diff --git a/Directory.Build.props b/Directory.Build.props index b3136d2e..d080b6fa 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 1 - 4 + 2 + 0 0 diff --git a/Hyperbee.Json.sln b/Hyperbee.Json.sln index 027c714e..33cf11ce 100644 --- a/Hyperbee.Json.sln +++ b/Hyperbee.Json.sln @@ -1,4 +1,3 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31912.275 @@ -36,16 +35,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hyperbee.Json.Tests", "test EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hyperbee.Json.Benchmark", "test\Hyperbee.Json.Benchmark\Hyperbee.Json.Benchmark.csproj", "{45C24D4B-4A0B-4FF1-AC66-38374D2455E9}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{13CB9B41-0462-4812-8B13-0BFD17F2BC18}" - ProjectSection(SolutionItems) = preProject - docs\ADDITIONAL-CLASSES.md = docs\ADDITIONAL-CLASSES.md - docs\index.md = docs\index.md - docs\JSONPATH-SYNTAX.md = docs\JSONPATH-SYNTAX.md - docs\_config.yml = docs\_config.yml - EndProjectSection -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hyperbee.Json.Cts", "test\Hyperbee.Json.Cts\Hyperbee.Json.Cts.csproj", "{CC1D3E7F-E6F1-432B-B4D1-9402AED24119}" EndProject +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "docs", "docs\docs.shproj", "{FC3B0A95-4DA0-43A0-A19D-624152BDF2F6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -77,10 +70,13 @@ Global {4DBDB7F5-3F66-4572-80B5-3322449C77A4} = {1FA7CE2A-C9DA-4DC3-A242-5A7EAF8EE4FC} {97886205-1467-4EE6-B3DA-496CA3D086E4} = {F9B24CD9-E06B-4834-84CB-8C29E5F10BE0} {45C24D4B-4A0B-4FF1-AC66-38374D2455E9} = {F9B24CD9-E06B-4834-84CB-8C29E5F10BE0} - {13CB9B41-0462-4812-8B13-0BFD17F2BC18} = {870D9301-BE3D-44EA-BF9C-FCC2E87FE4CD} {CC1D3E7F-E6F1-432B-B4D1-9402AED24119} = {F9B24CD9-E06B-4834-84CB-8C29E5F10BE0} + {FC3B0A95-4DA0-43A0-A19D-624152BDF2F6} = {870D9301-BE3D-44EA-BF9C-FCC2E87FE4CD} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {32874F5B-B467-4F28-A8E2-82C2536FB228} EndGlobalSection + GlobalSection(SharedMSBuildProjectFiles) = preSolution + docs\docs.projitems*{fc3b0a95-4da0-43a0-a19d-624152bdf2f6}*SharedItemsImports = 13 + EndGlobalSection EndGlobal diff --git a/README.md b/README.md index fb8ed686..5655f1b7 100644 --- a/README.md +++ b/README.md @@ -1,280 +1,62 @@ +# Welcome to Hyperbee.Json -# Hyperbee.Json +Hyperbee.Json is a high-performance JSON library for .NET, providing robust support for JSONPath, JsonPointer, JsonPatch, and JsonDiff. +This library is optimized for speed and low memory allocations, and it adheres to relevant RFCs to ensure reliable and predictable behavior. -`Hyperbee.Json` is a high-performance JSONPath parser for .NET, that supports both `JsonElement` and `JsonNode`. -The library is designed to be quick and extensible, allowing support for other JSON document types and functions. +Unlike other libraries that support only `JsonElement` or `JsonNode`, Hyperbee.Json supports **both** types, and can be easily extended to +support additional document types and functions. ## Features - **High Performance:** Optimized for performance and efficiency. -- **Supports:** `JsonElement` and `JsonNode`. -- **Extensible:** Easily extended to support additional JSON document types and functions. -- **`IEnumerable` Results:** Deferred execution queries with `IEnumerable`. -- **Conformant:** Adheres to the JSONPath Specification [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html). +- **Low Memory Allocations:** Designed to minimize memory usage. +- **Comprehensive JSON Support:** Supports JSONPath, JsonPointer, JsonPatch, and JsonDiff. +- **Conformance:** Adheres to the JSONPath Specification [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html), JSONPointer [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901.html), and JsonPatch [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html). +- **Supports both `JsonElement` and `JsonNode`:** Works seamlessly with both JSON document types. -## JSONPath RFC +## JSONPath -Hyperbee.Json conforms to the RFC, and aims to support the [JSONPath consensus](https://cburgmer.github.io/json-path-comparison) -when the RFC is unopinionated. When the RFC is unopinionated, and where the consensus is ambiguous or not aligned with our -performance and usability goals, we may deviate. Our goal is always to provide a robust and performant library while -strengthening our alignment with the RFC and the community. +JSONPath is a query language for JSON, allowing you to navigate and extract data from JSON documents using a set of path expressions. +Hyperbee.Json's JSONPath implementation is designed for optimal performance, ensuring low memory allocations and fast query execution. +It fully conforms to [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html). -## Installation +[Read more about JsonPath](jsonpath/index.md) +[Read more about JsonPath syntax](jsonpath/jsonpath-syntax.md) -Install via NuGet: - -```bash -dotnet add package Hyperbee.Json -``` - -## Usage +## JSONPointer -### Basic Examples - -#### Selecting Elements - -```csharp - -var json = """ -{ - "store": { - "book": [ - { "category": "fiction" }, - { "category": "science" } - ] - } -} -"""; +JSONPointer is a syntax for identifying a specific value within a JSON document. It is simple and easy to use, making it an excellent +choice for pinpointing exact values. Hyperbee.Json's JsonPointer implementation adheres to [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901.html). -var root = JsonDocument.Parse(json); -var result = JsonPath.Select(root, "$.store.book[*].category"); - -foreach (var item in result) -{ - Console.WriteLine(item); // Output: "fiction" and "science" -} -``` - -#### Filtering - -```csharp - -var json = """ -{ - "store": { - "book": [ - { - "category": "fiction", - "price": 10 - }, - { - "category": "science", - "price": 15 - } - ] - } -} -"""; +[Documentation for JsonPointer coming soon] -var root = JsonDocument.Parse(json); -var result = JsonPath.Select(root, "$.store.book[?(@.price > 10)]"); - -foreach (var item in result) -{ - Console.WriteLine(item); // Output: { "category": "science", "price": 15 } -} -``` - -#### Working with (JsonElement, Path) pairs -```csharp - -var json = """ -{ - "store": { - "book": [ - { "category": "fiction" }, - { "category": "science" } - ] - } -} -"""; - -var root = JsonDocument.Parse(json); -var result = JsonPath.SelectPath(root, "$.store.book[0].category"); - -var (node, path) = result.First(); - -Console.WriteLine(node); // Output: "fiction" -Console.WriteLine(path); // Output: "$.store.book[0].category -``` - -#### Working with JsonNode - -```csharp - -var json = """ -{ - "store": { - "book": [ - { "category": "fiction" }, - { "category": "science" } - ] - } -} -"""; - -var root = JsonNode.Parse(json); -var result = JsonPath.Select(root, "$.store.book[0].category"); - -Console.WriteLine(result.First()); // Output: "fiction" -``` +## JSONPatch -## JSONPath Syntax Overview +JSONPatch is a format for describing changes to a JSON document. It allows you to apply partial modifications to JSON data efficiently. +Hyperbee.Json supports JsonPatch as defined in [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html), ensuring compatibility and reliability. -Here's a quick overview of JSONPath syntax: +[Read more about JsonPatch](jsonpatch.md) -| JSONPath | Description -|:---------------------------------------------|:----------------------------------------------------------- -| `$` | Root node -| `@` | Current node -| `.`, `.''`, or `.""` | Object member dot operator -| `[]`, or `['']`, or `[""]` | Object member subscript operator -| `[` | Filter selector +## JSONDiff -JSONPath expressions refer to a JSON structure, and JSONPath assumes the name `$` is assigned -to the root JSON object. +JSONDiff allows you to compute the difference between two JSON documents, which is useful for versioning and synchronization. +Hyperbee.Json's implementation is optimized for performance and low memory usage, adhering to the standards set in [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html). -JSONPath expressions can use dot-notation: +[Read more about JsonDiff](jsonpatch.md) - $.store.book[0].title +## Getting Started -or bracket-notation: +To get started with Hyperbee.Json, refer to the documentation for detailed instructions and examples. Install the library via NuGet: - $['store']['book'][0]['title'] - -- JSONPath allows the wildcard symbol `*` for member names and array indices. -- It borrows the descendant operator `..` from [E4X][e4x] -- It uses the `@` symbol to refer to the current object. -- It uses `?` syntax for filtering. -- It uses the array slice syntax proposal `[start:end:step]` from ECMASCRIPT 4. - -Expressions can be used as an alternative to explicit names or indices, as in: - - $.store.book[(length(@)-1)].title - -Filter expressions are supported via the syntax `?()`, as in: - - $.store.book[?(@.price < 10)].title - -### JSONPath Functions - -JsonPath expressions support basic method calls. - -| Method | Description | Example -|------------|--------------------------------------------------------|------------------------------------------------ -| `length()` | Returns the length of an array or string. | `$.store.book[?(length(@.title) > 5)]` -| `count()` | Returns the count of matching elements. | `$.store.book[?(count(@.authors) > 1)]` -| `match()` | Returns true if a string matches a regular expression. | `$.store.book[?(match(@.title,'.*Century.*'))]` -| `search()` | Searches for a string within another string. | `$.store.book[?(search(@.title,'Sword'))]` -| `value()` | Accesses the value of a key in the current object. | `$.store.book[?(value(@.price) < 10)]` - -### JSONPath Extended Syntax - -The library extends the JSONPath expression syntax to support additional features. - -| Operators | Description | Example -|---------------------|-----------------------------------------------|------------------------------------------------ -| `+` `-` `*` `\` `%` | Basic math operators. | `$[?(@.a + @.b == 3)]` -| `in` | Tests is a value is in a set. | `$[?@.value in ['a', 'b', 'c'] ]` - - -### JSONPath Custom Functions - -You can extend the supported function set by registering your own functions. - -**Example:** Implement a `JsonNode` Path Function: - -**Example:** Implement a `JsonNode` Path Function: - -**Step 1:** Create a custom function that returns the path of a `JsonNode`. - -```csharp -public class PathNodeFunction() : ExtensionFunction( PathMethod, CompareConstraint.MustCompare ) -{ - public const string Name = "path"; - private static readonly MethodInfo PathMethod = GetMethod( nameof( Path ) ); - - private static ScalarValue Path( IValueType argument ) - { - return argument.TryGetNode( out var node ) ? node?.GetPath() : null; - } -} -``` - -**Step 2:** Register your custom function. - -```csharp -JsonTypeDescriptorRegistry.GetDescriptor().Functions - .Register( PathNodeFunction.Name, () => new PathNodeFunction() ); -``` - -**Step 3:** Use your custom function in a JSONPath query. +Install via NuGet: -```csharp -var results = source.Select( "$..[?path(@) == '$.store.book[2].title']" ); +```bash +dotnet add package Hyperbee.Json ``` -## Why Choose [Hyperbee.Json](https://github.com/Stillpoint-Software/Hyperbee.Json) ? +## Documentation -- High Performance. -- Supports both `JsonElement`, and `JsonNode`. -- Deferred execution queries with `IEnumerable`. -- Enhanced JsonPath syntax. -- Extendable to support additional JSON document types. -- RFC conforming JSONPath implementation. - -## Comparison with Other Libraries - -There are excellent libraries available for RFC-9535 .NET JsonPath. - -### [JsonPath.Net](https://docs.json-everything.net/path/basics/) Json-Everything - -- **Pros:** - - Comprehensive feature set. - - Deferred execution queries with `IEnumerable`. - - Enhanced JsonPath syntax. - - Strong community support. - -- **Cons:** - - No support for `JsonElement`. - - More memory intensive. - - Not quite as fast as other `System.Text.Json` implementations. - -### [JsonCons.NET](https://danielaparker.github.io/JsonCons.Net/articles/JsonPath/JsonConsJsonPath.html) - -- **Pros:** - - High performance. - - Enhanced JsonPath syntax. - -- **Cons:** - - No support for `JsonNode`. - - Does not return an `IEnumerable` result (no defered query execution). - -### [Json.NET](https://www.newtonsoft.com/json) Newtonsoft - -- **Pros:** - - Comprehensive feature set. - - Deferred execution queries with `IEnumerable`. - - Documentation and examples. - - Strong community support. - -- **Cons:** - - No support for `JsonElement`, or `JsonNode`. +Documentation can be found in the project's `/docs` folder. ## Benchmarks @@ -319,46 +101,42 @@ Here is a performance comparison of various queries on the standard book store d } ``` -| Method | Mean | Error | StdDev | Allocated -|------------------------ |----------:|-----------:|----------:|---------: -| `$..* First()` -| Hyperbee_JsonElement | 3.105 us | 1.6501 us | 0.0904 us | 3.52 KB -| JsonEverything_JsonNode | 3.278 us | 3.3157 us | 0.1817 us | 3.53 KB -| Hyperbee_JsonNode | 3.302 us | 3.2094 us | 0.1759 us | 3.09 KB -| JsonCons_JsonElement | 6.170 us | 4.1597 us | 0.2280 us | 8.48 KB -| Newtonsoft_JObject | 8.708 us | 8.7586 us | 0.4801 us | 14.22 KB -| | | | | -| `$..*` -| JsonCons_JsonElement | 5.792 us | 6.6920 us | 0.3668 us | 8.45 KB -| Hyperbee_JsonElement | 7.504 us | 7.6479 us | 0.4192 us | 9.13 KB -| Hyperbee_JsonNode | 10.320 us | 5.6676 us | 0.3107 us | 10.91 KB -| Newtonsoft_JObject | 10.862 us | 0.4374 us | 0.0240 us | 14.86 KB -| JsonEverything_JsonNode | 21.914 us | 19.4680 us | 1.0671 us | 36.81 KB -| | | | | -| `$..price` -| Hyperbee_JsonElement | 4.557 us | 3.6801 us | 0.2017 us | 4.2 KB -| JsonCons_JsonElement | 4.989 us | 2.3125 us | 0.1268 us | 5.65 KB -| Hyperbee_JsonNode | 7.929 us | 0.6128 us | 0.0336 us | 7.48 KB -| Newtonsoft_JObject | 10.511 us | 11.4901 us | 0.6298 us | 14.4 KB -| JsonEverything_JsonNode | 15.999 us | 0.5210 us | 0.0286 us | 27.63 KB -| | | | | -| `$.store.book[?@.price == 8.99]` -| Hyperbee_JsonElement | 4.221 us | 2.4758 us | 0.1357 us | 5.24 KB -| JsonCons_JsonElement | 5.424 us | 0.3551 us | 0.0195 us | 5.05 KB -| Hyperbee_JsonNode | 7.023 us | 7.0447 us | 0.3861 us | 8 KB -| Newtonsoft_JObject | 10.572 us | 2.4203 us | 0.1327 us | 15.84 KB -| JsonEverything_JsonNode | 12.478 us | 0.5762 us | 0.0316 us | 15.85 KB -| | | | | -| `$.store.book[0]` -| Hyperbee_JsonElement | 2.720 us | 1.9771 us | 0.1084 us | 2.27 KB -| JsonCons_JsonElement | 3.266 us | 0.2087 us | 0.0114 us | 3.21 KB -| Hyperbee_JsonNode | 3.396 us | 0.5137 us | 0.0282 us | 2.77 KB -| JsonEverything_JsonNode | 5.088 us | 0.1202 us | 0.0066 us | 5.96 KB -| Newtonsoft_JObject | 9.178 us | 9.5618 us | 0.5241 us | 14.56 KB - -## Additional Documentation - -Additional documentation can be found in the project's `/docs` folder. + | Method | Mean | Error | StdDev | Allocated + | :----------------------- | ---------: | ----------: | ---------: | ---------: + | `$..* First()` + | Hyperbee_JsonElement | 2.874 μs | 1.6256 μs | 0.0891 μs | 3.52 KB + | Hyperbee_JsonNode | 3.173 μs | 0.7979 μs | 0.0437 μs | 3.09 KB + | JsonEverything_JsonNode | 3.199 μs | 2.4697 μs | 0.1354 μs | 3.53 KB + | JsonCons_JsonElement | 5.976 μs | 8.4042 μs | 0.4607 μs | 8.48 KB + | Newtonsoft_JObject | 9.219 μs | 2.9245 μs | 0.1603 μs | 14.22 KB + | | | | | + | `$..*` + | JsonCons_JsonElement | 5.674 μs | 3.8650 μs | 0.2119 μs | 8.45 KB + | Hyperbee_JsonElement | 7.934 μs | 3.5907 μs | 0.1968 μs | 9.13 KB + | Hyperbee_JsonNode | 10.457 μs | 7.7120 μs | 0.4227 μs | 10.91 KB + | Newtonsoft_JObject | 10.722 μs | 4.1310 μs | 0.2264 μs | 14.86 KB + | JsonEverything_JsonNode | 23.096 μs | 10.8629 μs | 0.5954 μs | 36.81 KB + | | | | | + | `$..price` + | Hyperbee_JsonElement | 4.428 μs | 4.6731 μs | 0.2561 μs | 4.2 KB + | JsonCons_JsonElement | 5.355 μs | 1.1624 μs | 0.0637 μs | 5.65 KB + | Hyperbee_JsonNode | 7.931 μs | 0.6970 μs | 0.0382 μs | 7.48 KB + | Newtonsoft_JObject | 10.334 μs | 8.2331 μs | 0.4513 μs | 14.4 KB + | JsonEverything_JsonNode | 17.000 μs | 14.9812 μs | 0.8212 μs | 27.63 KB + | | | | | + | `$.store.book[?(@.price == 8.99)]` + | Hyperbee_JsonElement | 4.153 μs | 3.6089 μs | 0.1978 μs | 5.24 KB + | JsonCons_JsonElement | 4.873 μs | 1.0395 μs | 0.0570 μs | 5.05 KB + | Hyperbee_JsonNode | 6.980 μs | 5.1007 μs | 0.2796 μs | 8 KB + | Newtonsoft_JObject | 10.629 μs | 3.9096 μs | 0.2143 μs | 15.84 KB + | JsonEverything_JsonNode | 11.133 μs | 7.2544 μs | 0.3976 μs | 15.85 KB + | | | | | + | `$.store.book[0]` + | Hyperbee_JsonElement | 2.677 μs | 2.2733 μs | 0.1246 μs | 2.27 KB + | Hyperbee_JsonNode | 3.126 μs | 3.5345 μs | 0.1937 μs | 2.77 KB + | JsonCons_JsonElement | 3.229 μs | 0.0681 μs | 0.0037 μs | 3.21 KB + | JsonEverything_JsonNode | 4.612 μs | 2.0037 μs | 0.1098 μs | 5.96 KB + | Newtonsoft_JObject | 9.627 μs | 1.1498 μs | 0.0630 μs | 14.56 KB ## Credits diff --git a/docs/ADDITIONAL-CLASSES.md b/docs/additional-classes.md similarity index 99% rename from docs/ADDITIONAL-CLASSES.md rename to docs/additional-classes.md index 1e159d0d..df5d47d5 100644 --- a/docs/ADDITIONAL-CLASSES.md +++ b/docs/additional-classes.md @@ -1,7 +1,7 @@ --- layout: default title: Additional Classes -nav_order: 3 +nav_order: 99 --- ## Additional Classes diff --git a/docs/docs.projitems b/docs/docs.projitems new file mode 100644 index 00000000..28add021 --- /dev/null +++ b/docs/docs.projitems @@ -0,0 +1,20 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + fc3b0a95-4da0-43a0-a19d-624152bdf2f6 + + + docs + + + + + + + + + + + \ No newline at end of file diff --git a/docs/docs.shproj b/docs/docs.shproj new file mode 100644 index 00000000..7e7b4b13 --- /dev/null +++ b/docs/docs.shproj @@ -0,0 +1,13 @@ + + + + fc3b0a95-4da0-43a0-a19d-624152bdf2f6 + 14.0 + + + + + + + + diff --git a/docs/index.md b/docs/index.md index 46a6bef1..991330b9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,81 @@ --- layout: default -title: Welcome to Hyperbee Json +title: Hyperbee Json nav_order: 1 --- -# Welcome to Hyperbee Json +# Welcome to Hyperbee.Json + +Hyperbee.Json is a high-performance JSON library for .NET, providing robust support for JSONPath, JsonPointer, JsonPatch, and JsonDiff. +This library is optimized for speed and low memory allocations, and it adheres to relevant RFCs to ensure reliable and predictable behavior. + +Unlike other libraries that support only `JsonElement` or `JsonNode`, Hyperbee.Json supports both types, and can be easily extended to +support additional document types and functions. + +## Features + +- **High Performance:** Optimized for performance and efficiency. +- **Low Memory Allocations:** Designed to minimize memory usage. +- **Comprehensive JSON Support:** Supports JSONPath, JsonPointer, JsonPatch, and JsonDiff. +- **Conformance:** Adheres to the JSONPath Specification [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html), JSONPointer [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901.html), and JsonPatch [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html). +- **Supports both `JsonElement` and `JsonNode`:** Works seamlessly with both JSON document types. + +## JSONPath + +JSONPath is a query language for JSON, allowing you to navigate and extract data from JSON documents using a set of path expressions. +Hyperbee.Json's JSONPath implementation is designed for optimal performance, ensuring low memory allocations and fast query execution. +It fully conforms to [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html). + +[Read more about JsonPath](jsonpath/index.md) +[Read more about JsonPath syntax](jsonpath/jsonpath-syntax.md) + +## JSONPointer + +JSONPointer is a syntax for identifying a specific value within a JSON document. It is simple and easy to use, making it an excellent +choice for pinpointing exact values. Hyperbee.Json's JsonPointer implementation adheres to [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901.html). + +[Documentation for JsonPointer coming soon] + +## JSONPatch + +JSONPatch is a format for describing changes to a JSON document. It allows you to apply partial modifications to JSON data efficiently. +Hyperbee.Json supports JsonPatch as defined in [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html), ensuring compatibility and reliability. + +[Read more about JsonPatch](jsonpatch.md) + +## JSONDiff + +JSONDiff allows you to compute the difference between two JSON documents, which is useful for versioning and synchronization. +Hyperbee.Json's implementation is optimized for performance and low memory usage, adhering to the standards set in [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html). + +[Read more about JsonDiff](jsonpatch.md) + +## Getting Started + +To get started with Hyperbee.Json, refer to the documentation for detailed instructions and examples. Install the library via NuGet: + +Install via NuGet: + +```bash +dotnet add package Hyperbee.Json +``` + +## Additional Documentation + +Additional documentation can be found in the project's `/docs` folder. + +## Credits + +Hyperbee.Json is built upon the great work of several open-source projects. Special thanks to: + +- System.Text.Json team for their work on the `System.Text.Json` library. +- Stefan Goessner for the original [JSONPath implementation](https://goessner.net/articles/JsonPath/). +- Atif Aziz's C# port of Goessner's JSONPath library [.NET JSONPath](https://github.com/atifaziz/JSONPath). +- Christoph Burgmer [JSONPath consensus effort](https://cburgmer.github.io/json-path-comparison). +- [JSONPath Compliance Test Suite Team](https://github.com/jsonpath-standard/jsonpath-compliance-test-suite). + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](https://github.com/Stillpoint-Software/.github/blob/main/.github/CONTRIBUTING.md) +for more details. + diff --git a/docs/jsonpatch.md b/docs/jsonpatch.md new file mode 100644 index 00000000..17949e8f --- /dev/null +++ b/docs/jsonpatch.md @@ -0,0 +1,122 @@ +--- +layout: default +title: JsonPatch +nav_order: 2 +--- + +# Hyperbee JsonPatch + +Hyperbee JsonPatch is a high-performance library for applying JSON patches to JSON documents, as defined in [RFC 6902](https://www.rfc-editor.org/rfc/rfc6902.html). It supports both `JsonElement` and `JsonNode`, allowing for efficient and flexible modifications of JSON data. + +## Features + +- **High Performance:** Optimized for speed and low memory allocations. +- **Supports:** `JsonElement` and `JsonNode`. +- **RFC Conformance:** Fully adheres to RFC 6902 for reliable behavior. + +## Usage + +### Applying a Patch + +You can use JsonPatch to apply a series of operations to a JSON document. + +```csharp +var json = """ +{ + "store": { + "book": [ + { "category": "fiction" }, + { "category": "science" } + ] + } +} +"""; + +var patch = """ +[ + { "op": "add", "path": "/store/book/0/title", "value": "New Book" }, + { "op": "remove", "path": "/store/book/1" } +] +"""; + +var document = JsonDocument.Parse( json ); +var jsonPath = JsonSerializer.Deserialize( patch ); + +jsonPath.Apply( document.RootElement, out var node ); // Apply updates a JsonNode (since elements cannot be modified) + +var value = JsonPathPointer.FromPointer( node, "/store/book/0/title" ); +Console.WriteLine( value ); // Output: "New Book" +``` + +### Using JsonNode + +JsonPatch also supports JsonNode for patch operations. + +```csharp +var json = """ +{ + "store": { + "book": [ + { "category": "fiction" }, + { "category": "science" } + ] + } +} +"""; + +var patch = """ +[ + { "op": "add", "path": "/store/book/0/title", "value": "New Book" }, + { "op": "remove", "path": "/store/book/1" } +] +"""; + +var node = JsonNode.Parse( json ); +var jsonPath = JsonSerializer.Deserialize( patch ); + +jsonPath.Apply( node ); // Apply modifies the JsonNode in place (does rollback changes if an error occurs) + +var value = JsonPathPointer.FromPointer( node, "/store/book/0/title" ); +Console.WriteLine( value ); // Output: "New Book" +``` + +### JsonPatch Operations + +JsonPatch can also be created manually using `PatchOperation` objects. + +```csharp +var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/store/book/0/title", From: null, "New Book" ), + new PatchOperation( PatchOperationType.Remove, "/store/book/1", From: null, Value: null ), +); +``` + +Or by using JsonDiff to generate a patch from two JSON documents. + +```csharp +var source = JsonNode.Parse( +""" + { + "first": "John" + } +""" ); + +var target = JsonNode.Parse( +""" + { + "first": "John", + "last": "Doe" + } +""" ); + +var patchOperations = JsonDiff.Diff( source, target ); + +var patch = JsonSerializer.Serialize( patchOperations ); +Console.WriteLine( patch ); // Output: [{"op":"add","path":"/last","value":"Doe"}] +``` + +## Why Choose Hyperbee JsonPatch? + +- **Fast and Efficient:** Designed for high performance and low memory usage. +- **Versatile:** Works seamlessly with both `JsonElement` and `JsonNode`. +- **Standards Compliant:** Adheres strictly to RFC 6902 for JSON Patch. diff --git a/docs/jsonpath/comparison.md b/docs/jsonpath/comparison.md new file mode 100644 index 00000000..b09f95da --- /dev/null +++ b/docs/jsonpath/comparison.md @@ -0,0 +1,124 @@ +--- +layout: default +title: Comparison +parent: JsonPath +nav_order: 4 +--- + +## Comparison with Other Libraries + +There are other excellent libraries .NET JsonPath. + +### [JsonPath.Net](https://docs.json-everything.net/path/basics/) Json-Everything + +- **Pros:** + - Comprehensive feature set. + - Deferred execution queries with `IEnumerable`. + - Enhanced JsonPath syntax. + - Strong community support. + +- **Cons:** + - No support for `JsonElement`. + - More memory intensive. + - Not quite as fast as other implementations. + +### [JsonCons.NET](https://danielaparker.github.io/JsonCons.Net/articles/JsonPath/JsonConsJsonPath.html) + +- **Pros:** + - High performance. + - Enhanced JsonPath syntax. + +- **Cons:** + - No support for `JsonNode`. + - Does not return an `IEnumerable` result (no defered query execution). + +### [Json.NET](https://www.newtonsoft.com/json) Newtonsoft + +- **Pros:** + - Comprehensive feature set. + - Deferred execution queries with `IEnumerable`. + - Documentation and examples. + - Strong community support. + +- **Cons:** + - No support for `JsonElement`, or `JsonNode`. + +## Benchmarks + +Here is a performance comparison of various queries on the standard book store document. + +```json +{ + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + ], + "bicycle": { + "color": "red", + "price": 19.95 + } + } +} +``` + + | Method | Mean | Error | StdDev | Allocated + | :----------------------- | ---------: | ----------: | ---------: | ---------: + | `$..* First()` + | Hyperbee_JsonElement | 2.874 μs | 1.6256 μs | 0.0891 μs | 3.52 KB + | Hyperbee_JsonNode | 3.173 μs | 0.7979 μs | 0.0437 μs | 3.09 KB + | JsonEverything_JsonNode | 3.199 μs | 2.4697 μs | 0.1354 μs | 3.53 KB + | JsonCons_JsonElement | 5.976 μs | 8.4042 μs | 0.4607 μs | 8.48 KB + | Newtonsoft_JObject | 9.219 μs | 2.9245 μs | 0.1603 μs | 14.22 KB + | | | | | + | `$..*` + | JsonCons_JsonElement | 5.674 μs | 3.8650 μs | 0.2119 μs | 8.45 KB + | Hyperbee_JsonElement | 7.934 μs | 3.5907 μs | 0.1968 μs | 9.13 KB + | Hyperbee_JsonNode | 10.457 μs | 7.7120 μs | 0.4227 μs | 10.91 KB + | Newtonsoft_JObject | 10.722 μs | 4.1310 μs | 0.2264 μs | 14.86 KB + | JsonEverything_JsonNode | 23.096 μs | 10.8629 μs | 0.5954 μs | 36.81 KB + | | | | | + | `$..price` + | Hyperbee_JsonElement | 4.428 μs | 4.6731 μs | 0.2561 μs | 4.2 KB + | JsonCons_JsonElement | 5.355 μs | 1.1624 μs | 0.0637 μs | 5.65 KB + | Hyperbee_JsonNode | 7.931 μs | 0.6970 μs | 0.0382 μs | 7.48 KB + | Newtonsoft_JObject | 10.334 μs | 8.2331 μs | 0.4513 μs | 14.4 KB + | JsonEverything_JsonNode | 17.000 μs | 14.9812 μs | 0.8212 μs | 27.63 KB + | | | | | + | `$.store.book[?(@.price == 8.99)]` + | Hyperbee_JsonElement | 4.153 μs | 3.6089 μs | 0.1978 μs | 5.24 KB + | JsonCons_JsonElement | 4.873 μs | 1.0395 μs | 0.0570 μs | 5.05 KB + | Hyperbee_JsonNode | 6.980 μs | 5.1007 μs | 0.2796 μs | 8 KB + | Newtonsoft_JObject | 10.629 μs | 3.9096 μs | 0.2143 μs | 15.84 KB + | JsonEverything_JsonNode | 11.133 μs | 7.2544 μs | 0.3976 μs | 15.85 KB + | | | | | + | `$.store.book[0]` + | Hyperbee_JsonElement | 2.677 μs | 2.2733 μs | 0.1246 μs | 2.27 KB + | Hyperbee_JsonNode | 3.126 μs | 3.5345 μs | 0.1937 μs | 2.77 KB + | JsonCons_JsonElement | 3.229 μs | 0.0681 μs | 0.0037 μs | 3.21 KB + | JsonEverything_JsonNode | 4.612 μs | 2.0037 μs | 0.1098 μs | 5.96 KB + | Newtonsoft_JObject | 9.627 μs | 1.1498 μs | 0.0630 μs | 14.56 KB diff --git a/docs/jsonpath/functions.md b/docs/jsonpath/functions.md new file mode 100644 index 00000000..7c460d1a --- /dev/null +++ b/docs/jsonpath/functions.md @@ -0,0 +1,53 @@ +--- +layout: default +title: Functions +parent: JsonPath +nav_order: 3 +--- + +# JSONPath Functions + +JsonPath expressions support basic method calls. + +| Method | Description | Example +|------------|--------------------------------------------------------|------------------------------------------------ +| `length()` | Returns the length of an array or string. | `$.store.book[?(length(@.title) > 5)]` +| `count()` | Returns the count of matching elements. | `$.store.book[?(count(@.authors) > 1)]` +| `match()` | Returns true if a string matches a regular expression. | `$.store.book[?(match(@.title,'.*Century.*'))]` +| `search()` | Searches for a string within another string. | `$.store.book[?(search(@.title,'Sword'))]` +| `value()` | Accesses the value of a key in the current object. | `$.store.book[?(value(@.price) < 10)]` + +## JSONPath Extensions Functions + +You can extend the supported function set by registering your own functions. + +**Example:** Implement a `JsonNode` Path Function: + +**Step 1:** Create a custom function that returns the path of a `JsonNode`. + +```csharp +public class PathNodeFunction() : ExtensionFunction( PathMethod, CompareConstraint.MustCompare ) +{ + public const string Name = "path"; + private static readonly MethodInfo PathMethod = GetMethod( nameof( Path ) ); + + private static ScalarValue Path( IValueType argument ) + { + return argument.TryGetNode( out var node ) ? node?.GetPath() : null; + } +} +``` + +**Step 2:** Register your custom function. + +```csharp +JsonTypeDescriptorRegistry.GetDescriptor().Functions + .Register( PathNodeFunction.Name, () => new PathNodeFunction() ); +``` + +**Step 3:** Use your custom function in a JSONPath query. + +```csharp +var results = source.Select( "$..[?path(@) == '$.store.book[2].title']" ); +``` + diff --git a/docs/jsonpath/jsonpath.md b/docs/jsonpath/jsonpath.md new file mode 100644 index 00000000..4bb161d4 --- /dev/null +++ b/docs/jsonpath/jsonpath.md @@ -0,0 +1,19 @@ +--- +layout: default +title: JsonPath +has_children: true +nav_order: 1 +--- + +# Hyperbee JsonPath + +Hyperbee JsonPath is a high-performance JSONPath parser for `System.Text.Json`, that supports both `JsonElement` and `JsonNode`. +The library is designed to be fast and extensible, allowing support for other JSON document types and functions. + +## Features + +- **High Performance:** Optimized for performance and low memory allocations. +- **Supports:** `JsonElement` and `JsonNode`. +- **Extensible:** Easily extended to support additional JSON document types and functions. +- **`IEnumerable` Results:** Deferred execution queries with `IEnumerable`. +- **Conformant:** Adheres to the JSONPath Specification [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html). diff --git a/docs/jsonpath/overview.md b/docs/jsonpath/overview.md new file mode 100644 index 00000000..afec6a12 --- /dev/null +++ b/docs/jsonpath/overview.md @@ -0,0 +1,221 @@ +--- +layout: default +title: Overview +parent: JsonPath +nav_order: 1 +--- + +# Hyperbee JsonPath + +Hyperbee JsonPath is a high-performance JSONPath parser for `System.Text.Json`, that supports both `JsonElement` and `JsonNode`. +The library is designed to be fast and extensible, allowing support for other JSON document types and functions. + +## Why Choose Hyperbee JsonPath? + +Hyperbee is fast, lightweight, fully RFC-9535 conforming, that supports **both** `JsonElement` and `JsonNode`. + +- High Performance, low allocating. +- Supports **both** `JsonElement`, and `JsonNode`. +- Deferred execution queries with `IEnumerable`. +- Enhanced JsonPath syntax. +- Extendable to support additional JSON document types. +- RFC conforming JSONPath implementation. + +## JSONPath RFC + +Hyperbee.Json conforms to the RFC-9535, and aims to support the [JSONPath consensus](https://cburgmer.github.io/json-path-comparison) +when the RFC is unopinionated. When the RFC is unopinionated, and where the consensus is ambiguous or not aligned with our +performance and usability goals, we may deviate. Our goal is to provide a robust and performant library while strengthening our alignment with the RFC and the community. + +## Usage + +### Selecting Elements + +```csharp + +var json = """ +{ + "store": { + "book": [ + { "category": "fiction" }, + { "category": "science" } + ] + } +} +"""; + +var root = JsonDocument.Parse(json); +var result = JsonPath.Select(root, "$.store.book[*].category"); + +foreach (var item in result) +{ + Console.WriteLine(item); // Output: "fiction" and "science" +} +``` + +### Selecting Nodes + +```csharp + +var json = """ +{ + "store": { + "book": [ + { "category": "fiction" }, + { "category": "science" } + ] + } +} +"""; + +var root = JsonNode.Parse(json); +var result = JsonPath.Select(root, "$.store.book[0].category"); + +Console.WriteLine(result.First()); // Output: "fiction" +``` + +### Selecting Elements with Path + +```csharp + +var json = """ +{ + "store": { + "book": [ + { "category": "fiction" }, + { "category": "science" } + ] + } +} +"""; + +var root = JsonDocument.Parse(json); +var (element, path) = JsonPath.SelectPath(root, "$.store.book[*].category").First(); + +Console.WriteLine(element); // Output: "fiction" +Console.WriteLine(path); // Output: "$.store.book[0].category" + +``` + + +## Comparison with Other Libraries + +There are other excellent libraries .NET JsonPath. + +### [JsonPath.Net](https://docs.json-everything.net/path/basics/) Json-Everything + +- **Pros:** + - Comprehensive feature set. + - Deferred execution queries with `IEnumerable`. + - Enhanced JsonPath syntax. + - Strong community support. + +- **Cons:** + - No support for `JsonElement`. + - More memory intensive. + - Not quite as fast as other implementations. + +### [JsonCons.NET](https://danielaparker.github.io/JsonCons.Net/articles/JsonPath/JsonConsJsonPath.html) + +- **Pros:** + - High performance. + - Enhanced JsonPath syntax. + +- **Cons:** + - No support for `JsonNode`. + - Does not return an `IEnumerable` result (no defered query execution). + +### [Json.NET](https://www.newtonsoft.com/json) Newtonsoft + +- **Pros:** + - Comprehensive feature set. + - Deferred execution queries with `IEnumerable`. + - Documentation and examples. + - Strong community support. + +- **Cons:** + - No support for `JsonElement`, or `JsonNode`. + +## Benchmarks + +Here is a performance comparison of various queries on the standard book store document. + +```json +{ + "store": { + "book": [ + { + "category": "reference", + "author": "Nigel Rees", + "title": "Sayings of the Century", + "price": 8.95 + }, + { + "category": "fiction", + "author": "Evelyn Waugh", + "title": "Sword of Honour", + "price": 12.99 + }, + { + "category": "fiction", + "author": "Herman Melville", + "title": "Moby Dick", + "isbn": "0-553-21311-3", + "price": 8.99 + }, + { + "category": "fiction", + "author": "J. R. R. Tolkien", + "title": "The Lord of the Rings", + "isbn": "0-395-19395-8", + "price": 22.99 + } + ], + "bicycle": { + "color": "red", + "price": 19.95 + } + } +} +``` + + | Method | Mean | Error | StdDev | Allocated + | :----------------------- | ---------: | ----------: | ---------: | ---------: + | `$..* First()` + | Hyperbee_JsonElement | 2.874 μs | 1.6256 μs | 0.0891 μs | 3.52 KB + | Hyperbee_JsonNode | 3.173 μs | 0.7979 μs | 0.0437 μs | 3.09 KB + | JsonEverything_JsonNode | 3.199 μs | 2.4697 μs | 0.1354 μs | 3.53 KB + | JsonCons_JsonElement | 5.976 μs | 8.4042 μs | 0.4607 μs | 8.48 KB + | Newtonsoft_JObject | 9.219 μs | 2.9245 μs | 0.1603 μs | 14.22 KB + | | | | | + | `$..*` + | JsonCons_JsonElement | 5.674 μs | 3.8650 μs | 0.2119 μs | 8.45 KB + | Hyperbee_JsonElement | 7.934 μs | 3.5907 μs | 0.1968 μs | 9.13 KB + | Hyperbee_JsonNode | 10.457 μs | 7.7120 μs | 0.4227 μs | 10.91 KB + | Newtonsoft_JObject | 10.722 μs | 4.1310 μs | 0.2264 μs | 14.86 KB + | JsonEverything_JsonNode | 23.096 μs | 10.8629 μs | 0.5954 μs | 36.81 KB + | | | | | + | `$..price` + | Hyperbee_JsonElement | 4.428 μs | 4.6731 μs | 0.2561 μs | 4.2 KB + | JsonCons_JsonElement | 5.355 μs | 1.1624 μs | 0.0637 μs | 5.65 KB + | Hyperbee_JsonNode | 7.931 μs | 0.6970 μs | 0.0382 μs | 7.48 KB + | Newtonsoft_JObject | 10.334 μs | 8.2331 μs | 0.4513 μs | 14.4 KB + | JsonEverything_JsonNode | 17.000 μs | 14.9812 μs | 0.8212 μs | 27.63 KB + | | | | | + | `$.store.book[?(@.price == 8.99)]` + | Hyperbee_JsonElement | 4.153 μs | 3.6089 μs | 0.1978 μs | 5.24 KB + | JsonCons_JsonElement | 4.873 μs | 1.0395 μs | 0.0570 μs | 5.05 KB + | Hyperbee_JsonNode | 6.980 μs | 5.1007 μs | 0.2796 μs | 8 KB + | Newtonsoft_JObject | 10.629 μs | 3.9096 μs | 0.2143 μs | 15.84 KB + | JsonEverything_JsonNode | 11.133 μs | 7.2544 μs | 0.3976 μs | 15.85 KB + | | | | | + | `$.store.book[0]` + | Hyperbee_JsonElement | 2.677 μs | 2.2733 μs | 0.1246 μs | 2.27 KB + | Hyperbee_JsonNode | 3.126 μs | 3.5345 μs | 0.1937 μs | 2.77 KB + | JsonCons_JsonElement | 3.229 μs | 0.0681 μs | 0.0037 μs | 3.21 KB + | JsonEverything_JsonNode | 4.612 μs | 2.0037 μs | 0.1098 μs | 5.96 KB + | Newtonsoft_JObject | 9.627 μs | 1.1498 μs | 0.0630 μs | 14.56 KB + +## Additional Documentation + +Additional documentation for [JsonPath syntax can be found here](jsonpath-syntax). diff --git a/docs/JSONPATH-SYNTAX.md b/docs/jsonpath/syntax.md similarity index 87% rename from docs/JSONPATH-SYNTAX.md rename to docs/jsonpath/syntax.md index f8a6fd3e..d7041e60 100644 --- a/docs/JSONPATH-SYNTAX.md +++ b/docs/jsonpath/syntax.md @@ -1,6 +1,7 @@ --- layout: default -title: JSONPath Syntax Reference +title: Syntax +parent: JsonPath nav_order: 2 --- @@ -9,7 +10,41 @@ nav_order: 2 JSONPath is a query language for JSON that allows you to extract specific values from JSON documents. This page outlines the syntax and operators supported by `Hyperbee.Json`. -## Basic Syntax and Operators +## JSONPath Overview + +JSONPath operates on JSON documents: + +* The special symbol `$` is used to reference the root JSON node. +* The special symbol `@` is used to reference the current JSON node. +* Queries can use dot-notation: `$.store.book[0].title`, or bracket-notation: `$['store']['book'][0]['title']` +* Filters may be used to conditionally include results: `$.store.book[?(@.price < 10)]` + +### JSONPath Syntax + +| JSONPath | Description +|:---------------------------------------------|:----------------------------------------------------------- +| `$` | Root JSON node +| `@` | Current JSON node +| `.`, `.''`, or `.""` | Object member dot operator +| `[]`, or `['']`, or `[""]` | Object member subscript operator +| `[` | Filter selector + +### JSONPath Extended Syntax + +The library extends the JSONPath expression syntax to support additional features. + +| Operators | Description | Example +|---------------------|-----------------------------------------------|------------------------------------------------ +| `+` `-` `*` `\` `%` | Basic math operators. | `$[?(@.a + @.b == 3)]` +| `in` | Tests is a value is in a set. | `$[?@.value in ['a', 'b', 'c'] ]` + + +## JSONPath Operators ### Root Node diff --git a/docs/jsonpointer.md b/docs/jsonpointer.md new file mode 100644 index 00000000..25fd2fad --- /dev/null +++ b/docs/jsonpointer.md @@ -0,0 +1,68 @@ +--- +layout: default +title: JsonPointer +nav_order: 3 +--- + +# Hyperbee JsonPointer + +Hyperbee JsonPointer provides a simple and efficient way to navigate JSON documents using pointer syntax, as defined in [RFC 6901](https://www.rfc-editor.org/rfc/rfc6901.html). It supports both `JsonElement` and `JsonNode`, making it a versatile tool for JSON manipulation in .NET. + +## Features + +- **High Performance:** Optimized for speed and efficiency. +- **Supports:** `JsonElement` and `JsonNode`. +- **RFC Conformance:** Fully adheres to RFC 6901 for reliable behavior. + +## Usage + +### Basic Usage + +You can use JsonPointer to retrieve a value from a JSON document. + +```csharp +var json = """ +{ + "store": { + "book": [ + { "category": "fiction" }, + { "category": "science" } + ] + } +} +"""; + +var document = JsonDocument.Parse(json); +var value = JsonPathPointer.FromPointer(document.RootElement, "/store/book/0/category") + +Console.WriteLine(value.GetString()); // Output: "fiction" +``` + +### Using JsonNode + +JsonPointer also supports JsonNode for pointer operations. + +```csharp +var json = """ +{ + "store": { + "book": [ + { "category": "fiction" }, + { "category": "science" } + ] + } +} +"""; + +var node = JsonNode.Parse(json); +var value = JsonPointer.FromPointer(node, "/store/book/1/category") + +Console.WriteLine(value.GetValue()); // Output: "science" +``` + +## Why Choose Hyperbee JsonPointer? + +- **Fast and Efficient:** Designed for high performance and low memory usage. +- **Versatile:** Works seamlessly with both `JsonElement` and `JsonNode`. +- **Standards Compliant:** Adheres strictly to RFC 6901 for JSON Pointer. + diff --git a/src/Hyperbee.Json/Internal/JsonElementAccessor.cs b/src/Hyperbee.Json/Core/JsonElementAccessor.cs similarity index 99% rename from src/Hyperbee.Json/Internal/JsonElementAccessor.cs rename to src/Hyperbee.Json/Core/JsonElementAccessor.cs index f72347d9..3c93486f 100644 --- a/src/Hyperbee.Json/Internal/JsonElementAccessor.cs +++ b/src/Hyperbee.Json/Core/JsonElementAccessor.cs @@ -2,7 +2,7 @@ using System.Reflection.Emit; using System.Text.Json; -namespace Hyperbee.Json.Internal; +namespace Hyperbee.Json.Core; internal static class JsonElementAccessor { diff --git a/src/Hyperbee.Json/JsonElementDeepEqualityComparer.cs b/src/Hyperbee.Json/Core/JsonElementDeepEqualityComparer.cs similarity index 99% rename from src/Hyperbee.Json/JsonElementDeepEqualityComparer.cs rename to src/Hyperbee.Json/Core/JsonElementDeepEqualityComparer.cs index 15fb5dd4..f3efac3c 100644 --- a/src/Hyperbee.Json/JsonElementDeepEqualityComparer.cs +++ b/src/Hyperbee.Json/Core/JsonElementDeepEqualityComparer.cs @@ -12,7 +12,7 @@ // #endregion -namespace Hyperbee.Json; +namespace Hyperbee.Json.Core; // example 1: // diff --git a/src/Hyperbee.Json/JsonPathBuilder.cs b/src/Hyperbee.Json/Core/JsonPathBuilder.cs similarity index 99% rename from src/Hyperbee.Json/JsonPathBuilder.cs rename to src/Hyperbee.Json/Core/JsonPathBuilder.cs index a059ab50..a60bb60c 100644 --- a/src/Hyperbee.Json/JsonPathBuilder.cs +++ b/src/Hyperbee.Json/Core/JsonPathBuilder.cs @@ -1,9 +1,8 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; -using Hyperbee.Json.Internal; -namespace Hyperbee.Json; +namespace Hyperbee.Json.Core; public class JsonPathBuilder { diff --git a/src/Hyperbee.Json/JsonPathSliceSyntaxHelper.cs b/src/Hyperbee.Json/Core/SliceSyntaxHelper.cs similarity index 93% rename from src/Hyperbee.Json/JsonPathSliceSyntaxHelper.cs rename to src/Hyperbee.Json/Core/SliceSyntaxHelper.cs index 86884ef7..398ba32b 100644 --- a/src/Hyperbee.Json/JsonPathSliceSyntaxHelper.cs +++ b/src/Hyperbee.Json/Core/SliceSyntaxHelper.cs @@ -1,9 +1,8 @@ using System.Globalization; -using Hyperbee.Json.Internal; -namespace Hyperbee.Json; +namespace Hyperbee.Json.Core; -internal static class JsonPathSliceSyntaxHelper +public static class SliceSyntaxHelper { // parse slice expression and return normalized bounds public static (int Lower, int Upper, int Step) ParseExpression( ReadOnlySpan sliceExpr, int length, bool reverse = false ) @@ -50,14 +49,14 @@ public static (int Lower, int Upper, int Step) ParseExpression( ReadOnlySpan part, int length ) { // a little magic for overflow and underflow conditions cause by massive steps. - // just scope the step to length + 1 or -length - 1. + // just scope the step to length or -length. if ( !part.IsEmpty && long.TryParse( part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n ) ) { return n switch { - > 0 when n > length => length + 1, - < 0 when -n > length => -(length + 1), + > 0 when n > length => length, + < 0 when -n > length => -length, _ => (int) n }; } diff --git a/src/Hyperbee.Json/Internal/SpanHelper.cs b/src/Hyperbee.Json/Core/SpanHelper.cs similarity index 96% rename from src/Hyperbee.Json/Internal/SpanHelper.cs rename to src/Hyperbee.Json/Core/SpanHelper.cs index 98fc9583..273ad129 100644 --- a/src/Hyperbee.Json/Internal/SpanHelper.cs +++ b/src/Hyperbee.Json/Core/SpanHelper.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Internal; +namespace Hyperbee.Json.Core; internal enum SpanUnescapeOptions { @@ -9,7 +9,7 @@ internal enum SpanUnescapeOptions internal static class SpanHelper { - internal static void Unescape( ReadOnlySpan span, ref SpanBuilder builder, SpanUnescapeOptions options ) + internal static void Unescape( ReadOnlySpan span, ref ValueStringBuilder builder, SpanUnescapeOptions options ) { if ( options == SpanUnescapeOptions.Single || options == SpanUnescapeOptions.SingleThenUnquote ) { @@ -49,7 +49,7 @@ internal static void Unescape( ReadOnlySpan span, ref SpanBuilder builder, } } - private static int UnescapeQuotedString( ReadOnlySpan span, char quoteChar, ref SpanBuilder builder ) + private static int UnescapeQuotedString( ReadOnlySpan span, char quoteChar, ref ValueStringBuilder builder ) { for ( var i = 0; i < span.Length; i++ ) { diff --git a/src/Hyperbee.Json/Internal/SpanSplitOptions.cs b/src/Hyperbee.Json/Core/SpanSplitOptions.cs similarity index 73% rename from src/Hyperbee.Json/Internal/SpanSplitOptions.cs rename to src/Hyperbee.Json/Core/SpanSplitOptions.cs index f92259c0..77c47d81 100644 --- a/src/Hyperbee.Json/Internal/SpanSplitOptions.cs +++ b/src/Hyperbee.Json/Core/SpanSplitOptions.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Internal; +namespace Hyperbee.Json.Core; [Flags] internal enum SpanSplitOptions diff --git a/src/Hyperbee.Json/Internal/SpanSplitter.cs b/src/Hyperbee.Json/Core/SpanSplitter.cs similarity index 98% rename from src/Hyperbee.Json/Internal/SpanSplitter.cs rename to src/Hyperbee.Json/Core/SpanSplitter.cs index 81f44d68..f1924d7e 100644 --- a/src/Hyperbee.Json/Internal/SpanSplitter.cs +++ b/src/Hyperbee.Json/Core/SpanSplitter.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Internal; +namespace Hyperbee.Json.Core; // copied here from Hyperbee.Core to prevent additional assembly dependency /* diff --git a/src/Hyperbee.Json/Internal/SpanBuilder.cs b/src/Hyperbee.Json/Core/ValueStringBuilder.cs similarity index 56% rename from src/Hyperbee.Json/Internal/SpanBuilder.cs rename to src/Hyperbee.Json/Core/ValueStringBuilder.cs index 24c423d4..74bbc6f7 100644 --- a/src/Hyperbee.Json/Internal/SpanBuilder.cs +++ b/src/Hyperbee.Json/Core/ValueStringBuilder.cs @@ -1,25 +1,30 @@ - +using System.Buffers; using System.Runtime.CompilerServices; -namespace Hyperbee.Json.Internal; +namespace Hyperbee.Json.Core; -using System; -using System.Buffers; - -internal ref struct SpanBuilder // use in a try finally with an explicit Dispose +internal ref struct ValueStringBuilder // use in a try finally with an explicit Dispose { - private char[] _buffer; + private char[] _arrayPoolBuffer; private Span _chars; private int _pos; - public SpanBuilder( int initialCapacity ) + public ValueStringBuilder( int initialCapacity ) + { + _arrayPoolBuffer = ArrayPool.Shared.Rent( initialCapacity ); + _chars = _arrayPoolBuffer; + _pos = 0; + } + + public ValueStringBuilder( Span initialBuffer ) { - _buffer = ArrayPool.Shared.Rent( initialCapacity ); - _chars = _buffer; + _arrayPoolBuffer = null; + _chars = initialBuffer; _pos = 0; } public readonly bool IsEmpty => _pos == 0; + public readonly int Length => _pos; public void Append( char value ) { @@ -41,6 +46,7 @@ public void Append( ReadOnlySpan value ) public void Clear() => _pos = 0; public readonly ReadOnlySpan AsSpan() => _chars[.._pos]; + public readonly string AsString() => _chars[.._pos].ToString(); private void Grow( int additionalCapacity = 0 ) { @@ -48,14 +54,16 @@ private void Grow( int additionalCapacity = 0 ) var newArray = ArrayPool.Shared.Rent( newCapacity ); _chars.CopyTo( newArray ); - ArrayPool.Shared.Return( _buffer ); - _buffer = newArray; + if ( _arrayPoolBuffer != null ) + ArrayPool.Shared.Return( _arrayPoolBuffer ); + + _arrayPoolBuffer = newArray; _chars = newArray; } - public override string ToString() + public override string ToString() // disposes { - var value = _chars[.._pos].ToString(); + var value = AsString(); Dispose(); return value; } @@ -63,14 +71,10 @@ public override string ToString() [MethodImpl( MethodImplOptions.AggressiveInlining )] public void Dispose() { - var array = _buffer; + var arrayPoolBuffer = _arrayPoolBuffer; this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again - if ( array != null ) - ArrayPool.Shared.Return( array ); - - _buffer = null; - _chars = default; - _pos = 0; + if ( arrayPoolBuffer != null ) + ArrayPool.Shared.Return( arrayPoolBuffer ); } } diff --git a/src/Hyperbee.Json/Descriptors/Element/ElementActions.cs b/src/Hyperbee.Json/Descriptors/Element/ElementActions.cs new file mode 100644 index 00000000..293ae663 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/ElementActions.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using Hyperbee.Json.Extensions; +using Hyperbee.Json.Path; +using Hyperbee.Json.Pointer; +using Hyperbee.Json.Query; + +namespace Hyperbee.Json.Descriptors.Element; + +internal class ElementActions : INodeActions +{ + public bool TryParse( ref Utf8JsonReader reader, out JsonElement element ) + { + try + { + if ( JsonDocument.TryParseValue( ref reader, out var document ) ) + { + element = document.RootElement; + return true; + } + } + catch + { + // ignored: fall through + } + + element = default; + return false; + } + + public bool TryGetFromPointer( in JsonElement node, JsonSegment segment, out JsonElement value ) => + SegmentPointer.TryGetFromPointer( node, segment, out _, out value ); + + public bool DeepEquals( JsonElement left, JsonElement right ) => + left.DeepEquals( right ); + + public IEnumerable<(JsonElement Value, string Key)> GetChildren( in JsonElement value, bool complexTypesOnly = false ) + { + // allocating is faster than using yield return and less memory intensive. + // using stack results in fewer overall allocations than calling reverse, + // which internally allocates, and then discards, a new array. + + switch ( value.ValueKind ) + { + case JsonValueKind.Array: + { + var length = value.GetArrayLength(); + var results = new Stack<(JsonElement, string)>( length ); // stack will reverse items + + for ( var index = 0; index < length; index++ ) + { + var child = value[index]; + + if ( complexTypesOnly && child.ValueKind is not (JsonValueKind.Array or JsonValueKind.Object) ) + continue; + + results.Push( (child, IndexHelper.GetIndexString( index )) ); + } + + return results; + } + case JsonValueKind.Object: + { + var results = new Stack<(JsonElement, string)>(); // stack will reverse items + + foreach ( var child in value.EnumerateObject() ) + { + if ( complexTypesOnly && child.Value.ValueKind is not (JsonValueKind.Array or JsonValueKind.Object) ) + continue; + + results.Push( (child.Value, child.Name) ); + } + + return results; + } + } + + return []; + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/ElementTypeDescriptor.cs b/src/Hyperbee.Json/Descriptors/Element/ElementTypeDescriptor.cs index 126c76d8..57947655 100644 --- a/src/Hyperbee.Json/Descriptors/Element/ElementTypeDescriptor.cs +++ b/src/Hyperbee.Json/Descriptors/Element/ElementTypeDescriptor.cs @@ -1,29 +1,15 @@ using System.Text.Json; using Hyperbee.Json.Descriptors.Element.Functions; -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; namespace Hyperbee.Json.Descriptors.Element; public class ElementTypeDescriptor : ITypeDescriptor { - private ElementValueAccessor _accessor; - private ValueTypeComparer _comparer; - private FilterRuntime _runtime; + public IValueAccessor ValueAccessor => new ElementValueAccessor(); + public INodeActions NodeActions => new ElementActions(); public FunctionRegistry Functions { get; } = new(); - public IValueAccessor Accessor => - _accessor ??= new ElementValueAccessor(); - - public IFilterRuntime FilterRuntime => - _runtime ??= new FilterRuntime(); - - public IValueTypeComparer Comparer => - _comparer ??= new ValueTypeComparer( Accessor ); - - public bool CanUsePointer => true; - public ElementTypeDescriptor() { Functions.Register( CountElementFunction.Name, () => new CountElementFunction() ); @@ -32,4 +18,5 @@ public ElementTypeDescriptor() Functions.Register( SearchElementFunction.Name, () => new SearchElementFunction() ); Functions.Register( ValueElementFunction.Name, () => new ValueElementFunction() ); } + } diff --git a/src/Hyperbee.Json/Descriptors/Element/ElementValueAccessor.cs b/src/Hyperbee.Json/Descriptors/Element/ElementValueAccessor.cs index 183202ce..a20e5b47 100644 --- a/src/Hyperbee.Json/Descriptors/Element/ElementValueAccessor.cs +++ b/src/Hyperbee.Json/Descriptors/Element/ElementValueAccessor.cs @@ -1,80 +1,44 @@ -using System.Globalization; -using System.Runtime.CompilerServices; -using System.Text; +using System.Runtime.CompilerServices; using System.Text.Json; -using Hyperbee.Json.Extensions; namespace Hyperbee.Json.Descriptors.Element; -internal class ElementValueAccessor : IValueAccessor +internal sealed class ElementValueAccessor : IValueAccessor { - public IEnumerable<(JsonElement, string, SelectorKind)> EnumerateChildren( JsonElement value, bool includeValues = true ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public NodeKind GetNodeKind( in JsonElement value ) { - // allocating is faster than using yield return and less memory intensive - // because we avoid calling reverse on the enumerable (which anyway allocates a new array) - - switch ( value.ValueKind ) + return value.ValueKind switch { - case JsonValueKind.Array: - { - var length = value.GetArrayLength(); - var results = new (JsonElement, string, SelectorKind)[length]; - - var reverseIndex = length - 1; - for ( var index = 0; index < length; index++, reverseIndex-- ) - { - var child = value[index]; - - if ( includeValues || child.ValueKind is JsonValueKind.Array or JsonValueKind.Object ) - { - results[reverseIndex] = (child, index.ToString(), SelectorKind.Index); - } - } - - return results; - } - case JsonValueKind.Object: - { - var results = new Stack<(JsonElement, string, SelectorKind)>(); // stack will reverse the list - foreach ( var child in value.EnumerateObject() ) - { - if ( includeValues || child.Value.ValueKind is JsonValueKind.Array or JsonValueKind.Object ) - { - results.Push( (child.Value, child.Name, SelectorKind.Name) ); - } - } - - return results; - } - } - - return []; + JsonValueKind.Object => NodeKind.Object, + JsonValueKind.Array => NodeKind.Array, + _ => NodeKind.Value + }; } [MethodImpl( MethodImplOptions.AggressiveInlining )] - public bool TryGetElementAt( in JsonElement value, int index, out JsonElement element ) + public IEnumerable<(JsonElement, string)> EnumerateObject( in JsonElement value, bool excludeValues = false ) { - element = default; - - if ( index < 0 ) // flip negative index to positive - index = value.GetArrayLength() + index; - - if ( index < 0 || index >= value.GetArrayLength() ) // out of bounds - return false; - - element = value[index]; - return true; + if ( value.ValueKind != JsonValueKind.Object ) + return []; + + return !excludeValues + ? value.EnumerateObject().Select( x => (x.Value, x.Name) ) + : value.EnumerateObject() + .Where( x => x.Value.ValueKind == JsonValueKind.Object || x.Value.ValueKind == JsonValueKind.Array ) + .Select( x => (x.Value, x.Name) ); } [MethodImpl( MethodImplOptions.AggressiveInlining )] - public NodeKind GetNodeKind( in JsonElement value ) + public IEnumerable EnumerateArray( in JsonElement value, bool excludeValues = false ) { - return value.ValueKind switch - { - JsonValueKind.Object => NodeKind.Object, - JsonValueKind.Array => NodeKind.Array, - _ => NodeKind.Value - }; + if ( value.ValueKind != JsonValueKind.Array ) + return []; + + return !excludeValues + ? value.EnumerateArray() + : value.EnumerateArray() + .Where( x => x.ValueKind == JsonValueKind.Object || x.ValueKind == JsonValueKind.Array ); } [MethodImpl( MethodImplOptions.AggressiveInlining )] @@ -85,103 +49,56 @@ public int GetArrayLength( in JsonElement value ) : 0; } - public bool TryGetChild( in JsonElement value, string childSelector, SelectorKind selectorKind, out JsonElement childValue ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public JsonElement IndexAt( in JsonElement value, int index ) { - switch ( value.ValueKind ) - { - case JsonValueKind.Object: - if ( value.TryGetProperty( childSelector, out childValue ) ) - return true; - break; - - case JsonValueKind.Array: - if ( selectorKind == SelectorKind.Name ) - break; - - if ( int.TryParse( childSelector, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index ) ) - { - var arrayLength = value.GetArrayLength(); - - if ( index < 0 ) // flip negative index to positive - index = arrayLength + index; - - if ( index >= 0 && index < arrayLength ) - { - childValue = value[index]; - return true; - } - } - - break; - - default: - if ( !IsPathOperator( childSelector ) ) - throw new ArgumentException( $"Invalid child type '{childSelector}'. Expected child to be Object, Array or a path selector.", nameof( value ) ); - break; - } - - childValue = default; - return false; + if ( index < 0 ) // flip negative index to positive + index = value.GetArrayLength() + index; - [MethodImpl( MethodImplOptions.AggressiveInlining )] - static bool IsPathOperator( ReadOnlySpan x ) - { - return x.Length switch - { - 1 => x[0] == '*', - 2 => x[0] == '.' && x[1] == '.', - 3 => x[0] == '$', - _ => false - }; - } + return value[index]; } - public bool TryGetFromPointer( in JsonElement element, JsonPathSegment segment, out JsonElement childValue ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool TryGetIndexAt( in JsonElement value, int index, out JsonElement item ) { - return element.TryGetFromJsonPathPointer( segment, out childValue ); - } + if ( index < 0 ) // flip negative index to positive + index = value.GetArrayLength() + index; - // Filter Methods + if ( index < value.GetArrayLength() ) + { + item = value[index]; + return true; + } - public bool DeepEquals( JsonElement left, JsonElement right ) - { - return left.DeepEquals( right ); + item = default; // out of bounds + return false; } - public bool TryParseNode( ref Utf8JsonReader reader, out JsonElement element ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool TryGetProperty( in JsonElement value, string propertyName, out JsonElement propertyValue ) { - try - { - if ( JsonDocument.TryParseValue( ref reader, out var document ) ) - { - element = document.RootElement; - return true; - } - } - catch - { - // ignored: fall through - } + if ( value.ValueKind == JsonValueKind.Object && value.TryGetProperty( propertyName, out propertyValue ) ) + return true; - element = default; + propertyValue = default; return false; } - public bool TryGetValueFromNode( JsonElement element, out IConvertible value ) + public bool TryGetValue( JsonElement node, out IConvertible value ) { - switch ( element.ValueKind ) + switch ( node.ValueKind ) { case JsonValueKind.String: - value = element.GetString(); + value = node.GetString(); break; case JsonValueKind.Number: - if ( element.TryGetInt32( out int intValue ) ) + if ( node.TryGetInt32( out int intValue ) ) { value = intValue; break; } - if ( element.TryGetSingle( out float floatValue ) ) + if ( node.TryGetSingle( out float floatValue ) ) { value = floatValue; break; diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/CountElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/CountElementFunction.cs index ed6925ab..cb7bdb48 100644 --- a/src/Hyperbee.Json/Descriptors/Element/Functions/CountElementFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/CountElementFunction.cs @@ -1,7 +1,7 @@ using System.Reflection; using System.Text.Json; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Element.Functions; diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/LengthElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/LengthElementFunction.cs index 9ca19ca4..5a0fb7fd 100644 --- a/src/Hyperbee.Json/Descriptors/Element/Functions/LengthElementFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/LengthElementFunction.cs @@ -1,7 +1,7 @@ using System.Reflection; using System.Text.Json; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Element.Functions; @@ -10,23 +10,19 @@ public class LengthElementFunction() : ExtensionFunction( LengthMethod, CompareC public const string Name = "length"; private static readonly MethodInfo LengthMethod = GetMethod( nameof( Length ) ); - public static IValueType Length( IValueType argument ) + public static ScalarValue Length( IValueType argument ) { - switch ( argument.ValueKind ) + return argument.ValueKind switch { - case ValueKind.Scalar when argument.TryGetValue( out var value ): - return Scalar.Value( value.Length ); - - case ValueKind.NodeList when argument.TryGetNode( out var node ): - return node.ValueKind switch - { - JsonValueKind.String => Scalar.Value( node.GetString()?.Length ?? 0 ), - JsonValueKind.Array => Scalar.Value( node.GetArrayLength() ), - JsonValueKind.Object => Scalar.Value( node.EnumerateObject().Count() ), - _ => Scalar.Nothing - }; - } - - return Scalar.Nothing; + ValueKind.Scalar when argument.TryGetValue( out var value ) => value.Length, + ValueKind.NodeList when argument.TryGetNode( out var node ) => node.ValueKind switch + { + JsonValueKind.String => node.GetString()?.Length ?? 0, + JsonValueKind.Array => node.GetArrayLength(), + JsonValueKind.Object => node.EnumerateObject().Count(), + _ => Scalar.Nothing + }, + _ => Scalar.Nothing + }; } } diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/MatchElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/MatchElementFunction.cs index e9c2e873..7ad66016 100644 --- a/src/Hyperbee.Json/Descriptors/Element/Functions/MatchElementFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/MatchElementFunction.cs @@ -1,8 +1,8 @@ using System.Reflection; using System.Text.RegularExpressions; -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Element.Functions; diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/SearchElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/SearchElementFunction.cs index ce69a15f..f16632a4 100644 --- a/src/Hyperbee.Json/Descriptors/Element/Functions/SearchElementFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/SearchElementFunction.cs @@ -1,8 +1,8 @@ using System.Reflection; using System.Text.RegularExpressions; -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Element.Functions; diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/ValueElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/ValueElementFunction.cs index d04f7931..fa495d6b 100644 --- a/src/Hyperbee.Json/Descriptors/Element/Functions/ValueElementFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/ValueElementFunction.cs @@ -1,8 +1,8 @@ using System.Reflection; using System.Text.Json; using Hyperbee.Json.Extensions; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Element.Functions; diff --git a/src/Hyperbee.Json/Descriptors/Element/ValueTypeExtensions.cs b/src/Hyperbee.Json/Descriptors/Element/ValueTypeExtensions.cs index daa0daf1..f53bf201 100644 --- a/src/Hyperbee.Json/Descriptors/Element/ValueTypeExtensions.cs +++ b/src/Hyperbee.Json/Descriptors/Element/ValueTypeExtensions.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Hyperbee.Json.Extensions; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Element; diff --git a/src/Hyperbee.Json/Descriptors/FunctionRegistry.cs b/src/Hyperbee.Json/Descriptors/FunctionRegistry.cs index 4a06fbe7..32458913 100644 --- a/src/Hyperbee.Json/Descriptors/FunctionRegistry.cs +++ b/src/Hyperbee.Json/Descriptors/FunctionRegistry.cs @@ -1,7 +1,9 @@ -using Hyperbee.Json.Filters.Parser; +using Hyperbee.Json.Path.Filters.Parser; namespace Hyperbee.Json.Descriptors; +public delegate ExtensionFunction FunctionActivator(); + public sealed class FunctionRegistry { private Dictionary Functions { get; } = []; diff --git a/src/Hyperbee.Json/Descriptors/INodeActions.cs b/src/Hyperbee.Json/Descriptors/INodeActions.cs new file mode 100644 index 00000000..fbd01591 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/INodeActions.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using Hyperbee.Json.Query; + +namespace Hyperbee.Json.Descriptors; + +public interface INodeActions +{ + bool TryParse( ref Utf8JsonReader reader, out TNode value ); + + public bool TryGetFromPointer( in TNode node, JsonSegment segment, out TNode value ); + + public bool DeepEquals( TNode left, TNode right ); + + public IEnumerable<(TNode Value, string Key)> GetChildren( in TNode value, bool complexTypesOnly = false ); +} diff --git a/src/Hyperbee.Json/Descriptors/ITypeDescriptor.cs b/src/Hyperbee.Json/Descriptors/ITypeDescriptor.cs index 5bf8eed4..8ba00b03 100644 --- a/src/Hyperbee.Json/Descriptors/ITypeDescriptor.cs +++ b/src/Hyperbee.Json/Descriptors/ITypeDescriptor.cs @@ -1,11 +1,7 @@ -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; namespace Hyperbee.Json.Descriptors; -public delegate ExtensionFunction FunctionActivator(); - public interface ITypeDescriptor { public FunctionRegistry Functions { get; } @@ -13,15 +9,13 @@ public interface ITypeDescriptor public interface ITypeDescriptor : ITypeDescriptor { - public IValueAccessor Accessor { get; } - public IFilterRuntime FilterRuntime { get; } + public IValueAccessor ValueAccessor { get; } - public IValueTypeComparer Comparer { get; } - bool CanUsePointer { get; } + public INodeActions NodeActions { get; } - public void Deconstruct( out IValueAccessor valueAccessor, out IFilterRuntime filterRuntime ) + public void Deconstruct( out IValueAccessor valueAccessor, out INodeActions nodeActions ) { - valueAccessor = Accessor; - filterRuntime = FilterRuntime; + valueAccessor = ValueAccessor; + nodeActions = NodeActions; } } diff --git a/src/Hyperbee.Json/Descriptors/IValueAccessor.cs b/src/Hyperbee.Json/Descriptors/IValueAccessor.cs index 9ea58fac..4e7e5e85 100644 --- a/src/Hyperbee.Json/Descriptors/IValueAccessor.cs +++ b/src/Hyperbee.Json/Descriptors/IValueAccessor.cs @@ -1,17 +1,15 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Hyperbee.Json.Descriptors; +namespace Hyperbee.Json.Descriptors; public interface IValueAccessor { - IEnumerable<(TNode, string, SelectorKind)> EnumerateChildren( TNode value, bool includeValues = true ); - bool TryGetElementAt( in TNode value, int index, out TNode element ); NodeKind GetNodeKind( in TNode value ); + + IEnumerable<(TNode, string)> EnumerateObject( in TNode value, bool excludeValues = false ); + IEnumerable EnumerateArray( in TNode value, bool excludeValues = false ); + int GetArrayLength( in TNode value ); - bool TryGetChild( in TNode value, string childSelector, SelectorKind selectorKind, out TNode childValue ); - bool TryParseNode( ref Utf8JsonReader reader, out TNode value ); - bool DeepEquals( TNode left, TNode right ); - bool TryGetValueFromNode( TNode item, out IConvertible value ); - bool TryGetFromPointer( in TNode value, JsonPathSegment segment, out TNode childValue ); + TNode IndexAt( in TNode value, int index ); + bool TryGetIndexAt( in TNode value, int index, out TNode item ); + bool TryGetProperty( in TNode value, string propertyName, out TNode propertyValue ); + bool TryGetValue( TNode node, out IConvertible value ); } diff --git a/src/Hyperbee.Json/JsonTypeDescriptorRegistry.cs b/src/Hyperbee.Json/Descriptors/JsonTypeDescriptorRegistry.cs similarity index 88% rename from src/Hyperbee.Json/JsonTypeDescriptorRegistry.cs rename to src/Hyperbee.Json/Descriptors/JsonTypeDescriptorRegistry.cs index 9a989513..960f5ee9 100644 --- a/src/Hyperbee.Json/JsonTypeDescriptorRegistry.cs +++ b/src/Hyperbee.Json/Descriptors/JsonTypeDescriptorRegistry.cs @@ -1,8 +1,7 @@ -using Hyperbee.Json.Descriptors; -using Hyperbee.Json.Descriptors.Element; +using Hyperbee.Json.Descriptors.Element; using Hyperbee.Json.Descriptors.Node; -namespace Hyperbee.Json; +namespace Hyperbee.Json.Descriptors; public class JsonTypeDescriptorRegistry { diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/CountNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/CountNodeFunction.cs index d747e842..0d4cbc8d 100644 --- a/src/Hyperbee.Json/Descriptors/Node/Functions/CountNodeFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/CountNodeFunction.cs @@ -1,7 +1,7 @@ using System.Reflection; using System.Text.Json.Nodes; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Node.Functions; diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/LengthNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/LengthNodeFunction.cs index 0eab456e..9d9e866a 100644 --- a/src/Hyperbee.Json/Descriptors/Node/Functions/LengthNodeFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/LengthNodeFunction.cs @@ -1,8 +1,8 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Node.Functions; @@ -11,24 +11,19 @@ public class LengthNodeFunction() : ExtensionFunction( LengthMethod, CompareCons public const string Name = "length"; private static readonly MethodInfo LengthMethod = GetMethod( nameof( Length ) ); - public static IValueType Length( IValueType argument ) + public static ScalarValue Length( IValueType argument ) { - switch ( argument.ValueKind ) + return argument.ValueKind switch { - case ValueKind.Scalar when argument.TryGetValue( out var value ): - return Scalar.Value( value.Length ); - - case ValueKind.NodeList when argument.TryGetNode( out var node ): - return node?.GetValueKind() switch - { - JsonValueKind.String => Scalar.Value( node.GetValue()?.Length ?? 0 ), - JsonValueKind.Array => Scalar.Value( node.AsArray().Count ), - JsonValueKind.Object => Scalar.Value( node.AsObject().Count ), - _ => Scalar.Nothing - }; - } - - return Scalar.Nothing; + ValueKind.Scalar when argument.TryGetValue( out var value ) => value.Length, + ValueKind.NodeList when argument.TryGetNode( out var node ) => node?.GetValueKind() switch + { + JsonValueKind.String => node.GetValue()?.Length ?? 0, + JsonValueKind.Array => node.AsArray().Count, + JsonValueKind.Object => node.AsObject().Count, + _ => Scalar.Nothing + }, + _ => Scalar.Nothing + }; } - } diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/MatchNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/MatchNodeFunction.cs index 28520300..2e45280f 100644 --- a/src/Hyperbee.Json/Descriptors/Node/Functions/MatchNodeFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/MatchNodeFunction.cs @@ -1,8 +1,8 @@ using System.Reflection; using System.Text.RegularExpressions; -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Node.Functions; @@ -23,4 +23,3 @@ public static ScalarValue Match( IValueType argValue, IValueType argPatter return regex.IsMatch( value ); } } - diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/SearchNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/SearchNodeFunction.cs index 397f6227..47ef287e 100644 --- a/src/Hyperbee.Json/Descriptors/Node/Functions/SearchNodeFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/SearchNodeFunction.cs @@ -1,8 +1,8 @@ using System.Reflection; using System.Text.RegularExpressions; -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Node.Functions; diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/ValueNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/ValueNodeFunction.cs index 8ad200ae..75ca6cd5 100644 --- a/src/Hyperbee.Json/Descriptors/Node/Functions/ValueNodeFunction.cs +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/ValueNodeFunction.cs @@ -2,9 +2,8 @@ using System.Text.Json; using System.Text.Json.Nodes; using Hyperbee.Json.Extensions; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; -using Microsoft.VisualBasic; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Node.Functions; diff --git a/src/Hyperbee.Json/Descriptors/Node/NodeActions.cs b/src/Hyperbee.Json/Descriptors/Node/NodeActions.cs new file mode 100644 index 00000000..7f2f5358 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/NodeActions.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Hyperbee.Json.Path; +using Hyperbee.Json.Pointer; +using Hyperbee.Json.Query; + +namespace Hyperbee.Json.Descriptors.Node; + +internal class NodeActions : INodeActions +{ + public bool TryParse( ref Utf8JsonReader reader, out JsonNode node ) + { + try + { + node = JsonNode.Parse( ref reader ); + return true; + } + catch + { + node = null; + return false; + } + } + + public bool TryGetFromPointer( in JsonNode node, JsonSegment segment, out JsonNode value ) => + SegmentPointer.TryGetFromPointer( node, segment, out _, out value ); + + public bool DeepEquals( JsonNode left, JsonNode right ) => + JsonNode.DeepEquals( left, right ); + + public IEnumerable<(JsonNode Value, string Key)> GetChildren( in JsonNode value, bool complexTypesOnly = false ) + { + // allocating is faster than using yield return and less memory intensive. + // using stack results in fewer overall allocations than calling reverse, + // which internally allocates, and then discards, a new array. + + switch ( value ) + { + case JsonArray jsonArray: + { + var length = jsonArray.Count; + var results = new Stack<(JsonNode, string)>( length ); // stack will reverse items + + for ( var index = 0; index < length; index++ ) + { + var child = value[index]; + + if ( complexTypesOnly && child is not (JsonArray or JsonObject) ) + continue; + + results.Push( (child, IndexHelper.GetIndexString( index )) ); + } + + return results; + } + case JsonObject jsonObject: + { + var results = new Stack<(JsonNode, string)>(); // stack will reverse items + + foreach ( var child in jsonObject ) + { + if ( complexTypesOnly && child.Value is not (JsonArray or JsonObject) ) + continue; + + results.Push( (child.Value, child.Key) ); + } + + return results; + } + } + + return []; + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/NodeTypeDescriptor.cs b/src/Hyperbee.Json/Descriptors/Node/NodeTypeDescriptor.cs index bf2a9c85..d19ed3a0 100644 --- a/src/Hyperbee.Json/Descriptors/Node/NodeTypeDescriptor.cs +++ b/src/Hyperbee.Json/Descriptors/Node/NodeTypeDescriptor.cs @@ -1,29 +1,14 @@ using System.Text.Json.Nodes; using Hyperbee.Json.Descriptors.Node.Functions; -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; namespace Hyperbee.Json.Descriptors.Node; public class NodeTypeDescriptor : ITypeDescriptor { - private NodeValueAccessor _accessor; - private FilterRuntime _runtime; - private ValueTypeComparer _comparer; - + public IValueAccessor ValueAccessor => new NodeValueAccessor(); + public INodeActions NodeActions => new NodeActions(); public FunctionRegistry Functions { get; } = new(); - public IValueAccessor Accessor => - _accessor ??= new NodeValueAccessor(); - - public IFilterRuntime FilterRuntime => - _runtime ??= new FilterRuntime(); - - public IValueTypeComparer Comparer => - _comparer ??= new ValueTypeComparer( Accessor ); - - public bool CanUsePointer => true; - public NodeTypeDescriptor() { Functions.Register( CountNodeFunction.Name, () => new CountNodeFunction() ); diff --git a/src/Hyperbee.Json/Descriptors/Node/NodeValueAccessor.cs b/src/Hyperbee.Json/Descriptors/Node/NodeValueAccessor.cs index dc5d05a8..fe2cdd45 100644 --- a/src/Hyperbee.Json/Descriptors/Node/NodeValueAccessor.cs +++ b/src/Hyperbee.Json/Descriptors/Node/NodeValueAccessor.cs @@ -1,80 +1,56 @@ -using System.Globalization; -using System.Runtime.CompilerServices; -using System.Text; +using System.Runtime.CompilerServices; using System.Text.Json; using System.Text.Json.Nodes; -using Hyperbee.Json.Extensions; namespace Hyperbee.Json.Descriptors.Node; -internal class NodeValueAccessor : IValueAccessor +internal sealed class NodeValueAccessor : IValueAccessor { - public IEnumerable<(JsonNode, string, SelectorKind)> EnumerateChildren( JsonNode value, bool includeValues = true ) - { - // allocating is faster than using yield return and less memory intensive - // because we avoid calling reverse on the enumerable (which anyway allocates a new array) - switch ( value ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public NodeKind GetNodeKind( in JsonNode value ) + { + return value switch { - case JsonArray arrayValue: - { - var length = arrayValue.Count; - var results = new (JsonNode, string, SelectorKind)[length]; - - var reverseIndex = length - 1; - for ( var index = 0; index < length; index++, reverseIndex-- ) - { - var child = arrayValue[index]; - - if ( includeValues || child is JsonObject or JsonArray ) - { - results[reverseIndex] = (child, index.ToString(), SelectorKind.Index); - } - } - - return results; - } - case JsonObject objectValue: - { - var results = new Stack<(JsonNode, string, SelectorKind)>(); // stack will reverse the list - foreach ( var child in objectValue ) - { - if ( includeValues || child.Value is JsonObject or JsonArray ) - results.Push( (child.Value, child.Key, SelectorKind.Name) ); - } - - return results; - } - } - - return []; + JsonArray => NodeKind.Array, + JsonObject => NodeKind.Object, + _ => NodeKind.Value + }; } [MethodImpl( MethodImplOptions.AggressiveInlining )] - public bool TryGetElementAt( in JsonNode value, int index, out JsonNode element ) + public IEnumerable<(JsonNode, string)> EnumerateObject( in JsonNode value, bool excludeValues = false ) { - var array = (JsonArray) value; - element = null; - - if ( index < 0 ) // flip negative index to positive - index = array.Count + index; + if ( value is not JsonObject objectValue ) + return []; - if ( index < 0 || index >= array.Count ) // out of bounds - return false; + if ( !excludeValues ) + return objectValue.Select( x => (x.Value, x.Key) ); - element = value[index]; - return true; + return objectValue + .Where( x => + { + var valueKind = x.Value!.GetValueKind(); + return valueKind == JsonValueKind.Object || valueKind == JsonValueKind.Array; + } ) + .Select( x => (x.Value, x.Key) ); } [MethodImpl( MethodImplOptions.AggressiveInlining )] - public NodeKind GetNodeKind( in JsonNode value ) + public IEnumerable EnumerateArray( in JsonNode value, bool excludeValues = false ) { - return value switch - { - JsonArray => NodeKind.Array, - JsonObject => NodeKind.Object, - _ => NodeKind.Value - }; + if ( value is not JsonArray arrayValue ) + return []; + + if ( !excludeValues ) + return arrayValue; + + return arrayValue + .Where( x => + { + var valueKind = x.GetValueKind(); + return valueKind == JsonValueKind.Object || valueKind == JsonValueKind.Array; + } ); } [MethodImpl( MethodImplOptions.AggressiveInlining )] @@ -86,88 +62,46 @@ public int GetArrayLength( in JsonNode value ) return 0; } - public bool TryGetChild( in JsonNode value, string childSelector, SelectorKind selectorKind, out JsonNode childValue ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public JsonNode IndexAt( in JsonNode value, int index ) { - switch ( value ) - { - case JsonObject valueObject: - { - if ( valueObject.TryGetPropertyValue( childSelector, out childValue ) ) - return true; - - break; - } - case JsonArray valueArray: - { - if ( selectorKind == SelectorKind.Name ) - break; + var array = (JsonArray) value; - if ( int.TryParse( childSelector, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index ) ) - { - if ( index < 0 ) // flip negative index to positive - index = valueArray.Count + index; - - if ( index >= 0 && index < valueArray.Count ) - { - childValue = value[index]; - return true; - } - } + if ( index < 0 ) // flip negative index to positive + index = array.Count + index; - break; - } - default: - { - if ( !IsPathOperator( childSelector ) ) - throw new ArgumentException( $"Invalid child type '{childSelector}'. Expected child to be Object, Array or a path selector.", nameof( value ) ); + return value[index]; + } - break; - } - } + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool TryGetIndexAt( in JsonNode value, int index, out JsonNode item ) + { + var array = (JsonArray) value; - childValue = default; - return false; + if ( index < 0 ) // flip negative index to positive + index = array.Count + index; - [MethodImpl( MethodImplOptions.AggressiveInlining )] - static bool IsPathOperator( ReadOnlySpan x ) + if ( index < array.Count ) { - return x.Length switch - { - 1 => x[0] == '*', - 2 => x[0] == '.' && x[1] == '.', - 3 => x[0] == '$', - _ => false - }; + item = value[index]; + return true; } - } - - public bool TryGetFromPointer( in JsonNode node, JsonPathSegment segment, out JsonNode childValue ) - { - return node.TryGetFromJsonPathPointer( segment, out childValue ); - } - - // Filter methods - public bool DeepEquals( JsonNode left, JsonNode right ) - { - return JsonNode.DeepEquals( left, right ); + item = null; // out of bounds + return false; } - public bool TryParseNode( ref Utf8JsonReader reader, out JsonNode node ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool TryGetProperty( in JsonNode value, string propertyName, out JsonNode propertyValue ) { - try - { - node = JsonNode.Parse( ref reader ); + if ( value is JsonObject valueObject && valueObject.TryGetPropertyValue( propertyName, out propertyValue ) ) return true; - } - catch - { - node = null; - return false; - } + + propertyValue = default; + return false; } - public bool TryGetValueFromNode( JsonNode node, out IConvertible value ) + public bool TryGetValue( JsonNode node, out IConvertible value ) { switch ( node?.GetValueKind() ) { diff --git a/src/Hyperbee.Json/Descriptors/Node/ValueTypeExtensions.cs b/src/Hyperbee.Json/Descriptors/Node/ValueTypeExtensions.cs index dd773ee3..37fcf4eb 100644 --- a/src/Hyperbee.Json/Descriptors/Node/ValueTypeExtensions.cs +++ b/src/Hyperbee.Json/Descriptors/Node/ValueTypeExtensions.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; using Hyperbee.Json.Extensions; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; namespace Hyperbee.Json.Descriptors.Node; diff --git a/src/Hyperbee.Json/Dynamic/DynamicJsonElement.cs b/src/Hyperbee.Json/Dynamic/DynamicJsonElement.cs index 17d30f56..c153d4f7 100644 --- a/src/Hyperbee.Json/Dynamic/DynamicJsonElement.cs +++ b/src/Hyperbee.Json/Dynamic/DynamicJsonElement.cs @@ -1,5 +1,6 @@ using System.Dynamic; using System.Text.Json; +using Hyperbee.Json.Core; namespace Hyperbee.Json.Dynamic; diff --git a/src/Hyperbee.Json/Dynamic/DynamicJsonNode.cs b/src/Hyperbee.Json/Dynamic/DynamicJsonNode.cs index 873945d4..cebda493 100644 --- a/src/Hyperbee.Json/Dynamic/DynamicJsonNode.cs +++ b/src/Hyperbee.Json/Dynamic/DynamicJsonNode.cs @@ -1,7 +1,6 @@ using System.Dynamic; using System.Numerics; using System.Text.Json.Nodes; -using Hyperbee.Json.Extensions; namespace Hyperbee.Json.Dynamic; diff --git a/src/Hyperbee.Json/Extensions/JsonDynamicHelper.cs b/src/Hyperbee.Json/Dynamic/JsonDynamicHelper.cs similarity index 86% rename from src/Hyperbee.Json/Extensions/JsonDynamicHelper.cs rename to src/Hyperbee.Json/Dynamic/JsonDynamicHelper.cs index ec076695..2f4333d7 100644 --- a/src/Hyperbee.Json/Extensions/JsonDynamicHelper.cs +++ b/src/Hyperbee.Json/Dynamic/JsonDynamicHelper.cs @@ -1,8 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Hyperbee.Json.Dynamic; -namespace Hyperbee.Json.Extensions; +namespace Hyperbee.Json.Dynamic; public static class JsonDynamicHelper { diff --git a/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs b/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs index 8869ab39..7e50df9b 100644 --- a/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs +++ b/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Text.Json.Nodes; +using Hyperbee.Json.Core; namespace Hyperbee.Json.Extensions; @@ -11,4 +13,41 @@ public static class JsonElementExtensions var comparer = new JsonElementDeepEqualityComparer( options.MaxDepth ); return comparer.Equals( element1, element2 ); } + + public static JsonNode ConvertToNode( this JsonElement element ) + { + return element.ValueKind switch + { + JsonValueKind.Object => ConvertToObject( element ), + JsonValueKind.Array => ConvertToArray( element ), + JsonValueKind.String => JsonValue.Create( element.GetString() ), + JsonValueKind.Number => JsonValue.Create( element.GetSingle() ), // TODO: get best number type + JsonValueKind.True => JsonValue.Create( true ), + JsonValueKind.False => JsonValue.Create( false ), + JsonValueKind.Null => null, + JsonValueKind.Undefined => null, + _ => throw new NotSupportedException( $"Unsupported JsonValueKind: {element.ValueKind}" ) + }; + + static JsonObject ConvertToObject( JsonElement element ) + { + var jsonObject = new JsonObject(); + foreach ( JsonProperty property in element.EnumerateObject() ) + { + jsonObject[property.Name] = ConvertToNode( property.Value ); + } + return jsonObject; + } + + static JsonArray ConvertToArray( JsonElement element ) + { + var jsonArray = new JsonArray(); + foreach ( JsonElement item in element.EnumerateArray() ) + { + jsonArray.Add( ConvertToNode( item ) ); + } + return jsonArray; + } + } + } diff --git a/src/Hyperbee.Json/Extensions/JsonPathPointerExtensions.cs b/src/Hyperbee.Json/Extensions/JsonPathPointerExtensions.cs deleted file mode 100644 index 600eb9d4..00000000 --- a/src/Hyperbee.Json/Extensions/JsonPathPointerExtensions.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Hyperbee.Json.Extensions; - -// DISTINCT from JsonPath these extensions are intended to facilitate 'diving' for Json Properties -// using normalized paths. a normalized path is an absolute path that references a single element. -// Similar to JsonPointer but using JsonPath notation. -// -// syntax supports absolute paths; dotted notation, quoted names, and simple bracketed array accessors only. -// -// Json path style wildcard '*', '..', and '[a,b]' multi-result selector notations are NOT supported. - -public static class JsonPathPointerExtensions -{ - public static JsonElement FromJsonPathPointer( this JsonElement jsonElement, ReadOnlySpan pointer ) - { - var query = JsonPathQueryParser.Parse( pointer ); - var segment = query.Segments.Next; // skip the root segment - - return TryGetFromJsonPathPointer( jsonElement, segment, out var value ) ? value : default; - } - - internal static bool TryGetFromJsonPathPointer( this JsonElement jsonElement, JsonPathSegment segment, out JsonElement value ) - { - if ( !segment.IsNormalized ) - throw new NotSupportedException( "Unsupported JsonPath pointer query format." ); - - var current = jsonElement; - value = default; - - while ( !segment.IsFinal ) - { - var (selectorValue, selectorKind) = segment.Selectors[0]; - - switch ( selectorKind ) - { - case SelectorKind.Name: - { - if ( current.ValueKind != JsonValueKind.Object ) - return false; - - if ( !current.TryGetProperty( selectorValue, out var child ) ) - return false; - - current = child; - break; - } - - case SelectorKind.Index: - { - if ( current.ValueKind != JsonValueKind.Array ) - return false; - - var length = current.GetArrayLength(); - var index = int.Parse( selectorValue ); - - if ( index < 0 ) - index = length + index; - - if ( index < 0 || index >= length ) - return false; - - current = current[index]; - break; - } - - default: - throw new NotSupportedException( $"Unsupported {nameof( SelectorKind )}." ); - } - - segment = segment.Next; - } - - value = current; - return true; - } - - public static JsonNode FromJsonPathPointer( this JsonNode jsonNode, ReadOnlySpan pointer ) - { - var query = JsonPathQueryParser.Parse( pointer ); - var segment = query.Segments.Next; // skip the root segment - - return TryGetFromJsonPathPointer( jsonNode, segment, out var value ) ? value : default; - } - - public static bool TryGetFromJsonPathPointer( this JsonNode jsonNode, JsonPathSegment segment, out JsonNode value ) - { - if ( !segment.IsNormalized ) - throw new NotSupportedException( "Unsupported JsonPath pointer query format." ); - - var current = jsonNode; - value = default; - - while ( !segment.IsFinal ) - { - var (selectorValue, selectorKind) = segment.Selectors[0]; - - switch ( selectorKind ) - { - case SelectorKind.Name: - { - if ( current is not JsonObject jsonObject ) - return false; - - if ( !jsonObject.TryGetPropertyValue( selectorValue, out var child ) ) - return false; - - current = child; - break; - } - - case SelectorKind.Index: - { - if ( current is not JsonArray jsonArray ) - return false; - - var length = jsonArray.Count; - var index = int.Parse( selectorValue ); - - if ( index < 0 ) - index = length + index; - - if ( index < 0 || index >= length ) - return false; - - current = jsonArray[index]; - break; - } - - default: - throw new NotSupportedException( $"Unsupported {nameof( SelectorKind )}." ); - } - - segment = segment.Next; - } - - value = current; - return true; - } -} diff --git a/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs b/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs index da73a93e..6b428ae4 100644 --- a/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs +++ b/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs @@ -1,5 +1,8 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Hyperbee.Json.Core; +using Hyperbee.Json.Path; +using Hyperbee.Json.Query; namespace Hyperbee.Json.Extensions; @@ -36,7 +39,7 @@ public static IEnumerable Select( this JsonDocument document, strin yield break; - void NodeProcessor( in JsonElement parent, in JsonElement value, string key, in JsonPathSegment segment ) + void NodeProcessor( in JsonElement parent, in JsonElement value, string key, in JsonSegment segment ) { pathBuilder.InsertItem( parent, value, key ); // seed the path builder with the parent and value } diff --git a/src/Hyperbee.Json/Filters/IRegexp.cs b/src/Hyperbee.Json/Filters/IRegexp.cs deleted file mode 100644 index b3c7bdbc..00000000 --- a/src/Hyperbee.Json/Filters/IRegexp.cs +++ /dev/null @@ -1,111 +0,0 @@ -namespace Hyperbee.Json.Filters; - -public static class IRegexp -{ - public static string ConvertToIRegexp( ReadOnlySpan pattern ) - { - // RFC-9535 States that regular expressions must conform to the I-Regexp format (RFC-9485)​. - // - // This requirement impacts DotNet regex for the dot( . ) character and treatment of Surrogate Pairs. - // - // I-Regexp, addresses the expectation for the dot (.) character in regular expressions: - // The dot( . ) character should match any character except newline characters, including - // surrogate pairs, which are treated as single characters in the context of matching. - // - // Surrogate pairs are used to represent characters outside the BMP (Basic Multilingual Plane) - // in UTF-16 encoding. They consist of a high surrogate (D800-DBFF) and a low surrogate (DC00-DFFF), - // which are combined to represent a single character. DotNet does not handle surrogate pairs nicely. - // - // Further, DotNet regex does not match dot( . ) on `\r`, which is an expectation of the RFC-9535 - // compliance test suite. - // - // To address this, we need to rewrite the regex pattern to match the dot( . ) character as expected. - - // stackalloc a span to track positions for replacement - - if ( pattern.IsEmpty ) - return string.Empty; - - var patternSize = pattern.Length; - Span dotPositions = patternSize > 256 ? new bool[patternSize] : stackalloc bool[patternSize]; - - var inCharacterClass = false; - var dotCount = 0; - - for ( var i = 0; i < pattern.Length; i++ ) - { - var currentChar = pattern[i]; - - switch ( currentChar ) - { - case '\\': - i++; - break; - case '[': - inCharacterClass = true; - break; - case ']' when inCharacterClass: - inCharacterClass = false; - break; - case '.' when !inCharacterClass: - dotPositions[i] = true; - dotCount++; - break; - } - } - - if ( dotCount == 0 ) - return pattern.ToString(); - - /* - * Regex Rewrite Explanation: - * - * 1. Non-Capturing Group `(?: ... )` - * - The entire pattern is wrapped in a non-capturing group to group the regex parts together - * without capturing the matched text. - * - * 2. Negative Character Class `[^ ...]` - * - `[^\r\n]`: Match any character that is not a carriage return (`\r`) or newline (`\n`). - * - * 3. Surrogate Pair `\p{Cs}\p{Cs}` - * - `\p{Cs}`: Matches any character in the "Cs" (surrogate) Unicode category. - * - `\p{Cs}\p{Cs}`: Matches a surrogate pair, which consists of two surrogate characters in sequence. - * - * Overall Pattern: - * - The pattern matches either: - * 1. Any character that is not a newline (`\r` or `\n`), or - * 2. A surrogate pair (two surrogate characters in sequence). - * - * This ensures that the regex matches any character except newline and carriage return characters, - * while correctly handling surrogate pairs which are necessary for certain Unicode characters. - * - * Pattern: - * (?: - * (?[^\r\n]) # Match any character except \r and \n - * | - * \p{Cs}\p{Cs} # Match a surrogate pair (two surrogates in sequence) - * ) - */ - var replacement = @"(?:[^\r\n]|\p{Cs}\p{Cs})".AsSpan(); - - var newSize = pattern.Length + dotCount * (replacement.Length - 1); - Span buffer = newSize > 512 ? new char[newSize] : stackalloc char[newSize]; - - var bufferIndex = 0; - - for ( var i = 0; i < pattern.Length; i++ ) - { - if ( dotPositions[i] ) - { - replacement.CopyTo( buffer[bufferIndex..] ); - bufferIndex += replacement.Length; - } - else - { - buffer[bufferIndex++] = pattern[i]; - } - } - - return new string( buffer[..bufferIndex] ); - } -} diff --git a/src/Hyperbee.Json/Hyperbee.Json.csproj b/src/Hyperbee.Json/Hyperbee.Json.csproj index c0a3e1c4..8cc0c0de 100644 --- a/src/Hyperbee.Json/Hyperbee.Json.csproj +++ b/src/Hyperbee.Json/Hyperbee.Json.csproj @@ -2,21 +2,21 @@ net8.0 enable - true + true - Stillpoint Software, Inc. - README.md - json-path;jsonpath;query;path;json;rfc9535 - icon.png - https://github.com/Stillpoint-Software/Hyperbee.Json/ - net8.0 - LICENSE - Stillpoint Software, Inc. - Hyperbee Json - JSON Path (RFC 9535) implementation for System.Text.Json JsonElement, and JsonNode. - https://github.com/Stillpoint-Software/Hyperbee.Json - git - https://github.com/Stillpoint-Software/Hyperbee.Json/releases/latest + Stillpoint Software, Inc. + README.md + json-path;jsonpath;json-pointer;jsonpointer;json-patch;jsonpatch;query;path;patch;diff;json;rfc9535;rfc6901;rfc6902 + icon.png + https://stillpoint-software.github.io/hyperbee.json/ + net8.0 + LICENSE + Stillpoint Software, Inc. + Hyperbee Json + A high-performance JSON library for System.Text.Json JsonElement and JsonNode, providing robust support for JSONPath, JsonPointer, JsonPatch, and JsonDiff. + https://github.com/Stillpoint-Software/Hyperbee.Json + git + https://github.com/Stillpoint-Software/Hyperbee.Json/releases/latest @@ -34,7 +34,7 @@ - + all diff --git a/src/Hyperbee.Json/Patch/JsonDiff.cs b/src/Hyperbee.Json/Patch/JsonDiff.cs new file mode 100644 index 00000000..e46f9964 --- /dev/null +++ b/src/Hyperbee.Json/Patch/JsonDiff.cs @@ -0,0 +1,291 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Hyperbee.Json.Core; +using Hyperbee.Json.Descriptors; + +namespace Hyperbee.Json.Patch; + +public static class JsonDiff +{ + private readonly record struct DiffOperation( TNode Source, TNode Target, string Path ); + + private static readonly ITypeDescriptor Descriptor = JsonTypeDescriptorRegistry.GetDescriptor(); + + public static IEnumerable Diff( object source, object target ) + { + switch ( source ) + { + case TNode sourceNode when target is TNode targetNode: + return InternalDiff( sourceNode, targetNode ); + + default: + { + if ( typeof( TNode ) == typeof( JsonElement ) ) + { + var sourceElement = JsonSerializer.SerializeToElement( source ); + var targetElement = JsonSerializer.SerializeToElement( target ); + + return JsonDiff.InternalDiff( sourceElement, targetElement ); + } + + if ( typeof( TNode ) == typeof( JsonNode ) ) + { + var sourceNode = JsonSerializer.SerializeToNode( source ); + var targetNode = JsonSerializer.SerializeToNode( target ); + + return JsonDiff.InternalDiff( sourceNode, targetNode ); + } + + throw new NotSupportedException( $"Type {typeof( TNode )} is not supported." ); + } + } + } + + public static IEnumerable Diff( TNode source, TNode target ) + { + return InternalDiff( source, target ); + } + + private static PatchOperation[] InternalDiff( TNode source, TNode target ) + { + var stack = new Stack( 8 ); + var operations = new List( 8 ); + + stack.Push( new DiffOperation( source, target, string.Empty ) ); + + var accessor = Descriptor.ValueAccessor; + + while ( stack.Count > 0 ) + { + var operation = stack.Pop(); + + var sourceKind = accessor.GetNodeKind( operation.Source ); + var targetKind = accessor.GetNodeKind( operation.Target ); + + if ( sourceKind != targetKind ) + { + operations.Add( new PatchOperation { Operation = PatchOperationType.Replace, Path = operation.Path, Value = operation.Target } ); + } + else + { + switch ( sourceKind ) + { + case NodeKind.Object: + ProcessObjectDiff( operation, stack, operations ); + break; + + case NodeKind.Array: + ProcessArrayDiff( operation, stack, operations ); + break; + + case NodeKind.Value: + default: + ProcessValueDiff( operation, operations ); + + break; + } + } + } + + return [.. operations]; + } + + private static void ProcessObjectDiff( DiffOperation operation, Stack stack, List operations ) + { + var accessor = Descriptor.ValueAccessor; + + foreach ( var (value, name) in accessor.EnumerateObject( operation.Source ) ) + { + var propertyPath = Combine( operation.Path, name ); + + if ( accessor.TryGetProperty( operation.Target, name, out var targetValue ) ) + { + stack.Push( new DiffOperation( value, targetValue, propertyPath ) ); + } + else + { + operations.Add( new PatchOperation { Operation = PatchOperationType.Remove, Path = propertyPath } ); + } + } + + foreach ( var (value, name) in accessor.EnumerateObject( operation.Target ) ) + { + var propertyPath = Combine( operation.Path, name ); + + if ( accessor.TryGetProperty( operation.Source, name, out _ ) ) + { + continue; + } + + operations.Add( new PatchOperation { Operation = PatchOperationType.Add, Path = propertyPath, Value = value } ); + } + } + + private static void ProcessArrayDiff( DiffOperation operation, Stack stack, List operations ) + { + var accessor = Descriptor.ValueAccessor; + + var source = accessor.EnumerateArray( operation.Source ).ToArray(); + var target = accessor.EnumerateArray( operation.Target ).ToArray(); + + var row = source.Length; + var col = target.Length; + + var mrow = row + 1; + var mcol = col + 1; + + var matrix = mrow + mcol <= 64 + ? new Matrix( stackalloc byte[mrow * mcol], mrow, mcol ) + : new Matrix( mrow, mcol ); + + try + { + CalculateLevenshteinMatrix( matrix, source, target ); + + while ( row > 0 || col > 0 ) + { + if ( row > 0 && matrix[row, col] == matrix[row - 1, col] + 1 ) + { + var path = Combine( operation.Path, (row - 1).ToString() ); + operations.Add( new PatchOperation( PatchOperationType.Remove, path, null, null ) ); + row--; + } + else if ( col > 0 && matrix[row, col] == matrix[row, col - 1] + 1 ) + { + var path = Combine( operation.Path, (col - 1).ToString() ); + operations.Add( new PatchOperation( PatchOperationType.Add, path, null, target[col - 1] ) ); + col--; + } + else if ( row > 0 && col > 0 ) + { + if ( matrix[row, col] == matrix[row - 1, col - 1] ) + { + row--; + col--; + } + else + { + var sourceKind = accessor.GetNodeKind( source[row - 1] ); + var targetKind = accessor.GetNodeKind( target[col - 1] ); + var path = Combine( operation.Path, (row - 1).ToString() ); + + if ( sourceKind != targetKind ) + { + // If they don't match they are always a replacement. + operations.Add( new PatchOperation( PatchOperationType.Replace, path, null, target[col - 1] ) ); + } + else + { + // We already check if these were values when calculating the matrix, + // so we know this are objects or arrays, and we need further processing. + stack.Push( new DiffOperation( source[row - 1], target[col - 1], path ) ); + } + + row--; + col--; + } + } + } + } + finally + { + matrix.Dispose(); + } + } + + private static void ProcessValueDiff( DiffOperation operation, List operations ) + { + if ( Descriptor.NodeActions.DeepEquals( operation.Source, operation.Target ) ) + return; + + operations.Add( new PatchOperation { Operation = PatchOperationType.Replace, Path = operation.Path, Value = operation.Target } ); + } + + private static void CalculateLevenshteinMatrix( Matrix matrix, TNode[] source, TNode[] target ) + { + var accessor = Descriptor.ValueAccessor; + + for ( var row = 0; row <= source.Length; row++ ) + matrix[row, 0] = row; + + for ( var col = 0; col <= target.Length; col++ ) + matrix[0, col] = col; + + for ( var row = 1; row <= source.Length; row++ ) + { + for ( int col = 1; col <= target.Length; col++ ) + { + var cost = 1; + if ( accessor.TryGetValue( source[row - 1], out var sourceValue ) && + accessor.TryGetValue( target[col - 1], out var targetValue ) ) + { + if ( Equals( targetValue, sourceValue ) ) + cost = 0; + } + + // Calculate the cost of deletion, insertion, and replacement + var remove = matrix[row - 1, col] + 1; + var add = matrix[row, col - 1] + 1; + var replace = matrix[row - 1, col - 1] + cost; + + matrix[row, col] = Math.Min( Math.Min( remove, add ), replace ); + } + } + } + + private static string Combine( ReadOnlySpan initial, ReadOnlySpan path ) + { + // Count special characters + + var specialCharCount = 0; + for ( var i = 0; i < path.Length; i++ ) + { + if ( path[i] == '/' || path[i] == '~' ) + specialCharCount++; + } + + if ( specialCharCount == 0 ) + return string.Concat( initial, "/", path ); + + // Process special characters + + var size = path.Length + specialCharCount; + + var builder = size <= 512 + ? new ValueStringBuilder( stackalloc char[size] ) + : new ValueStringBuilder( size ); + + ReadOnlySpan escapeSlash = ['~', '1']; + ReadOnlySpan escapeTilde = ['~', '0']; + + var start = 0; + for ( var i = 0; i < path.Length; i++ ) + { + switch ( path[i] ) + { + case '/': + if ( i > start ) + builder.Append( path[start..i] ); + + builder.Append( escapeSlash ); + start = i + 1; + break; + + case '~': + if ( i > start ) + builder.Append( path[start..i] ); + + builder.Append( escapeTilde ); + start = i + 1; + break; + } + } + + if ( start < path.Length ) // Append remaining + builder.Append( path[start..] ); + + var result = string.Concat( initial, "/", builder.AsSpan() ); + builder.Dispose(); + return result; + } +} diff --git a/src/Hyperbee.Json/Patch/JsonPatch.cs b/src/Hyperbee.Json/Patch/JsonPatch.cs new file mode 100644 index 00000000..5e6b1cec --- /dev/null +++ b/src/Hyperbee.Json/Patch/JsonPatch.cs @@ -0,0 +1,440 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Hyperbee.Json.Extensions; +using Hyperbee.Json.Pointer; +using Hyperbee.Json.Query; + +namespace Hyperbee.Json.Patch; + +// https://datatracker.ietf.org/doc/html/rfc6902/ + +[JsonConverter( typeof( JsonPatchConverter ) )] +public class JsonPatch : IEnumerable +{ + private readonly List _operations = []; + + public JsonPatch( params PatchOperation[] operations ) + { + _operations.AddRange( operations ); + } + + public void Apply( JsonNode node ) => Apply( node, _operations ); + + public void Apply( JsonElement element, out JsonNode node ) + { + node = element.ConvertToNode(); + Apply( node, _operations ); + } + + public static void Apply( JsonNode node, List patches ) + { + var undoOperations = new Stack(); + + try + { + ApplyInternal( node, patches, undoOperations.Push ); + } + catch + { + try + { + ApplyInternal( node, undoOperations, null ); + } + catch + { + throw new JsonPatchException( "Failed patch rollback." ); + } + + throw; + } + } + + private static void ApplyInternal( JsonNode node, IEnumerable patches, Action undo ) + { + foreach ( var patch in patches ) + { + switch ( patch.Operation ) + { + case PatchOperationType.Add: + AddOperation( node, patch, undo ); + break; + + case PatchOperationType.Copy: + CopyOperation( node, patch, undo ); + break; + + case PatchOperationType.Move: + MoveOperation( node, patch, undo ); + break; + + case PatchOperationType.Remove: + RemoveOperation( node, patch, undo ); + break; + + case PatchOperationType.Replace: + ReplaceOperation( node, patch, undo ); + break; + + case PatchOperationType.Test: + TestOperation( node, patch ); + break; + + default: + throw new JsonPatchException( $"'{patch.Operation}' is an invalid operation." ); + } + } + } + + private static void AddOperation( JsonNode node, PatchOperation patch, Action undo ) + { + var segment = GetSegments( patch.Path ); + var target = FromPointer( node, segment, out var name, out var parent ); + + ThrowLocationDoesNotExist( patch.Path, parent ); + + switch ( parent ) + { + case JsonObject _ when target != null: + undo?.Invoke( new PatchOperation( PatchOperationType.Replace, patch.Path, null, target ) ); + target.ReplaceWith( PatchValue( patch ) ); + break; + + case JsonObject jsonObject: + undo?.Invoke( new PatchOperation( PatchOperationType.Remove, patch.Path, null, null ) ); + jsonObject.Add( name, PatchValue( patch ) ); + break; + + case JsonArray jsonArray: + + if ( name == "-" ) // special segment name for end of array + { + var endPath = string.Concat( patch.Path[..^1], jsonArray.Count ); + undo?.Invoke( new PatchOperation( PatchOperationType.Remove, endPath, null, null ) ); + jsonArray.Add( PatchValue( patch ) ); + } + else if ( int.TryParse( name, out var index ) ) + { + if ( index < 0 || index > jsonArray.Count ) + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + + undo?.Invoke( new PatchOperation( PatchOperationType.Remove, patch.Path, null, null ) ); + jsonArray.Insert( index, PatchValue( patch ) ); + } + else + { + throw new JsonPatchException( $"The target location '{patch.Path}' was an invalid index." ); + } + + break; + } + } + + private static void CopyOperation( JsonNode node, PatchOperation patch, Action undo ) + { + if ( patch.From is null ) + throw new JsonPatchException( "The 'from' property was missing." ); + + var segment = GetSegments( patch.Path ); + var fromSegment = GetSegments( patch.From ); + + var from = FromPointer( node, fromSegment, out var fromName, out var fromParent ); + + ThrowLocationDoesNotExist( patch.From, fromParent ); + ThrowLocationDoesNotExist( patch.From, from ); + + FromPointer( node, segment, out var name, out var parent ); + + ThrowLocationDoesNotExist( patch.Path, name ); + + switch ( fromParent ) + { + case JsonObject: + switch ( parent ) + { + case JsonObject jsonObject: + undo?.Invoke( new PatchOperation( PatchOperationType.Remove, patch.Path, null, null ) ); + jsonObject.Add( name, from.DeepClone() ); + break; + + case JsonArray jsonArray: + + if ( int.TryParse( name, out var targetIndex ) ) + { + if ( targetIndex < 0 || targetIndex > jsonArray.Count ) + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + + undo?.Invoke( new PatchOperation( PatchOperationType.Remove, patch.Path, null, null ) ); + if ( targetIndex == jsonArray.Count ) + jsonArray.Add( from.DeepClone() ); + else + jsonArray.Insert( targetIndex, from.DeepClone() ); + } + + break; + + default: + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + } + + break; + + case JsonArray fromParentArray: + if ( int.TryParse( fromName, out var fromIndex ) ) + { + if ( fromIndex < 0 || fromIndex > fromParentArray.Count ) + throw new JsonPatchException( $"The target location '{patch.From}' was out of range." ); + + switch ( parent ) + { + case JsonObject jsonObject: + undo?.Invoke( new PatchOperation( PatchOperationType.Remove, patch.Path, null, null ) ); + jsonObject.Add( name, from.DeepClone() ); + break; + + case JsonArray jsonArray: + if ( int.TryParse( name, out var targetIndex ) ) + { + if ( targetIndex < 0 || targetIndex > fromParentArray.Count ) + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + + undo?.Invoke( new PatchOperation( PatchOperationType.Remove, patch.Path, null, null ) ); + + if ( targetIndex == jsonArray.Count ) + jsonArray.Add( from.DeepClone() ); + else + jsonArray.Insert( targetIndex, from.DeepClone() ); + } + + break; + + default: + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + } + } + else + { + throw new JsonPatchException( $"The target location '{patch.Path}' was an invalid index." ); + } + + break; + } + } + + private static void MoveOperation( JsonNode node, PatchOperation patch, Action undo ) + { + if ( patch.From is null ) + throw new JsonPatchException( "The 'from' property was missing." ); + + var segment = GetSegments( patch.Path ); + var fromSegment = GetSegments( patch.From ); + + ThrowCycleDetected( segment, fromSegment ); + + var from = FromPointer( node, fromSegment, out var fromName, out var fromParent ); + + ThrowLocationDoesNotExist( patch.From, fromParent ); + ThrowLocationDoesNotExist( patch.From, from ); + + FromPointer( node, segment, out var moveName, out var parent ); + + ThrowLocationDoesNotExist( patch.Path, parent ); + + switch ( fromParent ) + { + case JsonObject fromParentObject: + + switch ( parent ) + { + case JsonObject parentObject: + fromParentObject.Remove( fromName ); + parentObject.Add( moveName, from ); + break; + + case JsonArray parentArray: + if ( int.TryParse( moveName, out var targetIndex ) ) + { + if ( targetIndex < 0 || targetIndex > parentArray.Count ) + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + + fromParentObject.Remove( fromName ); + if ( targetIndex == parentArray.Count ) + parentArray.Add( from ); + else + parentArray.Insert( targetIndex, from ); + } + + break; + + default: + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + } + + break; + + case JsonArray fromParentArray: + if ( int.TryParse( fromName, out var fromIndex ) ) + { + if ( fromIndex < 0 || fromIndex > fromParentArray.Count ) + throw new JsonPatchException( $"The target location '{patch.From}' was out of range." ); + + switch ( parent ) + { + case JsonObject parentObject: + fromParentArray.RemoveAt( fromIndex ); + parentObject.Add( moveName, from ); + break; + + case JsonArray parentArray: + if ( int.TryParse( moveName, out var targetIndex ) ) + { + if ( targetIndex < 0 || targetIndex > fromParentArray.Count ) + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + + fromParentArray.RemoveAt( fromIndex ); + if ( targetIndex == parentArray.Count ) + parentArray.Add( from ); + else + parentArray.Insert( targetIndex, from ); + } + + break; + + default: + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + } + } + else + { + throw new JsonPatchException( $"The target location '{patch.Path}' was an invalid index." ); + } + + break; + } + + // invert direction + undo?.Invoke( new PatchOperation( PatchOperationType.Move, patch.From, patch.Path, null ) ); + } + + private static void RemoveOperation( JsonNode node, PatchOperation patch, Action undo ) + { + var segment = GetSegments( patch.Path ); + + var removeTarget = FromPointer( node, segment, out var removeName, out var removeParent ); + + ThrowLocationDoesNotExist( patch.Path, removeTarget ); + + switch ( removeParent ) + { + case JsonObject parentObject: + undo?.Invoke( new PatchOperation( PatchOperationType.Add, patch.Path, null, removeTarget ) ); + parentObject.Remove( removeTarget.GetPropertyName() ); + break; + + case JsonArray parentArray: + if ( int.TryParse( removeName, out var index ) ) + { + if ( index < 0 || index > parentArray.Count ) + throw new JsonPatchException( $"The target location '{patch.Path}' was out of range." ); + + undo?.Invoke( new PatchOperation( PatchOperationType.Add, patch.Path, null, removeTarget ) ); + parentArray.RemoveAt( index ); + } + else + { + throw new JsonPatchException( $"The target location '{patch.Path}' was an invalid index." ); + } + + break; + } + } + + private static void ReplaceOperation( JsonNode node, PatchOperation patch, Action undo ) + { + var segment = GetSegments( patch.Path ); + var replaceTarget = FromPointer( node, segment, out _, out var replaceParent ); + + ThrowLocationDoesNotExist( patch.Path, replaceParent ); + ThrowLocationDoesNotExist( patch.Path, replaceTarget ); + + undo?.Invoke( new PatchOperation( PatchOperationType.Replace, patch.Path, null, replaceTarget.DeepClone() ) ); + + replaceTarget.ReplaceWith( PatchValue( patch ) ); + } + + private static void TestOperation( JsonNode node, PatchOperation patch ) + { + var segment = GetSegments( patch.Path ); + + var target = FromPointer( node, segment, out _, out var parent ); + + ThrowLocationDoesNotExist( patch.Path, target ); + ThrowLocationDoesNotExist( patch.Path, parent ); + + if ( !JsonNode.DeepEquals( target, PatchValue( patch ) ) ) + throw new JsonPatchException( $"The target location's value '{patch.Value}' is not equal the value." ); + } + + private static JsonSegment GetSegments( string path ) + { + if ( path == null ) + throw new JsonPatchException( "The 'path' property was missing." ); + + var query = JsonQueryParser.Parse( path, JsonQueryParserOptions.Rfc6902 ); + return query.Segments.Next; // skip the root segment + } + + private static void ThrowCycleDetected( JsonSegment toSegment, JsonSegment fromSegment ) + { + var from = fromSegment; + var to = toSegment; + + while ( true ) + { + if ( from == null || to == null ) + return; + + if ( from.IsFinal && !to.IsFinal ) + throw new JsonPatchException( "Cannot patch a child to itself." ); + + if ( from.Selectors[0].Value != to.Selectors[0].Value ) + return; + + from = from.Next; + to = to.Next; + } + } + + private static void ThrowLocationDoesNotExist( string path, JsonNode node ) + { + if ( node is null ) + throw new JsonPatchException( $"The target location '{path}' did not exist." ); + } + + private static JsonNode PatchValue( PatchOperation patch ) + { + if ( patch.Value is null ) + throw new JsonPatchException( "The 'value' property was missing." ); + + return (patch.Value is JsonNode node) + ? (node.Parent != null ? node.DeepClone() : node) + : JsonValue.Create( patch.Value ); + } + + public IEnumerator GetEnumerator() + { + return _operations.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private static JsonNode FromPointer( JsonNode jsonNode, JsonSegment segment, out string name, out JsonNode parent ) + { + name = segment.Last().Selectors[^1].Value; + return SegmentPointer.FromPointer( jsonNode, segment, out parent ); + } +} diff --git a/src/Hyperbee.Json/Patch/JsonPatchConverter.cs b/src/Hyperbee.Json/Patch/JsonPatchConverter.cs new file mode 100644 index 00000000..5d9a9433 --- /dev/null +++ b/src/Hyperbee.Json/Patch/JsonPatchConverter.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hyperbee.Json.Patch; + +public class JsonPatchConverter : JsonConverter +{ + public override JsonPatch Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) + { + if ( reader.TokenType != JsonTokenType.StartArray ) + throw new JsonException(); + + var operations = new List(); + while ( reader.Read() ) + { + if ( reader.TokenType == JsonTokenType.EndArray ) + break; + + var operation = JsonSerializer.Deserialize( ref reader, options ); + operations.Add( operation ); + } + + return new JsonPatch( [.. operations] ); + } + + public override void Write( Utf8JsonWriter writer, JsonPatch value, JsonSerializerOptions options ) + { + writer.WriteStartArray(); + + foreach ( var operation in value ) + { + JsonSerializer.Serialize( writer, operation, options ); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Hyperbee.Json/Patch/JsonPatchException.cs b/src/Hyperbee.Json/Patch/JsonPatchException.cs new file mode 100644 index 00000000..f0de3bf1 --- /dev/null +++ b/src/Hyperbee.Json/Patch/JsonPatchException.cs @@ -0,0 +1,3 @@ +namespace Hyperbee.Json.Patch; + +public class JsonPatchException( string message ) : Exception( message ); diff --git a/src/Hyperbee.Json/Patch/Matrix.cs b/src/Hyperbee.Json/Patch/Matrix.cs new file mode 100644 index 00000000..6713bf91 --- /dev/null +++ b/src/Hyperbee.Json/Patch/Matrix.cs @@ -0,0 +1,84 @@ +// ReSharper disable FieldCanBeMadeReadOnly.Local + +using System.Buffers; + +namespace Hyperbee.Json.Patch; + +public ref struct Matrix +{ + // dispose will reset values, so don't use readonly + private Span _stackAllocated; + private int[] _pooledArray; + private int _rows; + private int _cols; + + public Matrix( Span arrayBuffer, int rows, int columns ) + { + var totalElements = rows * columns; + + if ( totalElements > 64 ) + throw new ArgumentException( $"{nameof( rows )}.Length + {nameof( columns )}.Length exceeds the stack allocation limit of 64." ); + + if ( arrayBuffer.Length != totalElements ) + throw new ArgumentException( $"Length of {nameof( columns )} does not match the {nameof( rows )}.Length + {nameof( columns )}.Length." ); + + _stackAllocated = arrayBuffer; + _pooledArray = null; + + _rows = rows; + _cols = columns; + } + + public Matrix( int rows, int columns ) + { + _rows = rows; + _cols = columns; + + _stackAllocated = []; + _pooledArray = ArrayPool.Shared.Rent( rows * columns ); + } + + public int this[int row, int column] + { + readonly get + { + ThrowIfArgumentOutOfBounds( row, column ); + + return _pooledArray != null + ? _pooledArray[row * _cols + column] + : _stackAllocated[row * _cols + column]; + } + set + { + ThrowIfArgumentOutOfBounds( row, column ); + + switch ( _pooledArray ) + { + case null: + _stackAllocated[row * _cols + column] = (byte) value; + break; + default: + _pooledArray[row * _cols + column] = value; + break; + } + } + } + + private readonly void ThrowIfArgumentOutOfBounds( int row, int column ) + { + if ( row < 0 || row >= _rows ) + throw new ArgumentOutOfRangeException( nameof( row ), $"Index {nameof( row )} is out of bounds." ); + + if ( column < 0 || column >= _cols ) + throw new ArgumentOutOfRangeException( nameof( column ), $"Index {nameof( column )} is out of bounds." ); + } + + public void Dispose() + { + var pooledArray = _pooledArray; + this = default; // for safety, to avoid using pooled array if this instance is erroneously appended to again + + if ( pooledArray != null ) + ArrayPool.Shared.Return( pooledArray ); + } +} diff --git a/src/Hyperbee.Json/Patch/PatchOperation.cs b/src/Hyperbee.Json/Patch/PatchOperation.cs new file mode 100644 index 00000000..eff0ade2 --- /dev/null +++ b/src/Hyperbee.Json/Patch/PatchOperation.cs @@ -0,0 +1,99 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Hyperbee.Json.Patch; + +public enum PatchOperationType +{ + Add, + Copy, + Move, + Remove, + Replace, + Test +} + +[JsonConverter( typeof( PatchOperationConverter ) )] +[DebuggerDisplay( "{Operation}, Path = {Path}, Value = {Value}, From = {From}" )] +public readonly record struct PatchOperation( PatchOperationType Operation, string Path, string From, object Value ); + +public class PatchOperationConverter : JsonConverter +{ + public override PatchOperation Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) + { + string op = null; + string path = null; + string from = null; + object value = null; + + while ( reader.Read() ) + { + if ( reader.TokenType == JsonTokenType.EndObject ) + break; + + if ( reader.TokenType != JsonTokenType.PropertyName ) + continue; + + string propertyName = reader.GetString(); + reader.Read(); + + switch ( propertyName ) + { + case "op": + op = reader.GetString(); + break; + case "path": + path = reader.GetString(); + break; + case "from": + from = reader.GetString(); + break; + case "value": + value = JsonSerializer.Deserialize( ref reader, options ); + break; + default: + throw new JsonException( $"Unexpected property '{propertyName}'." ); + } + } + + if ( op == null ) + throw new JsonException( "Missing 'op' property." ); + + if ( path == null ) + throw new JsonException( "Missing 'path' property." ); + + if ( !Enum.TryParse( op, true, out PatchOperationType operationKind ) ) + throw new JsonException( $"Invalid operation '{op}'." ); + + return new PatchOperation { Operation = operationKind, Path = path, From = from, Value = value }; + } + + public override void Write( Utf8JsonWriter writer, PatchOperation value, JsonSerializerOptions options ) + { + writer.WriteStartObject(); + writer.WriteString( "op", value.Operation.ToString().ToLowerInvariant() ); + writer.WriteString( "path", value.Path ); + + switch ( value.Operation ) + { + case PatchOperationType.Add: + case PatchOperationType.Replace: + writer.WritePropertyName( "value" ); + JsonSerializer.Serialize( writer, value.Value, options ); + break; + case PatchOperationType.Move: + case PatchOperationType.Copy: + writer.WriteString( "from", value.From ); + break; + case PatchOperationType.Remove: + break; + case PatchOperationType.Test: + break; + default: + throw new JsonException( $"Invalid operation '{value.Operation}'." ); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Hyperbee.Json/Filters/FilterCompilerException.cs b/src/Hyperbee.Json/Path/Filters/FilterCompilerException.cs similarity index 90% rename from src/Hyperbee.Json/Filters/FilterCompilerException.cs rename to src/Hyperbee.Json/Path/Filters/FilterCompilerException.cs index f27f53d6..baa603d6 100644 --- a/src/Hyperbee.Json/Filters/FilterCompilerException.cs +++ b/src/Hyperbee.Json/Path/Filters/FilterCompilerException.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Filters; +namespace Hyperbee.Json.Path.Filters; [Serializable] public class FilterCompilerException : Exception diff --git a/src/Hyperbee.Json/Filters/FilterException.cs b/src/Hyperbee.Json/Path/Filters/FilterException.cs similarity index 90% rename from src/Hyperbee.Json/Filters/FilterException.cs rename to src/Hyperbee.Json/Path/Filters/FilterException.cs index 97de57da..fcd1a912 100644 --- a/src/Hyperbee.Json/Filters/FilterException.cs +++ b/src/Hyperbee.Json/Path/Filters/FilterException.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Filters; +namespace Hyperbee.Json.Path.Filters; [Serializable] public class FilterException : Exception diff --git a/src/Hyperbee.Json/Filters/FilterRuntime.cs b/src/Hyperbee.Json/Path/Filters/FilterRuntime.cs similarity index 92% rename from src/Hyperbee.Json/Filters/FilterRuntime.cs rename to src/Hyperbee.Json/Path/Filters/FilterRuntime.cs index 47da7b9d..45619228 100644 --- a/src/Hyperbee.Json/Filters/FilterRuntime.cs +++ b/src/Hyperbee.Json/Path/Filters/FilterRuntime.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; -using Hyperbee.Json.Filters.Parser; +using Hyperbee.Json.Path.Filters.Parser; using Microsoft.CSharp.RuntimeBinder; -namespace Hyperbee.Json.Filters; +namespace Hyperbee.Json.Path.Filters; public record FilterRuntimeContext( TNode Current, TNode Root ); diff --git a/src/Hyperbee.Json/Filters/IFilterRuntime.cs b/src/Hyperbee.Json/Path/Filters/IFilterRuntime.cs similarity index 76% rename from src/Hyperbee.Json/Filters/IFilterRuntime.cs rename to src/Hyperbee.Json/Path/Filters/IFilterRuntime.cs index 4c0b9d0a..7005739e 100644 --- a/src/Hyperbee.Json/Filters/IFilterRuntime.cs +++ b/src/Hyperbee.Json/Path/Filters/IFilterRuntime.cs @@ -1,5 +1,5 @@  -namespace Hyperbee.Json.Filters; +namespace Hyperbee.Json.Path.Filters; public interface IFilterRuntime { diff --git a/src/Hyperbee.Json/Path/Filters/IRegexp.cs b/src/Hyperbee.Json/Path/Filters/IRegexp.cs new file mode 100644 index 00000000..2f191abd --- /dev/null +++ b/src/Hyperbee.Json/Path/Filters/IRegexp.cs @@ -0,0 +1,86 @@ +using Hyperbee.Json.Core; + +namespace Hyperbee.Json.Path.Filters; + +public static class IRegexp +{ + public static string ConvertToIRegexp( ReadOnlySpan pattern ) + { + // RFC-9535 States that regular expressions must conform to the I-Regexp format (RFC-9485). + + if ( pattern.IsEmpty ) + return string.Empty; + + // First loop: count the number of dots that need to be replaced + var inCharacterClass = false; + var dotCount = 0; + + for ( var i = 0; i < pattern.Length; i++ ) + { + var currentChar = pattern[i]; + + switch ( currentChar ) + { + case '\\': + i++; // Skip the next character + break; + case '[': + inCharacterClass = true; + break; + case ']' when inCharacterClass: + inCharacterClass = false; + break; + case '.' when !inCharacterClass: + dotCount++; + break; + } + } + + if ( dotCount == 0 ) + return pattern.ToString(); + + // The replacement pattern for dots + var replacement = @"(?:[^\r\n]|\p{Cs}\p{Cs})".AsSpan(); + var replacementLength = replacement.Length - 1; + + var newSize = pattern.Length + dotCount * replacementLength; + + var builder = newSize <= 512 + ? new ValueStringBuilder( stackalloc char[newSize] ) + : new ValueStringBuilder( newSize ); + + // Second loop: process the pattern and build the result + inCharacterClass = false; + var start = 0; + + for ( var i = 0; i < pattern.Length; i++ ) + { + var currentChar = pattern[i]; + + switch ( currentChar ) + { + case '\\': + i++; // Skip the next character + break; + case '[': + inCharacterClass = true; + break; + case ']' when inCharacterClass: + inCharacterClass = false; + break; + case '.' when !inCharacterClass: + if ( i > start ) + builder.Append( pattern.Slice( start, i - start ) ); + + builder.Append( replacement ); + start = i + 1; + break; + } + } + + if ( start < pattern.Length ) // Append remaining + builder.Append( pattern[start..] ); + + return builder.ToString(); + } +} diff --git a/src/Hyperbee.Json/Filters/Parser/CompareConstraint.cs b/src/Hyperbee.Json/Path/Filters/Parser/CompareConstraint.cs similarity index 80% rename from src/Hyperbee.Json/Filters/Parser/CompareConstraint.cs rename to src/Hyperbee.Json/Path/Filters/Parser/CompareConstraint.cs index 80567445..6e72b08b 100644 --- a/src/Hyperbee.Json/Filters/Parser/CompareConstraint.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/CompareConstraint.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Filters.Parser; +namespace Hyperbee.Json.Path.Filters.Parser; [Flags] public enum CompareConstraint diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/CompareExpression.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/CompareExpression.cs similarity index 96% rename from src/Hyperbee.Json/Filters/Parser/Expressions/CompareExpression.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/CompareExpression.cs index 16b676d9..972e41b0 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/CompareExpression.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/CompareExpression.cs @@ -1,12 +1,12 @@ using System.Linq.Expressions; using System.Reflection; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal static class CompareExpression { - private static readonly IValueTypeComparer Comparer = JsonTypeDescriptorRegistry.GetDescriptor().Comparer; + private static readonly ValueTypeComparer Comparer = new(); // Expressions diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/FunctionExpressionFactory.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/FunctionExpressionFactory.cs similarity index 94% rename from src/Hyperbee.Json/Filters/Parser/Expressions/FunctionExpressionFactory.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/FunctionExpressionFactory.cs index 70fcd9dd..8b600076 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/FunctionExpressionFactory.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/FunctionExpressionFactory.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using Hyperbee.Json.Descriptors; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal class FunctionExpressionFactory : IExpressionFactory { diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/IExpressionFactory.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/IExpressionFactory.cs similarity index 83% rename from src/Hyperbee.Json/Filters/Parser/Expressions/IExpressionFactory.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/IExpressionFactory.cs index 077f7a59..b56bedfa 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/IExpressionFactory.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/IExpressionFactory.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using Hyperbee.Json.Descriptors; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal interface IExpressionFactory { diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/JsonExpressionFactory.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/JsonExpressionFactory.cs similarity index 82% rename from src/Hyperbee.Json/Filters/Parser/Expressions/JsonExpressionFactory.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/JsonExpressionFactory.cs index 4308e684..8fdd3abf 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/JsonExpressionFactory.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/JsonExpressionFactory.cs @@ -2,9 +2,9 @@ using System.Text; using System.Text.Json; using Hyperbee.Json.Descriptors; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal class JsonExpressionFactory : IExpressionFactory { @@ -12,7 +12,7 @@ public static bool TryGetExpression( ref ParserState state, out Expressio { compareConstraint = CompareConstraint.None; - if ( !TryParseNode( descriptor.Accessor, state.Item, out var node ) ) + if ( !TryParseNode( descriptor.NodeActions, state.Item, out var node ) ) { expression = null; return false; @@ -22,7 +22,7 @@ public static bool TryGetExpression( ref ParserState state, out Expressio return true; } - private static bool TryParseNode( IValueAccessor accessor, ReadOnlySpan item, out TNode node ) + private static bool TryParseNode( INodeActions actions, ReadOnlySpan item, out TNode node ) { var maxLength = Encoding.UTF8.GetMaxByteCount( item.Length ); Span bytes = maxLength <= 256 ? stackalloc byte[maxLength] : new byte[maxLength]; @@ -34,7 +34,7 @@ private static bool TryParseNode( IValueAccessor accessor, ReadOnl var reader = new Utf8JsonReader( bytes[..length] ); - if ( accessor.TryParseNode( ref reader, out node ) ) + if ( actions.TryParse( ref reader, out node ) ) return true; node = default; diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/LiteralExpressionFactory.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/LiteralExpressionFactory.cs similarity index 94% rename from src/Hyperbee.Json/Filters/Parser/Expressions/LiteralExpressionFactory.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/LiteralExpressionFactory.cs index 5b26a346..d4b56413 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/LiteralExpressionFactory.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/LiteralExpressionFactory.cs @@ -1,8 +1,8 @@ using System.Linq.Expressions; using Hyperbee.Json.Descriptors; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal class LiteralExpressionFactory : IExpressionFactory { diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/MathExpression.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/MathExpression.cs similarity index 85% rename from src/Hyperbee.Json/Filters/Parser/Expressions/MathExpression.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/MathExpression.cs index 2a1e8f29..5b795e0e 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/MathExpression.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/MathExpression.cs @@ -2,9 +2,9 @@ using System.Reflection; using Hyperbee.Json.Descriptors; using Hyperbee.Json.Extensions; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal static class MathExpression { @@ -108,30 +108,29 @@ static bool TryConvertToInt( float value, out int result, float tolerance = 1e-6 private static bool TryGetNumber( IValueType valueType, out IConvertible value ) { - if ( valueType is ScalarValue intValue ) + switch ( valueType ) { - value = intValue.Value; - return true; - } - - if ( valueType is ScalarValue floatValue ) - { - value = floatValue.Value; - return true; - } - - if ( valueType is NodeList nodes ) - { - var node = nodes.OneOrDefault(); - - if ( node != null && Descriptor.Accessor.TryGetValueFromNode( node, out var nodeValue ) ) - { - if ( nodeValue is float || nodeValue is int ) + case ScalarValue intValue: + value = intValue.Value; + return true; + case ScalarValue floatValue: + value = floatValue.Value; + return true; + case NodeList nodes: { - value = nodeValue; - return true; + var node = nodes.OneOrDefault(); + + if ( node != null && Descriptor.ValueAccessor.TryGetValue( node, out var nodeValue ) ) + { + if ( nodeValue is float or int ) + { + value = nodeValue; + return true; + } + } + + break; } - } } value = default; diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/NotExpressionFactory.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/NotExpressionFactory.cs similarity index 88% rename from src/Hyperbee.Json/Filters/Parser/Expressions/NotExpressionFactory.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/NotExpressionFactory.cs index 545d224b..8f016e23 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/NotExpressionFactory.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/NotExpressionFactory.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using Hyperbee.Json.Descriptors; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal class NotExpressionFactory : IExpressionFactory { diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/ParenExpressionFactory.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/ParenExpressionFactory.cs similarity index 92% rename from src/Hyperbee.Json/Filters/Parser/Expressions/ParenExpressionFactory.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/ParenExpressionFactory.cs index dfae4362..ac7363c0 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/ParenExpressionFactory.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/ParenExpressionFactory.cs @@ -1,7 +1,7 @@ using System.Linq.Expressions; using Hyperbee.Json.Descriptors; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal class ParenExpressionFactory : IExpressionFactory { diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/SelectExpressionFactory.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/SelectExpressionFactory.cs similarity index 83% rename from src/Hyperbee.Json/Filters/Parser/Expressions/SelectExpressionFactory.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/SelectExpressionFactory.cs index 2d88cd85..298d720c 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/SelectExpressionFactory.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/SelectExpressionFactory.cs @@ -1,9 +1,10 @@ using System.Linq.Expressions; using System.Reflection; using Hyperbee.Json.Descriptors; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; +using Hyperbee.Json.Query; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; internal class SelectExpressionFactory : IExpressionFactory { @@ -39,7 +40,11 @@ public static MethodCallExpression GetExpression( ReadOnlySpan item, bool private static IValueType Select( string query, bool allowDotWhitespace, FilterRuntimeContext runtimeContext ) { - var compiledQuery = JsonPathQueryParser.Parse( query, allowDotWhitespace ); + var options = allowDotWhitespace + ? JsonQueryParserOptions.Rfc9535AllowDotWhitespace + : JsonQueryParserOptions.Rfc9535; + + var compiledQuery = JsonQueryParser.Parse( query, options ); var value = query[0] == '$' ? runtimeContext.Root diff --git a/src/Hyperbee.Json/Filters/Parser/Expressions/TruthyExpression.cs b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/TruthyExpression.cs similarity index 94% rename from src/Hyperbee.Json/Filters/Parser/Expressions/TruthyExpression.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Expressions/TruthyExpression.cs index 9afe6299..836d81b8 100644 --- a/src/Hyperbee.Json/Filters/Parser/Expressions/TruthyExpression.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Expressions/TruthyExpression.cs @@ -1,9 +1,9 @@ using System.Collections; using System.Linq.Expressions; using System.Reflection; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser.Expressions; +namespace Hyperbee.Json.Path.Filters.Parser.Expressions; public static class TruthyExpression { diff --git a/src/Hyperbee.Json/Filters/Parser/ExtensionFunction.cs b/src/Hyperbee.Json/Path/Filters/Parser/ExtensionFunction.cs similarity index 97% rename from src/Hyperbee.Json/Filters/Parser/ExtensionFunction.cs rename to src/Hyperbee.Json/Path/Filters/Parser/ExtensionFunction.cs index 92d70a2e..eba5df1b 100644 --- a/src/Hyperbee.Json/Filters/Parser/ExtensionFunction.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/ExtensionFunction.cs @@ -1,8 +1,8 @@ using System.Linq.Expressions; using System.Reflection; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser; +namespace Hyperbee.Json.Path.Filters.Parser; public abstract class ExtensionFunction { diff --git a/src/Hyperbee.Json/Filters/Parser/FilterParser.cs b/src/Hyperbee.Json/Path/Filters/Parser/FilterParser.cs similarity index 96% rename from src/Hyperbee.Json/Filters/Parser/FilterParser.cs rename to src/Hyperbee.Json/Path/Filters/Parser/FilterParser.cs index 9b4e7e3b..4354f917 100644 --- a/src/Hyperbee.Json/Filters/Parser/FilterParser.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/FilterParser.cs @@ -12,10 +12,10 @@ using System.Diagnostics; using System.Linq.Expressions; using Hyperbee.Json.Descriptors; -using Hyperbee.Json.Filters.Parser.Expressions; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser.Expressions; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser; +namespace Hyperbee.Json.Path.Filters.Parser; public abstract class FilterParser { @@ -132,11 +132,12 @@ private static void MoveNext( ref ParserState state ) // move to the next item static bool IsFinished( in ParserState state, char ch, ref int itemEnd ) { // order of operations matters - bool result = state switch + + bool result = true switch { - _ when state.BracketDepth != 0 => false, - _ when !state.Operator.IsNonOperator() => true, - _ when ch == state.TerminalCharacter => true, // [ '\0' or ',' or ')' ] + true when state.BracketDepth != 0 => false, + true when !state.Operator.IsNonOperator() => true, + true when ch == state.TerminalCharacter => true, // [ '\0' or ',' or ')' ] _ => false }; @@ -301,7 +302,9 @@ static bool IsAddSubtractOperator( in ParserState state, int start ) var span = state.Buffer[start..state.Pos]; - return !span.IsEmpty && span[0] != '+' && span[0] != '-' && span[0] != '.' && span.Length >= 2 && span[^2] != 'e' && span[^2] != 'E'; + return !span.IsEmpty && + span[0] != '+' && span[0] != '-' && span[0] != '.' && + span.Length >= 2 && span[^2] != 'e' && span[^2] != 'E'; } // Helper method to check if the operator is a valid multiply operator diff --git a/src/Hyperbee.Json/Filters/Parser/Operator.cs b/src/Hyperbee.Json/Path/Filters/Parser/Operator.cs similarity index 96% rename from src/Hyperbee.Json/Filters/Parser/Operator.cs rename to src/Hyperbee.Json/Path/Filters/Parser/Operator.cs index 3676e403..0343fee5 100644 --- a/src/Hyperbee.Json/Filters/Parser/Operator.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/Operator.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Filters.Parser; +namespace Hyperbee.Json.Path.Filters.Parser; [Flags] public enum Operator diff --git a/src/Hyperbee.Json/Filters/Parser/ParserState.cs b/src/Hyperbee.Json/Path/Filters/Parser/ParserState.cs similarity index 96% rename from src/Hyperbee.Json/Filters/Parser/ParserState.cs rename to src/Hyperbee.Json/Path/Filters/Parser/ParserState.cs index bf734eee..5851c656 100644 --- a/src/Hyperbee.Json/Filters/Parser/ParserState.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/ParserState.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace Hyperbee.Json.Filters.Parser; +namespace Hyperbee.Json.Path.Filters.Parser; [DebuggerDisplay( "{Buffer.ToString()}, Item = {Item.ToString()}, Operator = {Operator}, Pos = {Pos.ToString()}" )] internal ref struct ParserState diff --git a/src/Hyperbee.Json/Filters/Parser/ValueTypeComparer.cs b/src/Hyperbee.Json/Path/Filters/Parser/ValueTypeComparer.cs similarity index 86% rename from src/Hyperbee.Json/Filters/Parser/ValueTypeComparer.cs rename to src/Hyperbee.Json/Path/Filters/Parser/ValueTypeComparer.cs index dada1a14..7c6d780d 100644 --- a/src/Hyperbee.Json/Filters/Parser/ValueTypeComparer.cs +++ b/src/Hyperbee.Json/Path/Filters/Parser/ValueTypeComparer.cs @@ -1,8 +1,8 @@ using Hyperbee.Json.Descriptors; using Hyperbee.Json.Extensions; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Values; -namespace Hyperbee.Json.Filters.Parser; +namespace Hyperbee.Json.Path.Filters.Parser; public interface IValueTypeComparer { @@ -13,10 +13,13 @@ public interface IValueTypeComparer public bool In( IValueType left, IValueType right ); } -public class ValueTypeComparer( IValueAccessor accessor ) : IValueTypeComparer +public class ValueTypeComparer : IValueTypeComparer { private const float Tolerance = 1e-6F; // Define a tolerance for float comparisons + private static readonly ITypeDescriptor Descriptor = + JsonTypeDescriptorRegistry.GetDescriptor(); + /* * Comparison Rules (according to JSONPath RFC 9535): * @@ -126,24 +129,29 @@ public bool In( IValueType left, IValueType right ) throw new NotSupportedException( "The right side of `in` must be a node list." ); var rightNode = rightList.OneOrDefault(); + var accessor = Descriptor.ValueAccessor; if ( rightNode == null || accessor.GetNodeKind( rightNode ) != NodeKind.Array ) return false; return Contains( this, accessor, left, rightNode ); - static bool Contains( IValueTypeComparer comparer, IValueAccessor accessor, IValueType left, TNode rightNode ) + static IEnumerable EnumerateChildren( IValueAccessor accessor, TNode node ) { - foreach ( var (rightChild, _, _) in accessor.EnumerateChildren( rightNode ) ) + return accessor.GetNodeKind( node ) switch { - var comparand = GetComparand( accessor, rightChild ); - var result = comparer.Compare( left, comparand, Operator.Equals ); - - if ( result == 0 ) - return true; - } + NodeKind.Array => accessor.EnumerateArray( node ), + NodeKind.Object => accessor.EnumerateObject( node ).Select( x => x.Item1 ), + _ => [] + }; + } - return false; + static bool Contains( IValueTypeComparer comparer, IValueAccessor accessor, IValueType left, TNode rightNode ) + { + return EnumerateChildren( accessor, rightNode ) + .Select( rightChild => GetComparand( accessor, rightChild ) ) + .Select( comparand => comparer.Compare( left, comparand, Operator.Equals ) ) + .Any( result => result == 0 ); } static IValueType GetComparand( IValueAccessor accessor, TNode childValue ) @@ -171,22 +179,24 @@ public bool Exists( IValueType node ) }; } - private int CompareEnumerables( IEnumerable left, IEnumerable right ) + private static int CompareEnumerables( IEnumerable left, IEnumerable right ) { using var leftEnumerator = left.GetEnumerator(); using var rightEnumerator = right.GetEnumerator(); + var (valueAccessor, nodeAccessor) = Descriptor; + while ( leftEnumerator.MoveNext() ) { if ( !rightEnumerator.MoveNext() ) return 1; // Left has more elements, so it is greater // if the values can be extracted, compare the values directly - if ( TryGetValue( accessor, leftEnumerator.Current, out var leftItemValue ) && - TryGetValue( accessor, rightEnumerator.Current, out var rightItemValue ) ) + if ( TryGetValue( valueAccessor, leftEnumerator.Current, out var leftItemValue ) && + TryGetValue( valueAccessor, rightEnumerator.Current, out var rightItemValue ) ) return CompareValues( leftItemValue, rightItemValue, out _ ); - if ( !accessor.DeepEquals( leftEnumerator.Current, rightEnumerator.Current ) ) + if ( !nodeAccessor.DeepEquals( leftEnumerator.Current, rightEnumerator.Current ) ) return -1; // Elements are not deeply equal } @@ -196,12 +206,14 @@ private int CompareEnumerables( IEnumerable left, IEnumerable righ return 0; // Sequences are equal } - private int CompareEnumerableToValue( IEnumerable enumeration, IValueType value, out bool typeMismatch, out int nodeCount ) + private static int CompareEnumerableToValue( IEnumerable enumeration, IValueType value, out bool typeMismatch, out int nodeCount ) { nodeCount = 0; typeMismatch = false; var lastCompare = -1; + var accessor = Descriptor.ValueAccessor; + foreach ( var item in enumeration ) { nodeCount++; @@ -266,7 +278,7 @@ private static int CompareValues( IValueType left, IValueType right, out bool ty // Helpers static bool IsTypeMismatch( IValueType left, IValueType right ) => left?.GetType() != right?.GetType(); - static bool IsNullOrNothing( IValueType value ) => value is Null or Nothing; + static bool IsNullOrNothing( IValueType value ) => value.ValueKind == ValueKind.Null || value.ValueKind == ValueKind.Nothing; static bool IsFloatToIntOperation( IValueType left, IValueType right ) => left is ScalarValue && right is ScalarValue || left is ScalarValue && right is ScalarValue; @@ -274,7 +286,7 @@ static bool IsFloatToIntOperation( IValueType left, IValueType right ) => private static bool TryGetValue( IValueAccessor accessor, TNode node, out IValueType nodeType ) { - if ( accessor.TryGetValueFromNode( node, out var itemValue ) ) + if ( accessor.TryGetValue( node, out var itemValue ) ) { nodeType = itemValue switch { diff --git a/src/Hyperbee.Json/Filters/Values/IValueType.cs b/src/Hyperbee.Json/Path/Filters/Values/IValueType.cs similarity index 85% rename from src/Hyperbee.Json/Filters/Values/IValueType.cs rename to src/Hyperbee.Json/Path/Filters/Values/IValueType.cs index 0edf82b8..6b91872c 100644 --- a/src/Hyperbee.Json/Filters/Values/IValueType.cs +++ b/src/Hyperbee.Json/Path/Filters/Values/IValueType.cs @@ -1,5 +1,5 @@  -namespace Hyperbee.Json.Filters.Values; +namespace Hyperbee.Json.Path.Filters.Values; public interface IValueType { diff --git a/src/Hyperbee.Json/Filters/Values/NodeList.cs b/src/Hyperbee.Json/Path/Filters/Values/NodeList.cs similarity index 90% rename from src/Hyperbee.Json/Filters/Values/NodeList.cs rename to src/Hyperbee.Json/Path/Filters/Values/NodeList.cs index 3ddb0f7a..c5b09c7e 100644 --- a/src/Hyperbee.Json/Filters/Values/NodeList.cs +++ b/src/Hyperbee.Json/Path/Filters/Values/NodeList.cs @@ -1,6 +1,6 @@ using System.Collections; -namespace Hyperbee.Json.Filters.Values; +namespace Hyperbee.Json.Path.Filters.Values; public readonly struct NodeList( IEnumerable value, bool isNormalized ) : IValueType, IEnumerable { diff --git a/src/Hyperbee.Json/Filters/Values/ScalarValue.cs b/src/Hyperbee.Json/Path/Filters/Values/ScalarValue.cs similarity index 53% rename from src/Hyperbee.Json/Filters/Values/ScalarValue.cs rename to src/Hyperbee.Json/Path/Filters/Values/ScalarValue.cs index ec648365..4cf37555 100644 --- a/src/Hyperbee.Json/Filters/Values/ScalarValue.cs +++ b/src/Hyperbee.Json/Path/Filters/Values/ScalarValue.cs @@ -1,21 +1,42 @@ -using System; -using System.Diagnostics; +using System.Diagnostics; -namespace Hyperbee.Json.Filters.Values; +namespace Hyperbee.Json.Path.Filters.Values; [DebuggerDisplay( "{ValueKind}, Value = {Value}" )] -public readonly struct ScalarValue( TType value ) : IValueType where TType : IConvertible +public readonly struct ScalarValue : IValueType where TType : IConvertible { - public ValueKind ValueKind => ValueKind.Scalar; + public ValueKind ValueKind { get; } - public TType Value { get; } = value; + public TType Value { get; } + + public ScalarValue( TType value ) + { + ValueKind = ValueKind.Scalar; + Value = value; + } + + private ScalarValue( Nothing _ ) + { + ValueKind = ValueKind.Nothing; + Value = default; + } + + private ScalarValue( Null _ ) + { + ValueKind = ValueKind.Null; + Value = default; + } public static implicit operator ScalarValue( bool value ) => new( (TType) (IConvertible) value ); public static implicit operator ScalarValue( string value ) => new( (TType) (IConvertible) value ); public static implicit operator ScalarValue( int value ) => new( (TType) (IConvertible) value ); public static implicit operator ScalarValue( float value ) => new( (TType) (IConvertible) value ); + + public static implicit operator ScalarValue( Nothing nothing ) => new( nothing ); + public static implicit operator ScalarValue( Null nul ) => new( nul ); } + public static class Scalar { public static ScalarValue Value( T value ) where T : IConvertible => new( value ); diff --git a/src/Hyperbee.Json/Filters/Values/ValueKind.cs b/src/Hyperbee.Json/Path/Filters/Values/ValueKind.cs similarity index 60% rename from src/Hyperbee.Json/Filters/Values/ValueKind.cs rename to src/Hyperbee.Json/Path/Filters/Values/ValueKind.cs index 86186191..4dfeee1d 100644 --- a/src/Hyperbee.Json/Filters/Values/ValueKind.cs +++ b/src/Hyperbee.Json/Path/Filters/Values/ValueKind.cs @@ -1,4 +1,4 @@ -namespace Hyperbee.Json.Filters.Values; +namespace Hyperbee.Json.Path.Filters.Values; public enum ValueKind { diff --git a/src/Hyperbee.Json/JsonPath.cs b/src/Hyperbee.Json/Path/JsonPath.cs similarity index 62% rename from src/Hyperbee.Json/JsonPath.cs rename to src/Hyperbee.Json/Path/JsonPath.cs index d23d74df..8108d747 100644 --- a/src/Hyperbee.Json/JsonPath.cs +++ b/src/Hyperbee.Json/Path/JsonPath.cs @@ -34,17 +34,32 @@ using System.Diagnostics; using System.Runtime.CompilerServices; +using Hyperbee.Json.Core; using Hyperbee.Json.Descriptors; +using Hyperbee.Json.Path.Filters; +using Hyperbee.Json.Query; // https://www.rfc-editor.org/rfc/rfc9535.html // https://www.rfc-editor.org/rfc/rfc9535.html#appendix-A -namespace Hyperbee.Json; +namespace Hyperbee.Json.Path; -public delegate void NodeProcessorDelegate( in TNode parent, in TNode value, string key, in JsonPathSegment segment ); +internal static class IndexHelper +{ + private const int LookupLength = 64; + private static readonly string[] IndexLookup = Enumerable.Range( 0, LookupLength ).Select( i => i.ToString() ).ToArray(); + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public static string GetIndexString( int index ) => index < 64 ? IndexLookup[index] : index.ToString(); +} + +public delegate void NodeProcessorDelegate( in TNode parent, in TNode value, string key, in JsonSegment segment ); public static class JsonPath { + private static readonly ITypeDescriptor Descriptor = JsonTypeDescriptorRegistry.GetDescriptor(); + private static readonly FilterRuntime FilterRuntime = new(); + [Flags] internal enum NodeFlags { @@ -52,15 +67,13 @@ internal enum NodeFlags AfterDescent = 1 } - private static readonly ITypeDescriptor Descriptor = JsonTypeDescriptorRegistry.GetDescriptor(); - public static IEnumerable Select( in TNode value, string query, NodeProcessorDelegate processor = null ) { - var compiledQuery = JsonPathQueryParser.Parse( query ); + var compiledQuery = JsonQueryParser.Parse( query ); return EnumerateMatches( value, value, compiledQuery, processor ); } - internal static IEnumerable SelectInternal( in TNode value, in TNode root, JsonPathQuery compiledQuery, NodeProcessorDelegate processor = null ) + internal static IEnumerable SelectInternal( in TNode value, in TNode root, JsonQuery compiledQuery, NodeProcessorDelegate processor = null ) { // entry point for filter recursive calls @@ -71,7 +84,7 @@ internal static IEnumerable SelectInternal( in TNode value, in TNode root return EnumerateMatches( value, root, compiledQuery, processor ); } - private static IEnumerable EnumerateMatches( in TNode value, in TNode root, JsonPathQuery compiledQuery, NodeProcessorDelegate processor = null ) + private static IEnumerable EnumerateMatches( in TNode value, in TNode root, JsonQuery compiledQuery, NodeProcessorDelegate processor = null ) { if ( string.IsNullOrWhiteSpace( compiledQuery.Query ) ) // invalid per the RFC ABNF return []; // Consensus: return empty array for empty query @@ -81,9 +94,9 @@ private static IEnumerable EnumerateMatches( in TNode value, in TNode roo var segmentNext = compiledQuery.Segments.Next; // The first segment is always the root; skip it - if ( Descriptor.CanUsePointer && compiledQuery.Normalized ) // we can fast path this + if ( compiledQuery.Normalized ) // we can fast path this { - if ( Descriptor.Accessor.TryGetFromPointer( in value, segmentNext, out var result ) ) + if ( Descriptor.NodeActions.TryGetFromPointer( in value, segmentNext, out var result ) ) return [result]; return []; @@ -95,8 +108,7 @@ private static IEnumerable EnumerateMatches( in TNode value, in TNode roo private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, NodeProcessorDelegate processor = null ) { var stack = new NodeArgsStack(); - - var (accessor, filterRuntime) = Descriptor; + var accessor = Descriptor.ValueAccessor; do { @@ -104,10 +116,11 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N var (parent, value, key, segmentNext, flags) = args; - // call node processor if it exists and the `key` is not null. - // the key is null when a descent has re-pushed the descent target. - // this should be safe to skip; we will see its values later. +// call node processor if it exists and the `key` is not null. +// the key is null when a descent has re-pushed the descent target. +// this should be safe to skip; we will see its values later. +ProcessArgs: if ( key != null ) processor?.Invoke( parent, value, key, segmentNext ); @@ -142,15 +155,33 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N continue; // don't allow indexing in to objects // try to access object or array using name or index - if ( accessor.TryGetChild( value, selector, selectorKind, out var childValue ) ) - stack.Push( value, childValue, selector, segmentNext ); + if ( TryGetChild( accessor, value, nodeKind, selector, selectorKind, out var childValue ) ) + { + // optimization: quicker return for final + if ( segmentNext.IsFinal ) + { + processor?.Invoke( in value, childValue, selector, segmentNext ); + yield return childValue; + continue; + } + + // optimization: avoid immediate push pop + // + // replaces stack.Push( value, childValue, selector, segmentNext ); + DeconstructValues( out parent, out value, out key, out segmentNext, out flags, + (value, childValue, selector, segmentNext, NodeFlags.Default) + ); + goto ProcessArgs; + } continue; } // group selector - for ( var i = 0; i < segmentCurrent.Selectors.Length; i++ ) // using 'for' for performance + var selectorCount = segmentCurrent.Selectors.Length; + + for ( var i = 0; i < selectorCount; i++ ) // using 'for' for performance { if ( i > 0 ) // we already have the first selector (selector, selectorKind) = segmentCurrent.Selectors[i]; @@ -160,24 +191,30 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N // descendant case SelectorKind.Descendant: { - foreach ( var (childValue, childKey, _) in accessor.EnumerateChildren( value, includeValues: false ) ) // child arrays or objects only - { - stack.Push( value, childValue, childKey, segmentCurrent ); // Descendant - } + var children = Descriptor.NodeActions.GetChildren( value, complexTypesOnly: true ); + stack.PushMany( value, children, segmentCurrent, NodeFlags.AfterDescent ); // Union Processing After Descent: If a union operator immediately follows a // descendant operator, the union should only process simple values. This is // to prevent duplication of complex objects that would result from both the // current node and the union processing the same items. - stack.Push( parent, value, null, segmentNext, NodeFlags.AfterDescent ); // process the current value - continue; + // optimization: avoid immediate push pop + // + // this is safe because descendant only ever has one selector. + // replaces stack.Push( value, childValue, selector, segmentNext ); + DeconstructValues( out parent, out value, out key, out segmentNext, out flags, // process the current value + (parent, value, null, segmentNext, NodeFlags.AfterDescent) + ); + goto ProcessArgs; } // wildcard case SelectorKind.Wildcard: { - foreach ( var (childValue, childKey, childKind) in accessor.EnumerateChildren( value ) ) + var childKind = GetSelectorKind( nodeKind ); + + foreach ( var (childValue, childKey) in Descriptor.NodeActions.GetChildren( value ) ) { // optimization: quicker return for final // @@ -189,7 +226,6 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N // the order of the results as per the RFC. so we push the current // value onto the stack without prepending the childKey or childKind // to set up for an immediate return on the next iteration. - //Push( stack, value, childValue, childKey, segmentNext ); stack.Push( value, childValue, childKey, segmentNext ); continue; } @@ -203,14 +239,17 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N // [?exp] case SelectorKind.Filter: { - foreach ( var (childValue, childKey, childKind) in accessor.EnumerateChildren( value ) ) + var childKind = GetSelectorKind( nodeKind ); + + foreach ( var (childValue, childKey) in Descriptor.NodeActions.GetChildren( value ) ) { - if ( !filterRuntime.Evaluate( selector[1..], childValue, root ) ) // remove the leading '?' character + if ( !FilterRuntime.Evaluate( selector[1..], childValue, root ) ) // remove the leading '?' character continue; // optimization: quicker return for tail values if ( segmentNext.IsFinal ) { + // yielding would not preserve the order of the results as per the RFC. stack.Push( value, childValue, childKey, segmentNext ); continue; } @@ -227,8 +266,11 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N if ( nodeKind != NodeKind.Array ) continue; - if ( accessor.TryGetElementAt( value, int.Parse( selector ), out var childValue ) ) + if ( accessor.TryGetIndexAt( value, int.Parse( selector ), out var childValue ) ) + { stack.Push( value, childValue, selector, segmentNext ); + } + continue; } @@ -238,12 +280,12 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N if ( nodeKind != NodeKind.Array ) continue; - var (upper, lower, step) = GetSliceRange( value, selector, accessor ); + var (upper, lower, step) = GetSliceRange( accessor, value, selector ); for ( var index = lower; step > 0 ? index < upper : index > upper; index += step ) { - if ( accessor.TryGetElementAt( value, index, out var childValue ) ) - stack.Push( value, childValue, index.ToString(), segmentNext ); + var childValue = accessor.IndexAt( value, index ); + stack.Push( value, childValue, index, segmentNext ); } continue; @@ -257,13 +299,12 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N for ( var index = length - 1; index >= 0; index-- ) { - if ( !accessor.TryGetElementAt( value, index, out var childValue ) ) - continue; + var childValue = accessor.IndexAt( value, index ); if ( flags == NodeFlags.AfterDescent && accessor.GetNodeKind( childValue ) != NodeKind.Value ) continue; - stack.Push( value, childValue, index.ToString(), indexSegment ); + stack.Push( value, childValue, index, indexSegment ); } continue; @@ -272,8 +313,10 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N // Object: [name1,name2,...] Names over object case SelectorKind.Name when nodeKind == NodeKind.Object: { - if ( accessor.TryGetChild( value, selector, selectorKind, out var childValue ) ) + if ( accessor.TryGetProperty( value, selector, out var childValue ) ) + { stack.Push( value, childValue, selector, segmentNext ); + } continue; } @@ -289,14 +332,44 @@ private static IEnumerable EnumerateMatches( TNode root, NodeArgs args, N } while ( stack.TryPop( out args ) ); } - private static (int Upper, int Lower, int Step) GetSliceRange( TNode value, string sliceExpr, IValueAccessor accessor ) + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static void DeconstructValues( out TNode parent, out TNode value, out string key, out JsonSegment segmentNext, out NodeFlags flags, + (TNode Parent, TNode Value, string Key, JsonSegment SegmentNext, NodeFlags Flags) values ) + { + parent = values.Parent; + value = values.Value; + key = values.Key; + segmentNext = values.SegmentNext; + flags = values.Flags; + } + + private static bool TryGetChild( IValueAccessor accessor, in TNode value, NodeKind nodeKind, string childSelector, SelectorKind selectorKind, out TNode childValue ) + { + switch ( nodeKind ) + { + case NodeKind.Object: + if ( accessor.TryGetProperty( value, childSelector, out childValue ) ) + return true; + break; + + case NodeKind.Array when selectorKind == SelectorKind.Index: + if ( int.TryParse( childSelector, out var index ) && accessor.TryGetIndexAt( value, index, out childValue ) ) + return true; + break; + } + + childValue = default; + return false; + } + + private static (int Upper, int Lower, int Step) GetSliceRange( IValueAccessor accessor, in TNode value, string sliceExpr ) { var length = accessor.GetArrayLength( value ); if ( length == 0 ) return (0, 0, 0); - var (lower, upper, step) = JsonPathSliceSyntaxHelper.ParseExpression( sliceExpr, length, reverse: true ); + var (lower, upper, step) = SliceSyntaxHelper.ParseExpression( sliceExpr, length, reverse: true ); if ( step < 0 ) (lower, upper) = (upper, lower); @@ -304,8 +377,19 @@ private static (int Upper, int Lower, int Step) GetSliceRange( TNode value, stri return (upper, lower, step); } + [MethodImpl( MethodImplOptions.AggressiveInlining )] + private static SelectorKind GetSelectorKind( NodeKind nodeKind ) + { + return nodeKind switch + { + NodeKind.Object => SelectorKind.Name, + NodeKind.Array => SelectorKind.Index, + _ => throw new ArgumentOutOfRangeException( nameof( nodeKind ), nodeKind, $"{nameof( NodeKind )} must be an object or an array." ) + }; + } + [DebuggerDisplay( "Parent = {Parent}, Value = {Value}, {Segment}" )] - private readonly record struct NodeArgs( TNode Parent, TNode Value, string Key, JsonPathSegment Segment, NodeFlags Flags ); + private readonly record struct NodeArgs( TNode Parent, TNode Value, string Key, JsonSegment Segment, NodeFlags Flags ); [DebuggerDisplay( "{_stack}" )] private sealed class NodeArgsStack( int capacity = 8 ) @@ -314,16 +398,29 @@ private sealed class NodeArgsStack( int capacity = 8 ) private readonly Stack _stack = new( capacity ); [MethodImpl( MethodImplOptions.AggressiveInlining )] - public void Push( in TNode parent, in TNode value, string key, in JsonPathSegment segment, NodeFlags flags = NodeFlags.Default ) + public void Push( in TNode parent, in TNode value, string key, in JsonSegment segment, NodeFlags flags = NodeFlags.Default ) { _stack.Push( new NodeArgs( parent, value, key, segment, flags ) ); } + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public void Push( in TNode parent, in TNode value, int index, in JsonSegment segment, NodeFlags flags = NodeFlags.Default ) + { + _stack.Push( new NodeArgs( parent, value, IndexHelper.GetIndexString( index ), segment, flags ) ); + } + + public void PushMany( in TNode parent, in IEnumerable<(TNode Value, string Key)> items, in JsonSegment segment, NodeFlags flags = NodeFlags.Default ) + { + foreach ( var (value, key) in items ) + { + _stack.Push( new NodeArgs( parent, value, key, segment, flags ) ); + } + } + [MethodImpl( MethodImplOptions.AggressiveInlining )] public bool TryPop( out NodeArgs args ) { return _stack.TryPop( out args ); } } - } diff --git a/src/Hyperbee.Json/Pointer/JsonPathPointer.cs b/src/Hyperbee.Json/Pointer/JsonPathPointer.cs new file mode 100644 index 00000000..e45e447e --- /dev/null +++ b/src/Hyperbee.Json/Pointer/JsonPathPointer.cs @@ -0,0 +1,26 @@ +using Hyperbee.Json.Query; + +namespace Hyperbee.Json.Pointer; + +// DISTINCT from JsonPath these extensions are intended to facilitate 'diving' for Json Properties +// using normalized paths. a normalized path is an absolute path that references a single element. +// Similar to JsonPointer but using JsonPath notation. +// +// syntax supports absolute paths; dotted notation, quoted names, and simple bracketed array accessors only. +// +// Json path style wildcard '*', '..', and '[a,b]' multi-result selector notations are NOT supported. + +public static class JsonPathPointer +{ + public static TNode FromPointer( TNode root, ReadOnlySpan pointer ) + { + var query = JsonQueryParser.Parse( pointer ); + return SegmentPointer.TryGetFromPointer( root, query.Segments, out _, out var value ) ? value : default; + } + + public static bool TryGetFromPointer( TNode root, ReadOnlySpan pointer, out TNode value ) + { + var query = JsonQueryParser.Parse( pointer ); + return SegmentPointer.TryGetFromPointer( root, query.Segments, out _, out value ); + } +} diff --git a/src/Hyperbee.Json/Pointer/JsonPathPointerConverter.cs b/src/Hyperbee.Json/Pointer/JsonPathPointerConverter.cs new file mode 100644 index 00000000..c6e73670 --- /dev/null +++ b/src/Hyperbee.Json/Pointer/JsonPathPointerConverter.cs @@ -0,0 +1,269 @@ +using System.Text; +using Hyperbee.Json.Core; + +namespace Hyperbee.Json.Pointer; + +[Flags] +public enum JsonPointerConvertOptions +{ + Default = 0x00, + Fragment = 0x01, +} + +public static class JsonPathPointerConverter +{ + public static string ConvertJsonPathToJsonPointer( ReadOnlySpan jsonPath, JsonPointerConvertOptions options = JsonPointerConvertOptions.Default ) + { + var fragment = options.HasFlag( JsonPointerConvertOptions.Fragment ); + + if ( jsonPath.IsEmpty || jsonPath.SequenceEqual( "$".AsSpan() ) ) + { + return fragment ? "#/" : "/"; + } + + var jsonPointer = new StringBuilder( fragment ? "#/" : "/" ); + var i = 0; + + while ( i < jsonPath.Length ) + { + switch ( jsonPath[i] ) + { + case '$': + i++; + break; + case '[': + i++; + var quote = jsonPath[i]; + switch ( quote ) + { + case '\'': + case '"': + { + i++; + var start = i; + while ( i < jsonPath.Length && (jsonPath[i] != quote || i > start && jsonPath[i - 1] == '\\') ) + { + i++; + } + + JsonPointerAppendEscaped( jsonPointer, jsonPath[start..i], true ); + i += 2; // Skip the closing ']' + break; + } + default: + { + var start = i; + while ( i < jsonPath.Length && jsonPath[i] != ']' ) + { + i++; + } + + jsonPointer.Append( jsonPath[start..i] ).Append( '/' ); + i++; + break; + } + } + + break; + case '.': + i++; + var startDot = i; + while ( i < jsonPath.Length && jsonPath[i] != '.' && jsonPath[i] != '[' ) + { + i++; + } + + JsonPointerAppendEscaped( jsonPointer, jsonPath[startDot..i], false ); + break; + default: + throw new InvalidOperationException( $"Unexpected character '{jsonPath[i]}' in JSONPath." ); + } + } + + if ( jsonPointer.Length > 1 ) + { + // Remove trailing slash + jsonPointer.Length--; + } + + return jsonPointer.ToString(); + } + + private static void JsonPointerAppendEscaped( StringBuilder jsonPointer, ReadOnlySpan itemSpan, bool isQuoted ) + { + var replacementCount = 0; + + foreach ( var c in itemSpan ) + { + if ( isQuoted && c == '/' || c == '~' || c == '/' ) + { + replacementCount++; + } + } + + if ( replacementCount == 0 ) + { + jsonPointer.Append( itemSpan ).Append( '/' ); + return; + } + + var length = itemSpan.Length + replacementCount; + var buffer = length <= 256 ? stackalloc char[length] : new char[length]; + + var bufferIndex = 0; + for ( var i = 0; i < itemSpan.Length; i++ ) + { + if ( itemSpan[i] == '\\' && isQuoted ) + { + // Skip the escape character and append the next character directly + buffer[bufferIndex++] = itemSpan[++i]; + } + else + { + switch ( itemSpan[i] ) + { + case '~': + buffer[bufferIndex++] = '~'; + buffer[bufferIndex++] = '0'; + break; + case '/': + buffer[bufferIndex++] = '~'; + buffer[bufferIndex++] = '1'; + break; + default: + buffer[bufferIndex++] = itemSpan[i]; + break; + } + } + } + + jsonPointer.Append( buffer[..bufferIndex] ).Append( '/' ); + } + + public static string ConvertJsonPointerToJsonPath( ReadOnlySpan jsonPointer, JsonPointerConvertOptions options = JsonPointerConvertOptions.Default ) + { + if ( jsonPointer.IsEmpty || jsonPointer is "/" or "#/" ) + { + return "$"; + } + + var jsonPath = new StringBuilder( "$" ); + + if ( jsonPointer[0] == '#' ) + { + jsonPointer = jsonPointer[1..]; + } + + var i = 0; + var item = new ValueStringBuilder( stackalloc char[512] ); + + while ( i < jsonPointer.Length ) + { + switch ( jsonPointer[i] ) + { + case '/': + { + i++; + var start = i; + + while ( i < jsonPointer.Length && jsonPointer[i] != '/' ) + { + i++; + } + + var pointerSpan = jsonPointer[start..i]; + item.Clear(); + + for ( var j = 0; j < pointerSpan.Length; j++ ) + { + if ( pointerSpan[j] != '~' ) + { + item.Append( pointerSpan[j] ); + continue; + } + + switch ( pointerSpan[j + 1] ) + { + case '1': + item.Append( '/' ); + j++; + break; + case '0': + item.Append( '~' ); + j++; + break; + default: + item.Append( '~' ); + break; + } + } + + var itemSpan = item.AsSpan(); + + if ( int.TryParse( itemSpan, out _ ) ) + { + jsonPath.Append( '[' ).Append( itemSpan ).Append( ']' ); + } + else if ( !HasSpecialCharacters( itemSpan ) ) + { + jsonPath.Append( '.' ).Append( itemSpan ); + } + else + { + JsonPathAppendEscaped( jsonPath, itemSpan ); + } + + break; + } + } + } + + return jsonPath.ToString(); + + // Helper method to determine if a property name is simple (no special characters) + + static bool HasSpecialCharacters( ReadOnlySpan propertyName ) + { + foreach ( var c in propertyName ) + { + if ( !char.IsLetterOrDigit( c ) && c != '_' ) + { + return true; + } + } + + return false; + } + } + + private static void JsonPathAppendEscaped( StringBuilder jsonPath, ReadOnlySpan itemSpan ) + { + jsonPath.Append( "['" ); + + var lastPos = 0; + for ( var j = 0; j < itemSpan.Length; j++ ) + { + if ( itemSpan[j] != '\'' ) + { + continue; + } + + if ( j > lastPos ) + { + jsonPath.Append( itemSpan[lastPos..j] ); // Append the chunk before the escape character + } + + jsonPath.Append( "\\'" ); + lastPos = j + 1; // Update the last position to after the escape character + } + + // Append any remaining part of the string after the last escape character + if ( lastPos < itemSpan.Length ) + { + jsonPath.Append( itemSpan[lastPos..] ); + } + + jsonPath.Append( "']" ); + } +} + diff --git a/src/Hyperbee.Json/Pointer/JsonPointer.cs b/src/Hyperbee.Json/Pointer/JsonPointer.cs new file mode 100644 index 00000000..b3afd3c6 --- /dev/null +++ b/src/Hyperbee.Json/Pointer/JsonPointer.cs @@ -0,0 +1,30 @@ +using Hyperbee.Json.Query; + +namespace Hyperbee.Json.Pointer; + +public static class JsonPointer +{ + public static TNode FromPointer( TNode root, ReadOnlySpan pointer, bool rfc6902 = false ) + { + var options = rfc6902 + ? JsonQueryParserOptions.Rfc6902 + : JsonQueryParserOptions.Rfc6901; + + var query = JsonQueryParser.Parse( pointer, options ); + var segment = query.Segments.Next; // skip the root segment + + return SegmentPointer.TryGetFromPointer( root, segment, out _, out var value ) ? value : default; + } + + public static bool TryGetFromPointer( TNode root, ReadOnlySpan pointer, out TNode value, bool rfc6902 = false ) + { + var options = rfc6902 + ? JsonQueryParserOptions.Rfc6902 + : JsonQueryParserOptions.Rfc6901; + + var query = JsonQueryParser.Parse( pointer, options ); + var segment = query.Segments.Next; // skip the root segment + + return SegmentPointer.TryGetFromPointer( root, segment, out _, out value ); + } +} diff --git a/src/Hyperbee.Json/Pointer/SegmentPointer.cs b/src/Hyperbee.Json/Pointer/SegmentPointer.cs new file mode 100644 index 00000000..8104927a --- /dev/null +++ b/src/Hyperbee.Json/Pointer/SegmentPointer.cs @@ -0,0 +1,99 @@ +using Hyperbee.Json.Descriptors; +using Hyperbee.Json.Query; + +namespace Hyperbee.Json.Pointer; + +public static class SegmentPointer +{ + private static readonly ITypeDescriptor Descriptor = JsonTypeDescriptorRegistry.GetDescriptor(); + + internal static TNode FromPointer( TNode root, JsonSegment segment, out TNode parent ) + { + return TryGetFromPointer( root, segment, out parent, out var value ) ? value : default; + } + + internal static bool TryGetFromPointer( TNode root, JsonSegment segment, out TNode parent, out TNode value ) + { + if ( !segment.IsNormalized ) + throw new NotSupportedException( "Unsupported pointer query format." ); + + if ( !segment.IsFinal && segment.Selectors[0].SelectorKind == SelectorKind.Root ) + segment = segment.Next; // skip the root segment + + var accessor = Descriptor.ValueAccessor; + + value = default; + parent = default; + + var current = root; + var currentParent = parent; + + var typeMismatch = false; + + while ( !segment.IsFinal ) + { + var (selectorValue, selectorKind) = segment.Selectors[0]; + + currentParent = current; + + switch ( selectorKind ) + { + case SelectorKind.Name: + { + if ( accessor.GetNodeKind( current ) != NodeKind.Object ) + { + typeMismatch = true; + goto NotFound; + } + + if ( !accessor.TryGetProperty( current, selectorValue, out current ) ) + goto NotFound; + break; + } + + case SelectorKind.Index: + { + if ( accessor.GetNodeKind( current ) != NodeKind.Array ) + { + typeMismatch = true; + goto NotFound; + } + + var length = accessor.GetArrayLength( current ); + + var index = selectorValue == "-" // rfc6902 index append support + ? length + : int.Parse( selectorValue ); + + if ( index < 0 ) + index = length + index; + + if ( index < 0 || index >= length ) // out of bounds + goto NotFound; + + current = accessor.IndexAt( current, index ); + break; + } + + default: + throw new NotSupportedException( $"Unsupported {nameof( SelectorKind )}." ); + } + + segment = segment.Next; + } + + value = current; + parent = currentParent; + + return true; + +NotFound: +// return parent if final segment fails. +// this is required for patch. + if ( segment.Next.IsFinal && !typeMismatch ) + parent = currentParent; + + value = default; + return false; + } +} diff --git a/src/Hyperbee.Json/Query/JsonQueryParser.cs b/src/Hyperbee.Json/Query/JsonQueryParser.cs new file mode 100644 index 00000000..f6cbce8d --- /dev/null +++ b/src/Hyperbee.Json/Query/JsonQueryParser.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace Hyperbee.Json.Query; + +[Flags] +public enum JsonQueryParserOptions +{ + Rfc9535 = 1, + Rfc6901 = 2, + Rfc6902 = 4, + + Rfc9535AllowDotWhitespace = Rfc9535 | 8 +} + +public record JsonQuery( string Query, JsonSegment Segments, bool Normalized ); + +internal static class JsonQueryParser +{ + private static readonly ConcurrentDictionary JsonPathQueries = new(); + + internal static void Clear() => JsonPathQueries.Clear(); + + internal static JsonQuery Parse( ReadOnlySpan query, JsonQueryParserOptions options = JsonQueryParserOptions.Rfc9535 ) + { + return Parse( query.ToString(), options ); + } + + internal static JsonQuery Parse( string query, JsonQueryParserOptions options = JsonQueryParserOptions.Rfc9535 ) + { + return JsonPathQueries.GetOrAdd( query, x => + { + switch ( options ) + { + case JsonQueryParserOptions.Rfc9535: + case JsonQueryParserOptions.Rfc9535AllowDotWhitespace: + return Rfc9535QueryFactory.Parse( x.AsSpan(), options ); + + case JsonQueryParserOptions.Rfc6901: + case JsonQueryParserOptions.Rfc6902: + return Rfc6901QueryFactory.Parse( x.AsSpan(), options ); + + default: + throw new ArgumentOutOfRangeException( nameof( options ) ); + } + } ); + } +} diff --git a/src/Hyperbee.Json/JsonPathSegment.cs b/src/Hyperbee.Json/Query/JsonSegment.cs similarity index 51% rename from src/Hyperbee.Json/JsonPathSegment.cs rename to src/Hyperbee.Json/Query/JsonSegment.cs index 4d44d524..126f5c7b 100644 --- a/src/Hyperbee.Json/JsonPathSegment.cs +++ b/src/Hyperbee.Json/Query/JsonSegment.cs @@ -1,13 +1,12 @@ -using System.Diagnostics; +using System.Collections; +using System.Diagnostics; -namespace Hyperbee.Json; - -public record JsonPathQuery( string Query, JsonPathSegment Segments, bool Normalized ); +namespace Hyperbee.Json.Query; [DebuggerDisplay( "{Value}, SelectorKind = {SelectorKind}" )] public record SelectorDescriptor { - public SelectorKind SelectorKind { get; init; } + public SelectorKind SelectorKind { get; internal set; } public string Value { get; init; } public void Deconstruct( out string value, out SelectorKind selectorKind ) @@ -19,50 +18,38 @@ public void Deconstruct( out string value, out SelectorKind selectorKind ) [DebuggerTypeProxy( typeof( SegmentDebugView ) )] [DebuggerDisplay( "First = ({Selectors?[0]}), IsSingular = {IsSingular}, Count = {Selectors?.Length}" )] -public class JsonPathSegment +public class JsonSegment : IEnumerable { - internal static readonly JsonPathSegment Final = new(); // special end node + internal static readonly JsonSegment Final = new(); // special end node public bool IsFinal => Next == null; public bool IsSingular { get; } // singular is true when the selector resolves to one and only one element - public JsonPathSegment Next { get; set; } + public JsonSegment Next { get; set; } public SelectorDescriptor[] Selectors { get; init; } - private JsonPathSegment() { } + private JsonSegment() { } - public JsonPathSegment( JsonPathSegment next, string selector, SelectorKind kind ) + public JsonSegment( JsonSegment next, string selector, SelectorKind kind ) { Next = next; Selectors = [ new SelectorDescriptor { SelectorKind = kind, Value = selector } ]; - IsSingular = InitIsSingular(); + IsSingular = SetIsSingular(); } - public JsonPathSegment( SelectorDescriptor[] selectors ) + public JsonSegment( SelectorDescriptor[] selectors ) { Selectors = selectors; - IsSingular = InitIsSingular(); + IsSingular = SetIsSingular(); } - public JsonPathSegment Prepend( string selector, SelectorKind kind ) + public JsonSegment Prepend( string selector, SelectorKind kind ) { - return new JsonPathSegment( this, selector, kind ); - } - - public IEnumerable AsEnumerable() - { - var current = this; - - while ( current != Final ) - { - yield return current; - - current = current.Next; - } + return new JsonSegment( this, selector, kind ); } public bool IsNormalized @@ -83,9 +70,10 @@ public bool IsNormalized } } - private bool InitIsSingular() + private bool SetIsSingular() { - // singular is one selector that is not a group + // the segment is singular, when there is only one selector + // and it is SelectorKind.Singular if ( Selectors.Length != 1 ) return false; @@ -99,12 +87,51 @@ public void Deconstruct( out bool singular, out SelectorDescriptor[] selectors ) selectors = Selectors; } - internal class SegmentDebugView( JsonPathSegment instance ) + internal static JsonQuery LinkSegments( ReadOnlySpan query, IList segments ) + { + if ( segments == null || segments.Count == 0 ) + return new JsonQuery( query.ToString(), Final, false ); + + // link the segments + + for ( var index = 0; index < segments.Count; index++ ) + { + var segment = segments[index]; + + segment.Next = index == segments.Count - 1 + ? Final + : segments[index + 1]; + } + + var rootSegment = segments.First(); // first segment is the root + var normalized = rootSegment.IsNormalized; + + return new JsonQuery( query.ToString(), rootSegment, normalized ); + } + + public IEnumerator GetEnumerator() + { + var current = this; + + while ( current != Final ) + { + yield return current; + + current = current.Next; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + internal class SegmentDebugView( JsonSegment instance ) { [DebuggerBrowsable( DebuggerBrowsableState.RootHidden )] public SelectorDescriptor[] Selectors => instance.Selectors; [DebuggerBrowsable( DebuggerBrowsableState.Collapsed )] - public JsonPathSegment Next => instance.Next; + public JsonSegment Next => instance.Next; } } diff --git a/src/Hyperbee.Json/Query/Rfc6901QueryFactory.cs b/src/Hyperbee.Json/Query/Rfc6901QueryFactory.cs new file mode 100644 index 00000000..b32d7285 --- /dev/null +++ b/src/Hyperbee.Json/Query/Rfc6901QueryFactory.cs @@ -0,0 +1,89 @@ +using Hyperbee.Json.Core; + +namespace Hyperbee.Json.Query; + +internal static class Rfc6901QueryFactory +{ + internal static JsonQuery Parse( ReadOnlySpan query, JsonQueryParserOptions options ) + { + // RFC 6901 - JSON Pointer + + var segments = new List(); + + // Process the root '/' or '#/' + + switch ( query.IsEmpty ) + { + case false when query.StartsWith( "/" ): + AppendSegment( segments, SelectorKind.Root, "/" ); + query = query[1..]; + break; + case false when query.StartsWith( "#/" ): + AppendSegment( segments, SelectorKind.Root, "#/" ); + query = query[2..]; + break; + default: + throw new ArgumentException( "Invalid JSON Pointer query.", nameof( query ) ); + } + + // Split the query by '/' + + if ( query.IsEmpty ) + return JsonSegment.LinkSegments( query, segments ); + + bool rfc6902 = options.HasFlag( JsonQueryParserOptions.Rfc6902 ); + var splitter = new SpanSplitter( query, '/' ); + + while ( splitter.TryMoveNext( out var part ) ) + { + var decodedPart = DecodeJsonPointerPart( part ); + + var selectorKind = int.TryParse( decodedPart, out _ ) || (rfc6902 && decodedPart == "-") + ? SelectorKind.Index + : SelectorKind.Name; + + AppendSegment( segments, selectorKind, decodedPart ); + } + + return JsonSegment.LinkSegments( query, segments ); + } + + private static void AppendSegment( List segments, SelectorKind selectorKind, string value ) + { + var selector = new SelectorDescriptor { SelectorKind = selectorKind, Value = value }; + var segment = new JsonSegment( [selector] ); + + segments.Add( segment ); + } + + private static string DecodeJsonPointerPart( ReadOnlySpan part ) + { + var builder = new ValueStringBuilder( stackalloc char[256] ); + + for ( int i = 0; i < part.Length; i++ ) + { + if ( part[i] != '~' || i + 1 >= part.Length ) + { + builder.Append( part[i] ); + continue; + } + + switch ( part[i + 1] ) + { + case '1': + builder.Append( '/' ); + i++; + break; + case '0': + builder.Append( '~' ); + i++; + break; + default: + builder.Append( part[i] ); + break; + } + } + + return builder.ToString(); + } +} diff --git a/src/Hyperbee.Json/JsonPathQueryParser.cs b/src/Hyperbee.Json/Query/Rfc9535QueryFactory.cs similarity index 89% rename from src/Hyperbee.Json/JsonPathQueryParser.cs rename to src/Hyperbee.Json/Query/Rfc9535QueryFactory.cs index 750c47f7..1a1a8050 100644 --- a/src/Hyperbee.Json/JsonPathQueryParser.cs +++ b/src/Hyperbee.Json/Query/Rfc9535QueryFactory.cs @@ -1,32 +1,10 @@ -using System.Collections.Concurrent; -using System.Runtime.CompilerServices; -using Hyperbee.Json.Internal; +using System.Runtime.CompilerServices; +using Hyperbee.Json.Core; -namespace Hyperbee.Json; +namespace Hyperbee.Json.Query; -[Flags] -public enum SelectorKind +internal static class Rfc9535QueryFactory { - Undefined = 0x0, - - // subtype - Singular = 0x1, - Group = 0x2, - - // selectors - Root = 0x8 | Singular, - Name = 0x10 | Singular, - Index = 0x20 | Singular, - Slice = 0x40 | Group, - Filter = 0x80 | Group, - Wildcard = 0x100 | Group, - Descendant = 0x200 | Group -} - -internal static class JsonPathQueryParser -{ - private static readonly ConcurrentDictionary JsonPathQueries = new(); - private enum State { Undefined, @@ -39,18 +17,10 @@ private enum State Final } - internal static JsonPathQuery Parse( ReadOnlySpan query, bool allowDotWhitespace = false ) + internal static JsonQuery Parse( ReadOnlySpan query, JsonQueryParserOptions options ) { - return Parse( query.ToString(), allowDotWhitespace ); - } + bool allowDotWhitespace = options == JsonQueryParserOptions.Rfc9535AllowDotWhitespace; - internal static JsonPathQuery Parse( string query, bool allowDotWhitespace = false ) - { - return JsonPathQueries.GetOrAdd( query, x => QueryFactory( x.AsSpan(), allowDotWhitespace ) ); - } - - private static JsonPathQuery QueryFactory( ReadOnlySpan query, bool allowDotWhitespace = false ) - { // RFC - query cannot start or end with whitespace if ( !query.IsEmpty && (char.IsWhiteSpace( query[0] ) || char.IsWhiteSpace( query[^1] )) ) throw new NotSupportedException( "Query cannot start or end with whitespace." ); @@ -63,23 +33,23 @@ private static JsonPathQuery QueryFactory( ReadOnlySpan query, bool allowD var inQuotes = false; var inFilter = false; var quoteChar = '\''; - bool escaped = false; + var escaped = false; var bracketDepth = 0; var parenDepth = 0; Span whitespaceTerminators = ['\0', '\0']; // '\0' is used as a sentinel value var whiteSpaceReplay = true; - var segments = new List(); + var segments = new List(); var selectors = new List(); var state = State.Start; - State returnState = State.Undefined; + var returnState = State.Undefined; do { // Read next character - char c = i < n ? query[i++] : '\0'; + var c = i < n ? query[i++] : '\0'; if ( state != State.Whitespace && c == '\0' ) // whitespace is a sub-state, allow it to exit state = State.Finish; // end of input @@ -175,6 +145,7 @@ private static JsonPathQuery QueryFactory( ReadOnlySpan query, bool allowD InsertSegment( segments, GetSelectorDescriptor( SelectorKind.Descendant, ".." ) ); i++; // advance past second `.` } + selectorStart = i; break; @@ -388,34 +359,9 @@ private static JsonPathQuery QueryFactory( ReadOnlySpan query, bool allowD } } while ( state != State.Final ); - return BuildJsonPathQuery( query, segments ); - } - - private static JsonPathQuery BuildJsonPathQuery( ReadOnlySpan query, IList segments ) - { - if ( segments == null || segments.Count == 0 ) - return new JsonPathQuery( query.ToString(), JsonPathSegment.Final, false ); - - // link the segments - - for ( var index = 0; index < segments.Count; index++ ) - { - var segment = segments[index]; - - segment.Next = index != segments.Count - 1 - ? segments[index + 1] - : JsonPathSegment.Final; - } - - var rootSegment = segments.First(); // first segment is the root - var normalized = rootSegment.IsNormalized; - - return new JsonPathQuery( query.ToString(), rootSegment, normalized ); + return JsonSegment.LinkSegments( query, segments ); } - internal static void ClearCache() => JsonPathQueries.Clear(); - - [MethodImpl( MethodImplOptions.AggressiveInlining )] private static SelectorDescriptor GetSelectorDescriptor( SelectorKind selectorKind, ReadOnlySpan selectorSpan, bool nullable = true ) { @@ -424,7 +370,7 @@ private static SelectorDescriptor GetSelectorDescriptor( SelectorKind selectorKi } [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static SelectorDescriptor GetSelectorDescriptor( SelectorKind selectorKind, in SpanBuilder builder, bool nullable = true ) + private static SelectorDescriptor GetSelectorDescriptor( SelectorKind selectorKind, in ValueStringBuilder builder, bool nullable = true ) { var selectorValue = builder.IsEmpty && !nullable ? null : builder.ToString(); return new SelectorDescriptor { SelectorKind = selectorKind, Value = selectorValue }; @@ -434,7 +380,7 @@ private static SelectorDescriptor GetUnescapedSelectorDescriptor( SelectorKind s { // SpanBuilder must be disposed, but it is a ref struct, so we can't use `using` - var builder = new SpanBuilder( selectorSpan.Length ); + var builder = new ValueStringBuilder( stackalloc char[512] ); try { @@ -493,12 +439,12 @@ private static SelectorKind GetUnionSelectorKind( ReadOnlySpan selector ) } [MethodImpl( MethodImplOptions.AggressiveInlining )] - private static void InsertSegment( List segments, params SelectorDescriptor[] selectors ) + private static void InsertSegment( List segments, params SelectorDescriptor[] selectors ) { - if ( selectors == null || selectors.Length == 0 || (selectors.Length == 1 && selectors[0]?.Value == null) ) + if ( selectors == null || selectors.Length == 0 || selectors.Length == 1 && selectors[0]?.Value == null ) return; // ignore null and empty selectors. this is valid in some cases like `].` and `..` - segments.Add( new JsonPathSegment( selectors ) ); + segments.Add( new JsonSegment( selectors ) ); } [MethodImpl( MethodImplOptions.AggressiveInlining )] @@ -568,7 +514,7 @@ static bool ValidatePart( ReadOnlySpan span, ref int idx, ref bool isValid var start = idx; var length = span.Length; - if ( idx < length && (span[idx] == '-') ) + if ( idx < length && span[idx] == '-' ) idx++; while ( idx < length && char.IsDigit( span[idx] ) ) @@ -617,7 +563,7 @@ private static bool IsValidNumber( ReadOnlySpan input, out bool isValid, o return false; } - int start = 0; + var start = 0; // Handle optional leading negative sign if ( input[0] == '-' ) @@ -632,7 +578,7 @@ private static bool IsValidNumber( ReadOnlySpan input, out bool isValid, o } // Check for leading zeros - if ( input[start] == '0' && length > (start + 1) ) + if ( input[start] == '0' && length > start + 1 ) { isValid = false; reason = "Leading zeros are not allowed."; @@ -642,7 +588,7 @@ private static bool IsValidNumber( ReadOnlySpan input, out bool isValid, o // Check if all remaining characters are digits for ( var i = start; i < length; i++ ) { - char c = input[i]; + var c = input[i]; if ( c >= '0' && c <= '9' ) continue; @@ -677,7 +623,7 @@ private static void ThrowIfInvalidUnquotedName( ReadOnlySpan name ) throw new NotSupportedException( $"Selector name cannot start with `{name[0]}`." ); // Validate subsequent characters - for ( int i = 1; i < name.Length; i++ ) + for ( var i = 1; i < name.Length; i++ ) { if ( !IsValidSubsequentChar( name[i] ) ) throw new NotSupportedException( $"Selector name cannot contain `{name[i]}`." ); @@ -694,11 +640,11 @@ private static void ThrowIfInvalidQuotedName( ReadOnlySpan name ) if ( name.IsEmpty ) throw new NotSupportedException( "Selector name cannot be empty." ); - char quoteChar = name[0]; - if ( name.Length < 2 || (quoteChar != '"' && quoteChar != '\'') || name[^1] != quoteChar ) + var quoteChar = name[0]; + if ( name.Length < 2 || quoteChar != '"' && quoteChar != '\'' || name[^1] != quoteChar ) throw new NotSupportedException( "Quoted name must start and end with the same quote character, either double or single quote." ); - for ( int i = 1; i < name.Length - 1; i++ ) + for ( var i = 1; i < name.Length - 1; i++ ) { if ( name[i] == '\\' ) { @@ -742,7 +688,7 @@ static bool IsValidEscapeChar( char escapeChar, char quoteChar ) escapeChar == 'f' || escapeChar == 'n' || escapeChar == 'r' || escapeChar == 't' || escapeChar == 'u' - ; + ; } static bool IsValidUnicodeEscapeSequence( ReadOnlySpan span ) diff --git a/src/Hyperbee.Json/Query/SelectorKind.cs b/src/Hyperbee.Json/Query/SelectorKind.cs new file mode 100644 index 00000000..36f425be --- /dev/null +++ b/src/Hyperbee.Json/Query/SelectorKind.cs @@ -0,0 +1,20 @@ +namespace Hyperbee.Json.Query; + +[Flags] +public enum SelectorKind +{ + Undefined = 0x0, + + // subtype + Singular = 0x1, + Group = 0x2, + + // selectors + Root = 0x8 | Singular, + Name = 0x10 | Singular, + Index = 0x20 | Singular, + Slice = 0x40 | Group, + Filter = 0x80 | Group, + Wildcard = 0x100 | Group, + Descendant = 0x200 | Group +} diff --git a/test/Hyperbee.Json.Benchmark/Config.cs b/test/Hyperbee.Json.Benchmark/Config.cs index e27f58ce..113affcc 100644 --- a/test/Hyperbee.Json.Benchmark/Config.cs +++ b/test/Hyperbee.Json.Benchmark/Config.cs @@ -1,7 +1,6 @@ using BenchmarkDotNet.Columns; using BenchmarkDotNet.Configs; using BenchmarkDotNet.Diagnosers; -using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Loggers; using BenchmarkDotNet.Reports; @@ -14,7 +13,6 @@ public class Config : ManualConfig public Config() { AddJob( Job.ShortRun ); - AddExporter( MarkdownExporter.GitHub ); AddValidator( JitOptimizationsValidator.DontFailOnError ); AddLogger( ConsoleLogger.Default ); AddColumnProvider( @@ -28,9 +26,48 @@ public Config() // Customize the summary style to prevent truncation WithSummaryStyle( SummaryStyle.Default.WithMaxParameterColumnWidth( 50 ) ); + // Add the custom exporter with specified visible columns + AddExporter( new JsonPathMarkdownExporter + { + ShowColumn = column => + { + return column.OriginalColumn.ColumnName switch + { + "Method" => true, + "Mean" => true, + "Error" => true, + "StdDev" => true, + "Allocated" => true, + _ => false + }; + } + } ); + AddDiagnoser( MemoryDiagnoser.Default ); Orderer = new FastestToSlowestByParamOrderer(); - ArtifactsPath = "benchmark"; + + // Set the artifacts path to a specific directory in the project + + // Set the artifacts path to a specific directory in the project + var projectFolder = FindParentFolder( "Hyperbee.Json.Benchmark" ); + ArtifactsPath = System.IO.Path.Combine( projectFolder, "benchmark" ); + } + + private static string FindParentFolder( string target ) + { + var currentDirectory = new DirectoryInfo( AppContext.BaseDirectory ); + + while ( currentDirectory != null ) + { + if ( currentDirectory.Name.Equals( target, StringComparison.OrdinalIgnoreCase ) ) + { + return currentDirectory.FullName; + } + + currentDirectory = currentDirectory.Parent; + } + + throw new DirectoryNotFoundException( $"Could not find the target folder '{target}' in the directory tree." ); } } diff --git a/test/Hyperbee.Json.Benchmark/FilterExpressionParserEvaluator.cs b/test/Hyperbee.Json.Benchmark/FilterExpressionParserEvaluator.cs index e9626464..34630b8d 100644 --- a/test/Hyperbee.Json.Benchmark/FilterExpressionParserEvaluator.cs +++ b/test/Hyperbee.Json.Benchmark/FilterExpressionParserEvaluator.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using BenchmarkDotNet.Attributes; -using Hyperbee.Json.Filters.Parser; +using Hyperbee.Json.Path.Filters.Parser; namespace Hyperbee.Json.Benchmark; @@ -11,13 +11,13 @@ public class FilterExpressionParserEvaluator public string Filter; [Benchmark] - public void JsonPathFilterParser_JsonElement() + public void FilterParser_JsonElement() { FilterParser.Parse( Filter ); } [Benchmark] - public void JsonPathFilterParser_JsonNode() + public void FilterParser_JsonNode() { FilterParser.Parse( Filter ); } diff --git a/test/Hyperbee.Json.Benchmark/Hyperbee.Json.Benchmark.csproj b/test/Hyperbee.Json.Benchmark/Hyperbee.Json.Benchmark.csproj index 9f4b5ad6..080e4741 100644 --- a/test/Hyperbee.Json.Benchmark/Hyperbee.Json.Benchmark.csproj +++ b/test/Hyperbee.Json.Benchmark/Hyperbee.Json.Benchmark.csproj @@ -16,7 +16,9 @@ + + diff --git a/test/Hyperbee.Json.Benchmark/JsonDiffBenchmark.cs b/test/Hyperbee.Json.Benchmark/JsonDiffBenchmark.cs new file mode 100644 index 00000000..9cf8921d --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/JsonDiffBenchmark.cs @@ -0,0 +1,44 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using BenchmarkDotNet.Attributes; +using Hyperbee.Json.Patch; + +namespace Hyperbee.Json.Benchmark; + +public class JsonDiffBenchmark +{ + [Params( """{"name":"John","age":30,"city":"New York"}""" )] + public string Source; + + [Params( + """{"name":"John","age":35,"city":"New York","country":"USA"}""", + """{"name":"John","age":35}""" + )] + public string Target; + + private JsonNode _nodeSource; + private JsonNode _nodeTarget; + private JsonElement _elementSource; + private JsonElement _elementTarget; + + [GlobalSetup] + public void Setup() + { + _nodeSource = JsonNode.Parse( Source ); + _nodeTarget = JsonNode.Parse( Target ); + _elementSource = JsonDocument.Parse( Source ).RootElement; + _elementTarget = JsonDocument.Parse( Target ).RootElement; + } + + [Benchmark] + public void JsonDiff_JsonElement() + { + _ = JsonDiff.Diff( _elementSource, _elementTarget ).ToArray(); + } + + [Benchmark] + public void JsonDiff_JsonNode() + { + _ = JsonDiff.Diff( _nodeSource, _nodeTarget ).ToArray(); + } +} diff --git a/test/Hyperbee.Json.Benchmark/JsonPatchBenchmark.cs b/test/Hyperbee.Json.Benchmark/JsonPatchBenchmark.cs new file mode 100644 index 00000000..3911cc3e --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/JsonPatchBenchmark.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using BenchmarkDotNet.Attributes; +using Hyperbee.Json.Dynamic; +using Hyperbee.Json.Patch; +using Microsoft.AspNetCore.JsonPatch; +using Newtonsoft.Json.Serialization; +using AspNetCore = Microsoft.AspNetCore.JsonPatch; +using JsonEverything = Json.Patch; + +namespace Hyperbee.Json.Benchmark; + +public class JsonPatchBenchmark +{ + [Params( """{"name":"John","age":30,"city":"New York"}""" )] + public string Source; + + [Params( + """[{ "op":"add", "path":"/country", "value":"USA" }]""" + )] + //"""[{ "op":"remove", "path":"/age" }]""" + public string Operations; + + private JsonNode _nodeSource; + private JsonNode _nodeEverythingSource; + private JsonElement _elementSource; + private dynamic _dynamicSource; + + private JsonPatch _patchNode; + private JsonPatch _patchElement; + private JsonEverything.JsonPatch _everythingPath; + private JsonPatchDocument _aspPatch; + + [GlobalSetup] + public void Setup() + { + _nodeSource = JsonNode.Parse( Source ); + _nodeEverythingSource = JsonNode.Parse( Source ); + _elementSource = JsonDocument.Parse( Source ).RootElement; + _dynamicSource = JsonDynamicHelper.ConvertToDynamic( JsonNode.Parse( Source ) ); + + _patchNode = JsonSerializer.Deserialize( Operations ); + _patchElement = JsonSerializer.Deserialize( Operations ); + + _everythingPath = JsonSerializer.Deserialize( Operations ); + _aspPatch = new JsonPatchDocument( + JsonSerializer.Deserialize>( Operations ), + new DefaultContractResolver() + ); + } + + [Benchmark] + public void Hyperbee_JsonNode() + { + _patchNode.Apply( _nodeSource ); + } + + [Benchmark] + public void Hyperbee_JsonElement() + { + _patchElement.Apply( _elementSource, out _ ); + } + + [Benchmark] + public void AspNetCore_JsonNode() + { + _aspPatch.ApplyTo( _dynamicSource ); + } + + [Benchmark] + public void JsonEverything_JsonNode() + { + _everythingPath.Apply( _nodeEverythingSource ); + } + +} diff --git a/test/Hyperbee.Json.Benchmark/JsonPathMarkdownExporter.cs b/test/Hyperbee.Json.Benchmark/JsonPathMarkdownExporter.cs new file mode 100644 index 00000000..c5feb1dd --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/JsonPathMarkdownExporter.cs @@ -0,0 +1,175 @@ +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Reports; + +namespace Hyperbee.Json.Benchmark; + +// Custom exporter that groups tests by filter and displays only specified columns +public class JsonPathMarkdownExporter : ExporterBase +{ + protected override string FileExtension => "md"; + protected override string FileNameSuffix => "-jsonpath"; + + protected bool UseCodeBlocks = true; + protected string CodeBlockStart = "```"; + protected string CodeBlockEnd = "```"; + protected string TableHeaderSeparator = " | "; + protected string TableColumnSeparator = " | "; + protected bool ColumnsStartWithSeparator = true; + + public Func ShowColumn { get; set; } = x => true; + + public override void ExportToLog( Summary summary, ILogger logger ) + { + if ( UseCodeBlocks ) + { + logger.WriteLine( CodeBlockStart ); + } + + logger.WriteLine(); + foreach ( string infoLine in summary.HostEnvironmentInfo.ToFormattedString() ) + { + logger.WriteLineInfo( infoLine ); + } + + logger.WriteLineInfo( summary.AllRuntimes ); + logger.WriteLine(); + + PrintTable( summary, logger, summary.Style ); + + var benchmarksWithTroubles = summary.Reports.Where( x => !x.GetResultRuns().Any() ).Select( x => x.BenchmarkCase ).ToList(); + if ( benchmarksWithTroubles.Count > 0 ) + { + logger.WriteLine(); + logger.WriteLineError( "Benchmarks with issues:" ); + + foreach ( var benchmarkWithTroubles in benchmarksWithTroubles ) + { + logger.WriteLineError( " " + benchmarkWithTroubles.DisplayInfo ); + } + } + + if ( UseCodeBlocks ) + { + logger.WriteLine( CodeBlockEnd ); + } + } + + private void PrintTable( Summary summary, ILogger logger, SummaryStyle style ) + { + var table = summary.Table; + var columns = table.Columns.Where( x => x.NeedToShow && x.OriginalColumn.ColumnName != "Filter" && (ShowColumn == null || ShowColumn( x )) ).ToArray(); + + var filterColumn = table.Columns.FirstOrDefault( x => x.Header == "Filter" ); + + if ( table.FullContent.Length == 0 ) + { + logger.WriteLineError( "There are no benchmarks found " ); + logger.WriteLine(); + return; + } + + if ( columns.Length == 0 ) + { + logger.WriteLine(); + logger.WriteLine( "There are no columns to show " ); + return; + } + + logger.WriteLine(); + + PrintHeader( columns, logger ); + + int rowCounter = 0; + + foreach ( var line in table.FullContent ) + { + if ( table.FullContentStartOfLogicalGroup[rowCounter] ) + { + if ( rowCounter > 0 ) + { + PrintEmptyLine( columns, logger ); + } + + if ( filterColumn != null ) + { + var filter = line[filterColumn.Index].Replace( "`", "" ); + logger.WriteLine( $"{TableHeaderSeparator}`{filter}`" ); + } + } + + PrintLine( line, columns, logger, style ); + rowCounter++; + } + } + + private void PrintHeader( SummaryTable.SummaryTableColumn[] columns, ILogger logger ) + { + var header = string.Join( TableHeaderSeparator, columns.Select( x => FormatHeaderCell( x.Header, x ) ) ); + var separator = string.Join( TableHeaderSeparator, columns.Select( GetColumnAlignment ) ); + + if ( ColumnsStartWithSeparator ) + { + header = TableHeaderSeparator + header; + separator = TableHeaderSeparator + separator; + } + + logger.WriteLine( header ); + logger.WriteLine( separator ); + } + + private static string FormatHeaderCell( string header, SummaryTable.SummaryTableColumn column ) + { + return column.OriginalColumn.IsNumeric + ? header.PadLeft( column.Width ) + : header.PadRight( column.Width ); + } + + private static string GetColumnAlignment( SummaryTable.SummaryTableColumn column ) + { + return column.OriginalColumn.IsNumeric + ? new string( '-', column.Width - 1 ) + ":" + : ":" + new string( '-', column.Width - 1 ); + } + + private void PrintLine( string[] line, SummaryTable.SummaryTableColumn[] columns, ILogger logger, SummaryStyle style ) + { + var formattedLine = string.Join( TableColumnSeparator, columns.Select( ( x, index ) => FormatCell( line[x.Index], x, style ) ) ); + + if ( ColumnsStartWithSeparator ) + { + formattedLine = TableColumnSeparator + formattedLine; + } + + logger.WriteLine( formattedLine ); + } + + private void PrintEmptyLine( SummaryTable.SummaryTableColumn[] columns, ILogger logger ) + { + var emptyLine = string.Join( TableColumnSeparator, columns.Select( x => new string( ' ', x.Width ) ) ); + + if ( ColumnsStartWithSeparator ) + { + emptyLine = TableColumnSeparator + emptyLine; + } + + logger.WriteLine( emptyLine ); + } + + private static string FormatCell( string cell, SummaryTable.SummaryTableColumn column, SummaryStyle style ) + { + if ( string.IsNullOrEmpty( cell ) ) + return string.Empty; + + cell = column.OriginalColumn.IsNumeric + ? cell.PadLeft( column.Width ) + : cell.PadRight( column.Width ); + + if ( cell.Length > style.MaxParameterColumnWidth ) + { + cell = cell[..style.MaxParameterColumnWidth] + "..."; + } + + return cell; + } +} diff --git a/test/Hyperbee.Json.Benchmark/JsonPathParseAndSelectEvaluator.cs b/test/Hyperbee.Json.Benchmark/JsonPathParseAndSelectEvaluator.cs index 75f5d3fb..1213a16b 100644 --- a/test/Hyperbee.Json.Benchmark/JsonPathParseAndSelectEvaluator.cs +++ b/test/Hyperbee.Json.Benchmark/JsonPathParseAndSelectEvaluator.cs @@ -69,11 +69,13 @@ public void Setup() { const string First = " `First()`"; - return Filter.EndsWith( First ) ? (Filter[..^First.Length], true) : (Filter, false); + return Filter.EndsWith( First ) + ? (Filter[..^First.Length], true) + : (Filter, false); } [Benchmark] - public void JsonPath_Hyperbee_JsonElement() + public void Hyperbee_JsonElement() { var (filter, first) = GetFilter(); @@ -86,7 +88,7 @@ public void JsonPath_Hyperbee_JsonElement() } [Benchmark] - public void JsonPath_Hyperbee_JsonNode() + public void Hyperbee_JsonNode() { var (filter, first) = GetFilter(); @@ -99,7 +101,7 @@ public void JsonPath_Hyperbee_JsonNode() } [Benchmark] - public void JsonPath_Newtonsoft_JObject() + public void Newtonsoft_JObject() { var (filter, first) = GetFilter(); @@ -112,7 +114,7 @@ public void JsonPath_Newtonsoft_JObject() } [Benchmark] - public void JsonPath_JsonEverything_JsonNode() + public void JsonEverything_JsonNode() { var (filter, first) = GetFilter(); @@ -126,7 +128,7 @@ public void JsonPath_JsonEverything_JsonNode() } [Benchmark] - public void JsonPath_JsonCons_JsonElement() + public void JsonCons_JsonElement() { var (filter, first) = GetFilter(); diff --git a/test/Hyperbee.Json.Benchmark/JsonPathSelectEvaluator.cs b/test/Hyperbee.Json.Benchmark/JsonPathSelectEvaluator.cs index d4dd3dee..0fb5d8d3 100644 --- a/test/Hyperbee.Json.Benchmark/JsonPathSelectEvaluator.cs +++ b/test/Hyperbee.Json.Benchmark/JsonPathSelectEvaluator.cs @@ -18,10 +18,10 @@ public class JsonPathSelectEvaluator "$.store.book[-1:]", "$.store.book[0,1]", "$.store.book['category','author']", - "$..book[?(@.isbn)]", - "$.store.book[?(@.price == 8.99)]", + "$..book[?@.isbn]", + "$.store.book[?@.price == 8.99]", "$..*", - "$..book[?(@.price == 8.99 && @.category == 'fiction')]" + "$..book[?@.price == 8.99 && @.category == 'fiction']" )] public string Filter; @@ -80,19 +80,19 @@ public void Setup() } [Benchmark] - public void JsonPath_Hyperbee_JsonElement() + public void Hyperbee_JsonElement() { var _ = _element.Select( Filter ).ToArray(); } [Benchmark] - public void JsonPath_Hyperbee_JsonNode() + public void Hyperbee_JsonNode() { var _ = _node.Select( Filter ).ToArray(); } [Benchmark] - public void JsonPath_Newtonsoft_JObject() + public void Newtonsoft_JObject() { var _ = _jObject.SelectTokens( Filter ).ToArray(); } diff --git a/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonDiffBenchmark-report-jsonpath.md b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonDiffBenchmark-report-jsonpath.md new file mode 100644 index 00000000..684fbfa9 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonDiffBenchmark-report-jsonpath.md @@ -0,0 +1,17 @@ +``` + +BenchmarkDotNet v0.13.12, Windows 11 (10.0.22621.3880/22H2/2022Update/SunValley2) +Intel Core i7-8850H CPU 2.60GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores +.NET SDK 8.0.303 + [Host] : .NET 8.0.7 (8.0.724.31311), X64 RyuJIT AVX2 [AttachedDebugger] + ShortRun : .NET 8.0.7 (8.0.724.31311), X64 RyuJIT AVX2 + + + | Method | Mean | Error | StdDev | Allocated + | :-------------------- | ----------: | -----------: | --------: | ---------: + | JsonDiff_JsonNode | 946.2 ns | 34.43 ns | 1.89 ns | 1.11 KB + | JsonDiff_JsonElement | 1,247.0 ns | 522.15 ns | 28.62 ns | 1.69 KB + | | | | | + | JsonDiff_JsonNode | 1,258.1 ns | 201.71 ns | 11.06 ns | 1.24 KB + | JsonDiff_JsonElement | 1,638.2 ns | 1,441.50 ns | 79.01 ns | 1.91 KB +``` diff --git a/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPatchBenchmark-report-jsonpath.md b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPatchBenchmark-report-jsonpath.md new file mode 100644 index 00000000..7fd17950 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPatchBenchmark-report-jsonpath.md @@ -0,0 +1,24 @@ +``` + +BenchmarkDotNet v0.13.12, Windows 11 (10.0.22621.3880/22H2/2022Update/SunValley2) +Intel Core i7-8850H CPU 2.60GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores +.NET SDK 8.0.303 + [Host] : .NET 8.0.7 (8.0.724.31311), X64 RyuJIT AVX2 + ShortRun : .NET 8.0.7 (8.0.724.31311), X64 RyuJIT AVX2 + + + | Method | Mean | Error | StdDev | Allocated + | :----------------------- | ----------: | ---------: | --------: | ---------: + | Hyperbee_JsonNode | 231.6 ns | 55.49 ns | 3.04 ns | 368 B + | JsonEverything_JsonNode | 405.4 ns | 135.07 ns | 7.40 ns | 728 B + | Hyperbee_JsonElement | 553.7 ns | 37.61 ns | 2.06 ns | 928 B + | AspNetCore_JsonNode | 744.9 ns | 519.69 ns | 28.49 ns | 1072 B + | | | | | + | JsonEverything_JsonNode | 442.3 ns | 316.54 ns | 17.35 ns | 672 B + | Hyperbee_JsonElement | 543.2 ns | 206.29 ns | 11.31 ns | 872 B + | AspNetCore_JsonNode | 1,257.9 ns | 476.58 ns | 26.12 ns | 1648 B + | Hyperbee_JsonNode | NA | NA | NA | NA + +Benchmarks with issues: + JsonPatchBenchmark.Hyperbee_JsonNode: ShortRun(IterationCount=3, LaunchCount=1, WarmupCount=3) [Source={"name":"John","age":30,"city":"New York"}, Operations=[{ "op":"remove", "path":"/age" }]] +``` diff --git a/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPathParseAndSelectEvaluator-report-jsonpath.md b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPathParseAndSelectEvaluator-report-jsonpath.md new file mode 100644 index 00000000..5b5a18de --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPathParseAndSelectEvaluator-report-jsonpath.md @@ -0,0 +1,46 @@ +``` + +BenchmarkDotNet v0.13.12, Windows 11 (10.0.22631.3958/23H2/2023Update/SunValley3) +Intel Core i9-9980HK CPU 2.40GHz, 1 CPU, 16 logical and 8 physical cores +.NET SDK 8.0.302 + [Host] : .NET 8.0.6 (8.0.624.26715), X64 RyuJIT AVX2 + ShortRun : .NET 8.0.6 (8.0.624.26715), X64 RyuJIT AVX2 + + + | Method | Mean | Error | StdDev | Allocated + | :----------------------- | ---------: | ----------: | ---------: | ---------: + | `$..* First()` + | Hyperbee_JsonElement | 3.075 μs | 0.0128 μs | 0.0007 μs | 3.5 KB + | Hyperbee_JsonNode | 3.183 μs | 2.5776 μs | 0.1413 μs | 3.07 KB + | JsonEverything_JsonNode | 3.206 μs | 3.7096 μs | 0.2033 μs | 3.53 KB + | JsonCons_JsonElement | 6.136 μs | 0.7476 μs | 0.0410 μs | 8.48 KB + | Newtonsoft_JObject | 8.829 μs | 10.1411 μs | 0.5559 μs | 14.22 KB + | | | | | + | `$..*` + | JsonCons_JsonElement | 5.595 μs | 0.8702 μs | 0.0477 μs | 8.45 KB + | Hyperbee_JsonElement | 7.152 μs | 5.2088 μs | 0.2855 μs | 9.09 KB + | Hyperbee_JsonNode | 9.769 μs | 9.4033 μs | 0.5154 μs | 10.86 KB + | Newtonsoft_JObject | 9.780 μs | 4.8754 μs | 0.2672 μs | 14.86 KB + | JsonEverything_JsonNode | 22.743 μs | 2.0588 μs | 0.1129 μs | 36.81 KB + | | | | | + | `$..price` + | Hyperbee_JsonElement | 4.433 μs | 0.7724 μs | 0.0423 μs | 4.34 KB + | JsonCons_JsonElement | 4.894 μs | 2.9680 μs | 0.1627 μs | 5.65 KB + | Hyperbee_JsonNode | 7.421 μs | 0.4506 μs | 0.0247 μs | 7.63 KB + | Newtonsoft_JObject | 12.818 μs | 37.7544 μs | 2.0694 μs | 14.4 KB + | JsonEverything_JsonNode | 16.584 μs | 16.7456 μs | 0.9179 μs | 27.63 KB + | | | | | + | `$.store.book[?(@.price == 8.99)]` + | Hyperbee_JsonElement | 4.164 μs | 3.7708 μs | 0.2067 μs | 5.4 KB + | JsonCons_JsonElement | 4.910 μs | 2.4579 μs | 0.1347 μs | 5.05 KB + | Hyperbee_JsonNode | 7.098 μs | 0.4756 μs | 0.0261 μs | 8.24 KB + | Newtonsoft_JObject | 10.036 μs | 11.8552 μs | 0.6498 μs | 15.84 KB + | JsonEverything_JsonNode | 11.373 μs | 3.3498 μs | 0.1836 μs | 15.85 KB + | | | | | + | `$.store.book[0]` + | Hyperbee_JsonElement | 2.682 μs | 1.8565 μs | 0.1018 μs | 2.27 KB + | JsonCons_JsonElement | 3.043 μs | 2.6136 μs | 0.1433 μs | 3.21 KB + | Hyperbee_JsonNode | 3.229 μs | 1.4402 μs | 0.0789 μs | 2.79 KB + | JsonEverything_JsonNode | 4.894 μs | 3.0709 μs | 0.1683 μs | 5.96 KB + | Newtonsoft_JObject | 9.111 μs | 8.1704 μs | 0.4478 μs | 14.56 KB +``` diff --git a/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPathSelectEvaluator-report-github.md b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPathSelectEvaluator-report-github.md deleted file mode 100644 index ef9c894e..00000000 --- a/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPathSelectEvaluator-report-github.md +++ /dev/null @@ -1,19 +0,0 @@ -``` - -BenchmarkDotNet v0.13.12, Windows 11 (10.0.22621.3593/22H2/2022Update/SunValley2) -Intel Core i7-8850H CPU 2.60GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores -.NET SDK 8.0.202 - [Host] : .NET 8.0.3 (8.0.324.11423), X64 RyuJIT AVX2 - ShortRun : .NET 8.0.3 (8.0.324.11423), X64 RyuJIT AVX2 - -Job=ShortRun IterationCount=3 LaunchCount=1 -WarmupCount=3 - -``` -| Method | Filter | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | -|----------------------------------------- |--------------------- |-----------:|-----------:|----------:|-------:|-------:|----------:| -| JsonPath_Newtonsoft_JObject | $..bo(...).99)] [27] | 2.159 μs | 1.442 μs | 0.0790 μs | 0.3929 | - | 1.81 KB | -| JsonPath_ExpressionEvaluator_JsonElement | $..bo(...).99)] [27] | 9.236 μs | 5.298 μs | 0.2904 μs | 2.0447 | 0.0153 | 9.42 KB | -| JsonPath_ExpressionEvaluator_JsonNode | $..bo(...).99)] [27] | 10.865 μs | 2.530 μs | 0.1387 μs | 2.7618 | 0.0153 | 12.71 KB | -| JsonPath_CSharpEvaluator_JsonNode | $..bo(...).99)] [27] | 329.290 μs | 102.773 μs | 5.6333 μs | 4.3945 | - | 22.12 KB | -| JsonPath_CSharpEvaluator_JsonElement | $..bo(...).99)] [27] | 340.260 μs | 69.324 μs | 3.7999 μs | 3.9063 | - | 20.14 KB | diff --git a/test/Hyperbee.Json.Cts/Tests/cts-index-selector-tests.cs b/test/Hyperbee.Json.Cts/Tests/cts-index-selector-tests.cs index ae7d8bb8..674c62d2 100644 --- a/test/Hyperbee.Json.Cts/Tests/cts-index-selector-tests.cs +++ b/test/Hyperbee.Json.Cts/Tests/cts-index-selector-tests.cs @@ -167,7 +167,7 @@ public void Test_negative_out_of_bound_8( Type documentType ) "second" ] """ ); - var results = document.Select( selector ); + var results = document.Select( selector ).ToArray(); var expect = TestHelper.Parse( documentType, """ [] diff --git a/test/Hyperbee.Json.Tests/Builder/JsonPathBuilderTests.cs b/test/Hyperbee.Json.Tests/Core/JsonPathBuilderTests.cs similarity index 94% rename from test/Hyperbee.Json.Tests/Builder/JsonPathBuilderTests.cs rename to test/Hyperbee.Json.Tests/Core/JsonPathBuilderTests.cs index 14bd9608..10a4413b 100644 --- a/test/Hyperbee.Json.Tests/Builder/JsonPathBuilderTests.cs +++ b/test/Hyperbee.Json.Tests/Core/JsonPathBuilderTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; -using Hyperbee.Json.Extensions; +using Hyperbee.Json.Core; using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Builder; +namespace Hyperbee.Json.Tests.Core; [TestClass] public class JsonPathBuilderTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Core/SliceSyntaxHelperTests.cs b/test/Hyperbee.Json.Tests/Core/SliceSyntaxHelperTests.cs new file mode 100644 index 00000000..f0faa456 --- /dev/null +++ b/test/Hyperbee.Json.Tests/Core/SliceSyntaxHelperTests.cs @@ -0,0 +1,80 @@ +using System; +using Hyperbee.Json.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Core +{ + [TestClass] + public class SliceSyntaxHelperTests + { + [TestMethod] + public void TestValidSliceExpression() + { + var result = SliceSyntaxHelper.ParseExpression( "1:5:2".AsSpan(), 10 ); + Assert.AreEqual( (1, 5, 2), result ); + } + + [TestMethod] + public void TestDefaultStep() + { + var result = SliceSyntaxHelper.ParseExpression( "1:5".AsSpan(), 10 ); + Assert.AreEqual( (1, 5, 1), result ); + } + + [TestMethod] + public void TestNegativeStep() + { + var result = SliceSyntaxHelper.ParseExpression( "5:1:-1".AsSpan(), 10 ); + Assert.AreEqual( (1, 5, -1), result ); + } + + [TestMethod] + public void TestReverseOrder() + { + var result = SliceSyntaxHelper.ParseExpression( "1:5:2".AsSpan(), 10, reverse: true ); + Assert.AreEqual( (-1, 3, -2), result ); + } + + [TestMethod] + [ExpectedException( typeof( InvalidOperationException ) )] + public void TestInvalidSliceExpression() + { + SliceSyntaxHelper.ParseExpression( "1:2:3:4".AsSpan(), 10 ); + } + + [TestMethod] + public void TestEmptySliceExpression() + { + var result = SliceSyntaxHelper.ParseExpression( "".AsSpan(), 10 ); + Assert.AreEqual( (0, 10, 1), result ); + } + + [TestMethod] + public void TestStepZero() + { + var result = SliceSyntaxHelper.ParseExpression( "1:5:0".AsSpan(), 10 ); + Assert.AreEqual( (0, 0, 0), result ); + } + + [TestMethod] + public void TestNegativeIndices() + { + var result = SliceSyntaxHelper.ParseExpression( "-5:-1:1".AsSpan(), 10 ); + Assert.AreEqual( (5, 9, 1), result ); + } + + [TestMethod] + public void TestLargeStep() + { + var result = SliceSyntaxHelper.ParseExpression( "1:5:100".AsSpan(), 10 ); + Assert.AreEqual( (1, 5, 10), result ); + } + + [TestMethod] + public void TestLargeNegativeStep() + { + var result = SliceSyntaxHelper.ParseExpression( "5:1:-100".AsSpan(), 10 ); + Assert.AreEqual( (1, 5, -10), result ); + } + } +} diff --git a/test/Hyperbee.Json.Tests/Dynamic/JsonDynamicTests.cs b/test/Hyperbee.Json.Tests/Dynamic/JsonDynamicTests.cs index cede4e0a..1dec05ed 100644 --- a/test/Hyperbee.Json.Tests/Dynamic/JsonDynamicTests.cs +++ b/test/Hyperbee.Json.Tests/Dynamic/JsonDynamicTests.cs @@ -1,7 +1,6 @@ using System; using System.Text.Json; using Hyperbee.Json.Dynamic; -using Hyperbee.Json.Extensions; using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; diff --git a/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj b/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj index 8e5a9b87..90063582 100644 --- a/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj +++ b/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj @@ -5,7 +5,7 @@ Hyperbee.Json.Tests - + diff --git a/test/Hyperbee.Json.Tests/Parsers/JsonPathQueryParserTests.cs b/test/Hyperbee.Json.Tests/Parsers/JsonPathQueryParserTests.cs deleted file mode 100644 index f0e2cac3..00000000 --- a/test/Hyperbee.Json.Tests/Parsers/JsonPathQueryParserTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Linq; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Hyperbee.Json.Tests.Parsers; - -[TestClass] -public class JsonPathQueryParserTests -{ - [DataTestMethod] - [DataRow( "$", "[$ => 1]" )] - [DataRow( "$.two.some", "[$ => 1][two => 1][some => 1]" )] - [DataRow( "$.thing[1:2:3]", "[$ => 1][thing => 1][1:2:3 => #]" )] - [DataRow( "$..thing[?(@.x == 1)]", "[$ => 1][.. => #][thing => 1][?(@.x == 1) => #]" )] - [DataRow( "$['two.some']", "[$ => 1][two.some => 1]" )] - [DataRow( "$.two.some.thing['this.or.that']", "[$ => 1][two => 1][some => 1][thing => 1][this.or.that => 1]" )] - [DataRow( "$.store.book[*].author", "[$ => 1][store => 1][book => 1][* => #][author => 1]" )] - [DataRow( "@..author", "[@ => 1][.. => #][author => 1]" )] - [DataRow( "$.store.*", "[$ => 1][store => 1][* => #]" )] - [DataRow( "$.store..price", "[$ => 1][store => 1][.. => #][price => 1]" )] - [DataRow( "$..book[2]", "[$ => 1][.. => #][book => 1][2 => 1]" )] - [DataRow( "$..book[-1:]", "[$ => 1][.. => #][book => 1][-1: => #]" )] - [DataRow( "$..book[:2]", "[$ => 1][.. => #][book => 1][:2 => #]" )] - [DataRow( "$..book[0,1]", "[$ => 1][.. => #][book => 1][0,1 => #]" )] - [DataRow( "$.store.book[0,1]", "[$ => 1][store => 1][book => 1][0,1 => #]" )] - [DataRow( "$..book['category','author']", "[$ => 1][.. => #][book => 1][category,author => #]" )] - [DataRow( "$..book[?(@.isbn)]", "[$ => 1][.. => #][book => 1][?(@.isbn) => #]" )] - [DataRow( "$..book[?@.isbn]", "[$ => 1][.. => #][book => 1][?@.isbn => #]" )] - [DataRow( "$..book[?(@.price<10)]", "[$ => 1][.. => #][book => 1][?(@.price<10) => #]" )] - [DataRow( "$..book[?@.price<10]", "[$ => 1][.. => #][book => 1][?@.price<10 => #]" )] - [DataRow( "$..*", "[$ => 1][.. => #][* => #]" )] - [DataRow( "$..book[?(@.price == 8.99 && @.category == \"fiction\")]", "[$ => 1][.. => #][book => 1][?(@.price == 8.99 && @.category == \"fiction\") => #]" )] - public void TokenizeJsonPath( string jsonPath, string expected ) - { - // act - var compiledQuery = JsonPathQueryParser.Parse( jsonPath ); - - // arrange - var result = GetResultString( compiledQuery.Segments ); - - // assert - Assert.AreEqual( expected, result ); - - return; - - static string GetResultString( JsonPathSegment segment ) - { - return string.Join( "", segment.AsEnumerable().Select( ConvertToString ) ); - - static string ConvertToString( JsonPathSegment segment ) - { - var (singular, selectors) = segment; - var selectorType = singular ? "1" : "#"; // 1:singular, #:group - var selectorsString = string.Join( ',', selectors.Select( x => x.Value ).Reverse() ); - - return $"[{selectorsString} => {selectorType}]"; - } - } - } - - [TestMethod] - public void ShouldFilterExpressionWithParentAxisOperator() - { - // NOT-SUPPORTED: parent axis operator is not supported - - // act & assert - const string jsonPath = "$[*].bookmarks[ ? (@.page == 45)]^^^"; - - Assert.ThrowsException( () => - { - _ = JsonPathQueryParser.Parse( jsonPath ); - } ); - } -} diff --git a/test/Hyperbee.Json.Tests/Patch/JsonDiffTests.cs b/test/Hyperbee.Json.Tests/Patch/JsonDiffTests.cs new file mode 100644 index 00000000..700355ba --- /dev/null +++ b/test/Hyperbee.Json.Tests/Patch/JsonDiffTests.cs @@ -0,0 +1,419 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using Hyperbee.Json.Patch; +using Hyperbee.Json.Tests.TestSupport; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Patch; + +[TestClass] +public class JsonDiffTests : JsonTestBase +{ + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void Add_WhenTargetHasAdditionalProperty( Type sourceType ) + { + var source = + """ + { + "first": "John" + } + """; + + var target = + """ + { + "first": "John", + "last": "Doe" + } + """; + + var results = Diff( sourceType, source, target ); + + Assert.IsTrue( results.Length == 1 ); + Assert.AreEqual( PatchOperationType.Add, results[0].Operation ); + Assert.AreEqual( "/last", results[0].Path ); + Assert.AreEqual( "Doe", Unwrap( results[0].Value ) ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void Add_WhenTargetArrayHasMoreItems( Type sourceType ) + { + var source = + """ + { + "categories": [ "A" ] + } + """; + + var target = + """ + { + "categories": [ "A", "B" ] + } + """; + + var results = Diff( sourceType, source, target ); + + Assert.IsTrue( results.Length == 1 ); + Assert.AreEqual( PatchOperationType.Add, results[0].Operation ); + Assert.AreEqual( "/categories/1", results[0].Path ); + Assert.AreEqual( "B", Unwrap( results[0].Value ) ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void Remove_WhenTargetIsMissingProperty( Type sourceType ) + { + var source = + """ + { + "first": "John", + "last": "Doe" + } + """; + + var target = + """ + { + "first": "John" + } + """; + + var results = Diff( sourceType, source, target ); + + Assert.IsTrue( results.Length == 1 ); + Assert.AreEqual( PatchOperationType.Remove, results[0].Operation ); + Assert.AreEqual( "/last", results[0].Path ); + Assert.IsNull( results[0].Value ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void Remove_WhenTargetArrayHasLessItems( Type sourceType ) + { + var source = + """ + { + "categories": [ "A", "B" ] + } + """; + + var target = + """ + { + "categories": [ "A" ] + } + """; + + var results = Diff( sourceType, source, target ); + + Assert.IsTrue( results.Length == 1 ); + Assert.AreEqual( PatchOperationType.Remove, results[0].Operation ); + Assert.AreEqual( "/categories/1", results[0].Path ); + Assert.IsNull( results[0].Value ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void Replace_WhenTargetPropertyUpdated( Type sourceType ) + { + var source = + """ + { + "first": "John" + } + """; + + var target = + """ + { + "first": "Mark" + } + """; + + var results = Diff( sourceType, source, target ); + + Assert.IsTrue( results.Length == 1 ); + Assert.AreEqual( PatchOperationType.Replace, results[0].Operation ); + Assert.AreEqual( "/first", results[0].Path ); + Assert.AreEqual( "Mark", Unwrap( results[0].Value ) ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void Replace_WhenTargetArrayItemsAreDifferent( Type sourceType ) + { + var source = + """ + { + "categories": [ "A", "B" ] + } + """; + + var target = + """ + { + "categories": [ "A", "C" ] + } + """; + + var results = Diff( sourceType, source, target ); + + Assert.IsTrue( results.Length == 1 ); + Assert.AreEqual( PatchOperationType.Replace, results[0].Operation ); + Assert.AreEqual( "/categories/1", results[0].Path ); + Assert.AreEqual( "C", Unwrap( results[0].Value ) ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void Replace_WhenComplexTargetArrayHasDifferentValues( Type sourceType ) + { + var source = + """ + { + "categories": [ + "A", + { + "name": "B", + "value": 1 + } + ] + } + """; + + var target = + """ + { + "categories": [ + "A", + { + "name": "B", + "value": 2 + }, + { + "name": "C", + "value": 3 + } + ] + } + """; + + var results = Diff( sourceType, source, target ); + + Assert.IsTrue( results.Length == 2 ); + + Assert.AreEqual( PatchOperationType.Add, results[0].Operation ); + Assert.AreEqual( "/categories/2", results[0].Path ); + + Assert.AreEqual( PatchOperationType.Replace, results[1].Operation ); + Assert.AreEqual( "/categories/1/value", results[1].Path ); + Assert.AreEqual( 2, Unwrap( results[1].Value ) ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void MultipleOperations_WhenTargetHasMultipleUpdates( Type sourceType ) + { + var source = + """ + { + "first": "John", + "age": 30, + "address": { + "city": "New York", + "state": "NY" + }, + "categories": [ "A", "B" ] + } + """; + + var target = + """ + { + "first": "John", + "last": "Doe", + "age": 45, + "address": { + "city": "New York", + "zip": "12345" + }, + "categories": [ "B", "C", "D" ], + "job": { + "title": "Developer", + "company": "Microsoft" + } + } + """; + + var results = Diff( sourceType, source, target ).ToArray(); + + Assert.IsTrue( results.Length == 8 ); + } + + [DataTestMethod] + [DataRow( typeof( JsonDocument ) )] + [DataRow( typeof( JsonNode ) )] + public void EscapePath_WhenJsonHasPropertyNames( Type sourceType ) + { + var source = + """ + { + "foo": ["bar", "baz"], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + " ": 7, + "m~n": 8 + } + """; + + var target = + """ + { + } + """; + + var results = Diff( sourceType, source, target ).ToArray(); + + Assert.IsTrue( results.Length == 10 ); + + Assert.AreEqual( "/foo", results[0].Path ); + Assert.AreEqual( "/", results[1].Path ); + Assert.AreEqual( "/a~1b", results[2].Path ); + Assert.AreEqual( "/c%d", results[3].Path ); + Assert.AreEqual( "/e^f", results[4].Path ); + Assert.AreEqual( "/g|h", results[5].Path ); + Assert.AreEqual( "/i\\j", results[6].Path ); + Assert.AreEqual( "/k\"l", results[7].Path ); + Assert.AreEqual( "/ ", results[8].Path ); + Assert.AreEqual( "/m~0n", results[9].Path ); + } + + [TestMethod] + public void MultipleOperations_WhenSourceAndTargetAreObjects() + { + var source = new + { + first = "John", + age = 30, + address = new { city = "New York", state = "NY" }, + categories = new[] { "A", "B" } + }; + + var target = new + { + first = "John", + last = "Doe", + age = 45, + address = new { city = "New York", zip = "12345" }, + categories = new[] { "B", "C", "D" }, + job = new { title = "Developer", company = "Microsoft" } + }; + + var results = JsonDiff.Diff( source, target ).ToArray(); + + Assert.IsTrue( results.Length == 8 ); + } + + [TestMethod] + public void ApplyPatch_ToExistingSource() + { + const string sourceJson = + """ + { + "categories": ["a", "b", "c"] + } + """; + const string targetJson = + """ + { + "categories": ["a", "c", "d"] + } + """; + + var source = JsonNode.Parse( sourceJson ); + var target = JsonNode.Parse( targetJson ); + + var diff = JsonDiff.Diff( source, target ).ToArray(); + + // NOTE: this is testing that the patch applies a copy of the source + // else JsonNode will fail with a parent already exists exception + var patch = new JsonPatch( diff ); + patch.Apply( source ); + } + + private static object Unwrap( object value ) + { + switch ( value ) + { + case JsonElement element: + switch ( element.ValueKind ) + { + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + return element.GetInt32(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + return null; + default: + return element; + } + case JsonNode node: + switch ( node.GetValueKind() ) + { + case JsonValueKind.String: + return node.GetValue(); + case JsonValueKind.Number: + return node.GetValue(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Null: + return null; + default: + return node; + } + default: + return value; + } + } + + private static PatchOperation[] Diff( Type sourceType, string source, string target ) + { + return sourceType switch + { + _ when sourceType == typeof( JsonNode ) => JsonDiff.Diff( + JsonNode.Parse( source ), + JsonNode.Parse( target ) ).ToArray(), + + _ when sourceType == typeof( JsonDocument ) => JsonDiff.Diff( + JsonDocument.Parse( source ).RootElement, + JsonDocument.Parse( target ).RootElement ).ToArray(), + + _ => throw new ArgumentOutOfRangeException( nameof( sourceType ), sourceType, null ) + }; + } +} + diff --git a/test/Hyperbee.Json.Tests/Patch/JsonPatchTests.cs b/test/Hyperbee.Json.Tests/Patch/JsonPatchTests.cs new file mode 100644 index 00000000..f1e9ff56 --- /dev/null +++ b/test/Hyperbee.Json.Tests/Patch/JsonPatchTests.cs @@ -0,0 +1,1480 @@ +using System; +using System.Text.Json.Nodes; +using Hyperbee.Json.Patch; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Patch; + +[TestClass] +public class JsonPatchTests +{ + [TestMethod] + public void Add_WhenValueProperty() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "company": "Acme" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/job/title", null, "developer" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "developer", source!["job"]!["title"]!.GetValue() ); + } + + [TestMethod] + public void Add_WhenValueObject() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( + PatchOperationType.Add, + "/job", + null, + JsonNode.Parse( + """ + { + "title": "developer", + "company": "Acme" + } + """ + ) + ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "developer", source!["job"]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["job"]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Add_WhenValueArray() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/categories/0", null, "b" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "b", source!["categories"]![0]!.GetValue() ); + } + + [TestMethod] + public void Add_WhenValueArrayAtExistIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/categories/0", null, "b" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "b", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "a", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + public void Add_WhenValueArrayAtEnd() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/categories/-", null, "b" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "b", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + public void Add_WhenValueArrayAtNextIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/categories/1", null, "b" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "b", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void AddFail_WhenValueArrayAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/categories/0", null, "b" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void AddFail_WhenValueArrayOutOfRange() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/categories/2", null, "b" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void AddFail_WhenValueArrayInvalidIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/categories/NaN", null, "b" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void AddFail_WhenValuePropertyAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/job/title", null, "developer" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + public void Copy_WhenFromProperty() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "company": "Acme", + "title": "developer" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/title", "/job/title", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonObject) source!["job"]!).Count ); + Assert.AreEqual( "developer", source!["title"]!.GetValue() ); + } + + [TestMethod] + public void Copy_WhenFromObject() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/position", "/job", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 3, ((JsonObject) source!).Count ); + Assert.AreEqual( "developer", source!["position"]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["position"]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Copy_WhenFromArray() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"], + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/ideas/0", "/categories/0", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 1, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( 1, ((JsonArray) source!["ideas"]!).Count ); + Assert.AreEqual( "a", source!["ideas"]![0]!.GetValue() ); + } + + [TestMethod] + public void Copy_WhenFromArrayToObject() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/ideas", "/categories/0", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 1, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["ideas"]!.GetValue() ); + } + + [TestMethod] + public void Copy_WhenFromObjectToArray() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + }, + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/ideas/0", "/job", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 3, ((JsonObject) source!).Count ); + Assert.AreEqual( "developer", source!["ideas"]![0]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["ideas"]![0]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Copy_WhenFromPropertyToArrayAtExistIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/categories/0", "/first", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "John", source!["first"]!.GetValue() ); + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "John", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "a", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + public void Copy_WhenFromPropertyToArrayAtNextIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/categories/1", "/first", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "John", source!["first"]!.GetValue() ); + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "John", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void CopyFail_WhenFromPropertyToArrayOutOfRange() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + }, + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/ideas/1", "/job", null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void CopyFail_WhenFromPropertyToArrayInvalidIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + }, + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/ideas/NaN", "/job", null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void CopyFail_WhenFromPropertyAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/title", "/job/title", null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + public void Copy_WhenFromPropertyIsChildOfSelf() + { + var source = JsonNode.Parse( + """ + { + "job": { + "title": "developer", + "company": "Acme" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/job/sub", "/job", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "developer", source!["job"]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["job"]!["company"]!.GetValue() ); + + Assert.AreEqual( "developer", source!["job"]!["sub"]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["job"]!["sub"]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Move_WhenFromProperty() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "company": "Acme", + "title": "developer" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/title", "/job/title", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 1, ((JsonObject) source!["job"]!).Count ); + Assert.AreEqual( "developer", source!["title"]!.GetValue() ); + } + + [TestMethod] + public void Move_WhenFromObject() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/position", "/job", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "developer", source!["position"]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["position"]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Move_WhenFromArray() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"], + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/ideas/0", "/categories/0", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 0, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( 1, ((JsonArray) source!["ideas"]!).Count ); + Assert.AreEqual( "a", source!["ideas"]![0]!.GetValue() ); + } + + [TestMethod] + public void Move_WhenFromArrayToObject() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/ideas", "/categories/0", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 0, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["ideas"]!.GetValue() ); + } + + [TestMethod] + public void Move_WhenFromObjectToArray() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + }, + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/ideas/0", "/job", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonObject) source!).Count ); + Assert.AreEqual( "developer", source!["ideas"]![0]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["ideas"]![0]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Move_WhenFromPropertyToArrayAtExistIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/categories/0", "/first", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "John", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "a", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + public void Move_WhenFromPropertyToArrayAtNextIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/categories/1", "/first", null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "John", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void MoveFail_WhenFromPropertyToArrayOutOfRange() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + }, + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/ideas/1", "/job", null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void MoveFail_WhenFromPropertyToArrayInvalidIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + }, + "ideas": [] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/ideas/NaN", "/job", null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void MoveFail_WhenFromPropertyAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/title", "/job/title", null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void MoveFail_WhenFromPropertyIsChildOfSelf() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/job/sub", "/job", null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + public void Remove_WhenValueProperty() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/job/title", null, null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 1, source!["job"]!.AsObject().Count ); + } + + [TestMethod] + public void Remove_WhenValueObject() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "developer", + "company": "Acme" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/job", null, null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 1, source!.AsObject().Count ); + } + + [TestMethod] + public void Remove_WhenValueArray() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a", "b" ] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/categories", null, null ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 1, source!.AsObject().Count ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void RemoveFail_WhenValueArrayAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/categories/0", null, null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void RemoveFail_WhenValueArrayOutOfRange() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a", "b"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/categories/2", null, null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void RemoveFail_WhenValueArrayInvalidIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/categories/NaN", null, null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void RemoveFail_WhenValuePropertyAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/job/title", null, null ) + ); + + patch.Apply( source ); + } + + [TestMethod] + public void Replace_WhenValueProperty() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/first", null, "Mark" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "Mark", source!["first"]!.GetValue() ); + } + + [TestMethod] + public void Replace_WhenValueObject() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "Marketing", + "company": "Microsoft" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/job", null, JsonNode.Parse( """ + { + "title": "developer", + "company": "Acme" + } + """ ) ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "developer", source!["job"]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["job"]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Replace_WhenValueArray() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": [ "a", "b" ] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/categories/0", null, "c" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "c", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "b", source!["categories"]![1]!.GetValue() ); + } + + [TestMethod] + public void Replace_WhenValueArrayAtExistIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/categories/0", null, "b" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 1, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "b", source!["categories"]![0]!.GetValue() ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void ReplaceFail_WhenValueArrayAtEnd() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/categories/-", null, "b" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void ReplaceFail_WhenValueArrayAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/categories/0", null, "b" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void ReplaceFail_WhenValueArrayOutOfRange() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/categories/2", null, "b" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void ReplaceFail_WhenValueArrayInvalidIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/categories/NaN", null, "b" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void ReplaceFail_WhenValuePropertyAndMissingParent() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/job/title", null, "developer" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + public void Test_WhenValuePropertyEqual() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/first", null, "John" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "John", source!["first"]!.GetValue() ); + } + + [TestMethod] + public void Test_WhenValueObjectEqual() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "Marketing", + "company": "Microsoft" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/job", null, JsonNode.Parse( """ + { + "title": "Marketing", + "company": "Microsoft" + } + """ ) ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "Marketing", source!["job"]!["title"]!.GetValue() ); + Assert.AreEqual( "Microsoft", source!["job"]!["company"]!.GetValue() ); + } + + [TestMethod] + public void Test_WhenValueArrayEqual() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": [ "a", "b" ] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/categories/0", null, "a" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( 2, ((JsonArray) source!["categories"]!).Count ); + Assert.AreEqual( "a", source!["categories"]![0]!.GetValue() ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void TestFail_WhenValueObjectNotEqual() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "job": { + "title": "Marketing", + "company": "Microsoft" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/job", null, JsonNode.Parse( """ + { + "title": "developer", + "company": "Microsoft" + } + """ ) ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void TestFail_WhenValueArrayNotEqual() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": [ "a", "b" ] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/categories/0", null, "c" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void TestFail_WhenValueArrayOutOfRange() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/categories/1", null, "a" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void TestFail_WhenValueArrayInvalidIndex() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "categories": ["a"] + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/categories/NaN", null, "a" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + [ExpectedException( typeof( JsonPatchException ) )] + public void TestFail_WhenValuePropertyNotEqual() + { + var source = JsonNode.Parse( + """ + { + "first": "John" + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Test, "/first", null, "Mark" ) + ); + + patch.Apply( source ); + } + + [TestMethod] + public void MultipleOperations_WhenRunTogether() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "address": { + "city": "New York", + "zip": "10001" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/last", null, "Doe" ), + new PatchOperation( PatchOperationType.Add, "/job", null, JsonNode.Parse( "{}" ) ), + new PatchOperation( PatchOperationType.Add, "/job/title", null, "developer" ), + new PatchOperation( PatchOperationType.Add, "/job/company", null, "Acme" ), + new PatchOperation( PatchOperationType.Remove, "/first", null, null ), + new PatchOperation( PatchOperationType.Add, "/address/state", null, "NY" ), + new PatchOperation( PatchOperationType.Add, "/categories", null, JsonNode.Parse( "[]" ) ), + new PatchOperation( PatchOperationType.Add, "/categories/0", null, "a" ), + new PatchOperation( PatchOperationType.Add, "/categories/1", null, "b" ), + new PatchOperation( PatchOperationType.Add, "/categories/-", null, "c" ), + new PatchOperation( PatchOperationType.Move, "/location", "/address", null ), + new PatchOperation( PatchOperationType.Replace, "/location/state", null, "CA" ), + new PatchOperation( PatchOperationType.Replace, "/location/city", null, "Los Angeles" ) + ); + + patch.Apply( source ); + + Assert.AreEqual( "Doe", source!["last"]!.GetValue() ); + + Assert.AreEqual( "a", source!["categories"]![0]!.GetValue() ); + Assert.AreEqual( "b", source!["categories"]![1]!.GetValue() ); + Assert.AreEqual( "c", source!["categories"]![2]!.GetValue() ); + + Assert.AreEqual( "developer", source!["job"]!["title"]!.GetValue() ); + Assert.AreEqual( "Acme", source!["job"]!["company"]!.GetValue() ); + + Assert.AreEqual( "CA", source!["location"]!["state"]!.GetValue() ); + Assert.AreEqual( "Los Angeles", source!["location"]!["city"]!.GetValue() ); + } + + [TestMethod] + public void Rollback_Add() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "address": { + "city": "New York", + "zip": "10001" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/address/state", null, "NY" ), + // Forced failure + new PatchOperation( PatchOperationType.Test, "/invalid", null, "noop" ) + ); + + try + { + patch.Apply( source ); + Assert.Fail( "Test should fail and rollback changes" ); + } + catch ( JsonPatchException ) + { + Assert.AreEqual( "New York", source!["address"]!["city"]!.GetValue() ); + } + } + + [TestMethod] + public void Rollback_Copy() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "address": { + "city": "New York", + "zip": "10001" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Copy, "/location", "/address", null ), + // Forced failure + new PatchOperation( PatchOperationType.Test, "/invalid", null, "noop" ) + ); + + try + { + patch.Apply( source ); + Assert.Fail( "Test should fail and rollback changes" ); + } + catch ( JsonPatchException ) + { + Assert.AreEqual( 2, ((JsonObject) source!).Count ); + Assert.AreEqual( "New York", source!["address"]!["city"]!.GetValue() ); + } + } + + [TestMethod] + public void Rollback_Move() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "address": { + "city": "New York", + "zip": "10001" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Move, "/location", "/address", null ), + // Forced failure + new PatchOperation( PatchOperationType.Test, "/invalid", null, "noop" ) + ); + + try + { + patch.Apply( source ); + Assert.Fail( "Test should fail and rollback changes" ); + } + catch ( JsonPatchException ) + { + Assert.AreEqual( "New York", source!["address"]!["city"]!.GetValue() ); + } + } + + [TestMethod] + public void Rollback_Remove() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "address": { + "city": "New York", + "zip": "10001" + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Remove, "/address", null, null ), + // Forced failure + new PatchOperation( PatchOperationType.Test, "/invalid", null, "noop" ) + ); + + try + { + patch.Apply( source ); + Assert.Fail( "Test should fail and rollback changes" ); + } + catch ( JsonPatchException ) + { + Assert.AreEqual( "New York", source!["address"]!["city"]!.GetValue() ); + } + } + + [TestMethod] + public void Rollback_Replace() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "address": { + "city": "New York", + "zip": 10001 + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Replace, "/address", null, JsonNode.Parse( """ + { + "city": "Los Angeles", + "zip": 90001 + } + """ ) ), + // Forced failure + new PatchOperation( PatchOperationType.Test, "/invalid", null, "noop" ) + ); + + try + { + patch.Apply( source ); + Assert.Fail( "Test should fail and rollback changes" ); + } + catch ( JsonPatchException ) + { + Assert.AreEqual( "New York", source!["address"]!["city"]!.GetValue() ); + Assert.AreEqual( 10001, source!["address"]!["zip"]!.GetValue() ); + } + } + + [TestMethod] + public void MultipleOperations_WhenRollbackTogether() + { + var source = JsonNode.Parse( + """ + { + "first": "John", + "address": { + "city": "New York", + "zip": 10001 + } + } + """ ); + + var patch = new JsonPatch( + new PatchOperation( PatchOperationType.Add, "/last", null, "Doe" ), + new PatchOperation( PatchOperationType.Add, "/job", null, JsonNode.Parse( "{}" ) ), + new PatchOperation( PatchOperationType.Add, "/job/title", null, "developer" ), + new PatchOperation( PatchOperationType.Add, "/job/company", null, "Acme" ), + new PatchOperation( PatchOperationType.Remove, "/first", null, null ), + new PatchOperation( PatchOperationType.Add, "/address/state", null, "NY" ), + new PatchOperation( PatchOperationType.Add, "/categories", null, JsonNode.Parse( "[]" ) ), + new PatchOperation( PatchOperationType.Add, "/categories/0", null, "a" ), + new PatchOperation( PatchOperationType.Add, "/categories/1", null, "b" ), + new PatchOperation( PatchOperationType.Copy, "/categories/1", "/categories/0", null ), + new PatchOperation( PatchOperationType.Add, "/categories/-", null, "c" ), + new PatchOperation( PatchOperationType.Move, "/location", "/address", null ), + new PatchOperation( PatchOperationType.Replace, "/location/state", null, "CA" ), + new PatchOperation( PatchOperationType.Replace, "/location/city", null, "Los Angeles" ), + // Forced failure + new PatchOperation( PatchOperationType.Test, "/invalid", null, "noop" ) + ); + + try + { + patch.Apply( source ); + Assert.Fail( "Test should have failed and rollback changes" ); + } + catch ( JsonPatchException ex ) + { + // verify that it was the error the test caused + Assert.AreEqual( "The target location '/invalid' did not exist.", ex.Message, "Invalid error thrown" ); + + Assert.AreEqual( 2, ((JsonObject) source!).Count ); + Assert.AreEqual( "New York", source!["address"]!["city"]!.GetValue() ); + Assert.AreEqual( 10001, source!["address"]!["zip"]!.GetValue() ); + } + } + +} diff --git a/test/Hyperbee.Json.Tests/Parsers/ExtensionFunctionTests.cs b/test/Hyperbee.Json.Tests/Path/Parser/ExtensionFunctionTests.cs similarity index 89% rename from test/Hyperbee.Json.Tests/Parsers/ExtensionFunctionTests.cs rename to test/Hyperbee.Json.Tests/Path/Parser/ExtensionFunctionTests.cs index 9a197b05..c87a9e8b 100644 --- a/test/Hyperbee.Json.Tests/Parsers/ExtensionFunctionTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Parser/ExtensionFunctionTests.cs @@ -1,14 +1,15 @@ using System.Linq; using System.Reflection; using System.Text.Json.Nodes; +using Hyperbee.Json.Descriptors; using Hyperbee.Json.Descriptors.Element; using Hyperbee.Json.Extensions; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Parsers; +namespace Hyperbee.Json.Tests.Path; [TestClass] public class ExtensionFunctionTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Parsers/FilterParserTests.cs b/test/Hyperbee.Json.Tests/Path/Parser/FilterParserTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Parsers/FilterParserTests.cs rename to test/Hyperbee.Json.Tests/Path/Parser/FilterParserTests.cs index 7a25cf86..c150e63c 100644 --- a/test/Hyperbee.Json.Tests/Parsers/FilterParserTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Parser/FilterParserTests.cs @@ -4,12 +4,12 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; -using Hyperbee.Json.Filters; -using Hyperbee.Json.Filters.Parser; +using Hyperbee.Json.Path.Filters; +using Hyperbee.Json.Path.Filters.Parser; using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Parsers; +namespace Hyperbee.Json.Tests.Path.Parser; [TestClass] public class FilterParserTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Parsers/ValueTypeComparerTests.cs b/test/Hyperbee.Json.Tests/Path/Parser/ValueTypeComparerTests.cs similarity index 96% rename from test/Hyperbee.Json.Tests/Parsers/ValueTypeComparerTests.cs rename to test/Hyperbee.Json.Tests/Path/Parser/ValueTypeComparerTests.cs index 801fa125..2595a643 100644 --- a/test/Hyperbee.Json.Tests/Parsers/ValueTypeComparerTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Parser/ValueTypeComparerTests.cs @@ -1,12 +1,11 @@ using System.Collections.Generic; using System.Text.Json.Nodes; -using Hyperbee.Json.Descriptors.Node; -using Hyperbee.Json.Filters.Parser; -using Hyperbee.Json.Filters.Values; +using Hyperbee.Json.Path.Filters.Parser; +using Hyperbee.Json.Path.Filters.Values; using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Parsers; +namespace Hyperbee.Json.Tests.Path.Parser; [TestClass] public class NodeTypeComparerTests : JsonTestBase @@ -137,7 +136,7 @@ public void Compare_WithEmpty() // Helper methods - private static ValueTypeComparer GetComparer() => new( new NodeValueAccessor() ); + private static ValueTypeComparer GetComparer() => new(); private static IValueType GetNodeValue( object item ) { diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathArrayTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathArrayTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Query/JsonPathArrayTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathArrayTests.cs index de49693d..667881e0 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathArrayTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathArrayTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathArrayTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreSelectPathTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathBookstoreSelectPathTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Query/JsonPathBookstoreSelectPathTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathBookstoreSelectPathTests.cs index 65376c3e..1bdef531 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreSelectPathTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathBookstoreSelectPathTests.cs @@ -4,7 +4,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathBookstoreSelectPathTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathBookstoreTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Query/JsonPathBookstoreTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathBookstoreTests.cs index d12dfbdb..93c9ba33 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathBookstoreTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathBookstoreTests : JsonTestBase @@ -31,7 +31,7 @@ public void TheRootOfEverything( string query, Type sourceType ) public void TheAuthorsOfAllBooksInTheStore( string query, Type sourceType ) { var source = GetDocumentAdapter( sourceType ); - var matches = source.Select( query ); + var matches = source.Select( query ).ToList(); var expected = new[] { source.FromJsonPathPointer( "$.store.book[0].author" ), diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathBracketNotationTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathBracketNotationTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Query/JsonPathBracketNotationTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathBracketNotationTests.cs index 25ecd658..fbf9420f 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathBracketNotationTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathBracketNotationTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathBracketNotationTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathDescentTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathDescentTests.cs similarity index 98% rename from test/Hyperbee.Json.Tests/Query/JsonPathDescentTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathDescentTests.cs index 3e3a354e..01133ebf 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathDescentTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathDescentTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathDescentTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathDotNotationTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathDotNotationTests.cs similarity index 98% rename from test/Hyperbee.Json.Tests/Query/JsonPathDotNotationTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathDotNotationTests.cs index 11588287..4cb9db68 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathDotNotationTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathDotNotationTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathDotNotationTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathFilterExpressionTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathFilterExpressionTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Query/JsonPathFilterExpressionTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathFilterExpressionTests.cs index 6c0d581e..851d8a2d 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathFilterExpressionTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathFilterExpressionTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathFilterExpressionTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathInOperationsTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathInOperationsTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Query/JsonPathInOperationsTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathInOperationsTests.cs index a563d285..2eb7b256 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathInOperationsTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathInOperationsTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathInOperationsTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathMathOperationsTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathMathOperationsTests.cs similarity index 98% rename from test/Hyperbee.Json.Tests/Query/JsonPathMathOperationsTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathMathOperationsTests.cs index ced22b72..52676339 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathMathOperationsTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathMathOperationsTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathMathOperationsTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathRootOnScalarTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathRootOnScalarTests.cs similarity index 97% rename from test/Hyperbee.Json.Tests/Query/JsonPathRootOnScalarTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathRootOnScalarTests.cs index 6e8b642a..6cfacdd3 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathRootOnScalarTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathRootOnScalarTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathRootOnScalarTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathUnionTests.cs b/test/Hyperbee.Json.Tests/Path/Query/JsonPathUnionTests.cs similarity index 99% rename from test/Hyperbee.Json.Tests/Query/JsonPathUnionTests.cs rename to test/Hyperbee.Json.Tests/Path/Query/JsonPathUnionTests.cs index b981ac9b..54cf82c2 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathUnionTests.cs +++ b/test/Hyperbee.Json.Tests/Path/Query/JsonPathUnionTests.cs @@ -5,7 +5,7 @@ using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Query; +namespace Hyperbee.Json.Tests.Path.Query; [TestClass] public class JsonPathUnionTests : JsonTestBase diff --git a/test/Hyperbee.Json.Tests/Pointer/JsonPathPointerConverterTests.cs b/test/Hyperbee.Json.Tests/Pointer/JsonPathPointerConverterTests.cs new file mode 100644 index 00000000..111badaf --- /dev/null +++ b/test/Hyperbee.Json.Tests/Pointer/JsonPathPointerConverterTests.cs @@ -0,0 +1,67 @@ +using System; +using Hyperbee.Json.Pointer; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Pointer; + +[TestClass] +public class JsonPathPointerConverterTests +{ + [DataTestMethod] + [DataRow( "$['store'].book[0].title", "#/store/book/0/title", true )] + [DataRow( "$['store'].book[0].title", "/store/book/0/title", false )] + [DataRow( "$.store.book[0].title", "#/store/book/0/title", true )] + [DataRow( "$.store.book[0].title", "/store/book/0/title", false )] + [DataRow( "$", "#/", true )] + [DataRow( "$", "/", false )] + [DataRow( "$['store']['book'][1]['author']", "#/store/book/1/author", true )] + [DataRow( "$['store']['book'][1]['author']", "/store/book/1/author", false )] + [DataRow( "$.store.book[1].author", "#/store/book/1/author", true )] + [DataRow( "$.store.book[1].author", "/store/book/1/author", false )] + [DataRow( "$['store'].book[0].price", "#/store/book/0/price", true )] + [DataRow( "$['store'].book[0].price", "/store/book/0/price", false )] + [DataRow( "$.store.book[0].price", "#/store/book/0/price", true )] + [DataRow( "$.store.book[0].price", "/store/book/0/price", false )] + [DataRow( "$['store'].bestseller", "#/store/bestseller", true )] + [DataRow( "$['store'].bestseller", "/store/bestseller", false )] + [DataRow( "$.store.bestseller", "#/store/bestseller", true )] + [DataRow( "$.store.bestseller", "/store/bestseller", false )] + [DataRow( "$['store'].book[0].isbn", "#/store/book/0/isbn", true )] + [DataRow( "$['store'].book[0].isbn", "/store/book/0/isbn", false )] + [DataRow( "$.store.book[0].isbn", "#/store/book/0/isbn", true )] + [DataRow( "$.store.book[0].isbn", "/store/book/0/isbn", false )] + [DataRow( "$['complex~0name']", "#/complex~00name", true )] + [DataRow( "$['complex~0name']", "/complex~00name", false )] + [DataRow( "$.store['complex/name']", "#/store/complex~1name", true )] + [DataRow( "$.store['complex/name']", "/store/complex~1name", false )] + public void TestConvertJsonPathToJsonPointer( string jsonPath, string expected, bool asFragment ) + { + var options = asFragment ? JsonPointerConvertOptions.Fragment : JsonPointerConvertOptions.Default; + var jsonPointer = JsonPathPointerConverter.ConvertJsonPathToJsonPointer( jsonPath.AsSpan(), options ); + + Assert.AreEqual( expected, jsonPointer ); + } + + [DataTestMethod] + [DataRow( "/store/book/0/title", "$.store.book[0].title" )] + [DataRow( "#/store/book/0/title", "$.store.book[0].title" )] + [DataRow( "/", "$" )] + [DataRow( "#/", "$" )] + [DataRow( "/store/book/1/author", "$.store.book[1].author" )] + [DataRow( "#/store/book/1/author", "$.store.book[1].author" )] + [DataRow( "/store/book/0/price", "$.store.book[0].price" )] + [DataRow( "#/store/book/0/price", "$.store.book[0].price" )] + [DataRow( "/store/bestseller", "$.store.bestseller" )] + [DataRow( "#/store/bestseller", "$.store.bestseller" )] + [DataRow( "/store/book/0/isbn", "$.store.book[0].isbn" )] + [DataRow( "#/store/book/0/isbn", "$.store.book[0].isbn" )] + [DataRow( "/complex~0name", "$['complex~name']" )] + [DataRow( "#/complex~0name", "$['complex~name']" )] + [DataRow( "/complex~1name", "$['complex/name']" )] + [DataRow( "#/complex~1name", "$['complex/name']" )] + public void TestConvertJsonPointerToJsonPath( string jsonPointer, string expected ) + { + var jsonPath = JsonPathPointerConverter.ConvertJsonPointerToJsonPath( jsonPointer.AsSpan() ); + Assert.AreEqual( expected, jsonPath ); + } +} diff --git a/test/Hyperbee.Json.Tests/Parsers/JsonPathPointerTests.cs b/test/Hyperbee.Json.Tests/Pointer/JsonPathPointerTests.cs similarity index 95% rename from test/Hyperbee.Json.Tests/Parsers/JsonPathPointerTests.cs rename to test/Hyperbee.Json.Tests/Pointer/JsonPathPointerTests.cs index 2e7d7dd9..342ace0b 100644 --- a/test/Hyperbee.Json.Tests/Parsers/JsonPathPointerTests.cs +++ b/test/Hyperbee.Json.Tests/Pointer/JsonPathPointerTests.cs @@ -1,9 +1,8 @@ using System.Text.Json; -using Hyperbee.Json.Extensions; using Hyperbee.Json.Tests.TestSupport; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace Hyperbee.Json.Tests.Parsers; +namespace Hyperbee.Json.Tests.Pointer; [TestClass] public class JsonPathPointerTests : JsonTestBase @@ -42,6 +41,7 @@ public void ValueFromJsonPathPointer( string pointer, object expected ) // act var target = document.RootElement.FromJsonPathPointer( pointer ); + object result = target.ValueKind switch { JsonValueKind.String => target.GetString(), diff --git a/test/Hyperbee.Json.Tests/Pointer/JsonPointerTests.cs b/test/Hyperbee.Json.Tests/Pointer/JsonPointerTests.cs new file mode 100644 index 00000000..c2fd168b --- /dev/null +++ b/test/Hyperbee.Json.Tests/Pointer/JsonPointerTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using Hyperbee.Json.Tests.TestSupport; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Pointer; + +[TestClass] +public class JsonPointerTests : JsonTestBase +{ + [DataTestMethod] + [DataRow( "/message", "The operation was successful" )] + [DataRow( "/status", 200 )] + [DataRow( "/timestamp/$date", "2021-07-24T20:14:06.613Z" )] + [DataRow( "/assets/0/hash", "22e1ea7a1c694262159271851eb6cff001fb39bf8e5edc795a345a771b2c3ffc" )] + [DataRow( "/assets/0/asset/code", "#load" )] + public void ValueFromJsonPointer( string pointer, object expected ) + { + // arrange + const string json = + """ + { + "message": "The operation was successful", + "status": 200, + "timestamp": { + "$date": "2021-07-24T20:14:06.613Z" + }, + "assets": [ + { + "hash": "22e1ea7a1c694262159271851eb6cff001fb39bf8e5edc795a345a771b2c3ffc", + "owners": [], + "asset": { + "code": "#load" + }, + "votes": [] + } + ] + } + """; + + var document = JsonDocument.Parse( json ); + + // act + var target = document.RootElement.FromJsonPointer( pointer ); + + object result = target.ValueKind switch + { + JsonValueKind.String => target.GetString(), + JsonValueKind.Number => target.GetInt32(), + _ => target.GetRawText() + }; + + // assert + Assert.AreEqual( expected, result ); + } +} diff --git a/test/Hyperbee.Json.Tests/Query/JsonQueryParserRfc6901Tests.cs b/test/Hyperbee.Json.Tests/Query/JsonQueryParserRfc6901Tests.cs new file mode 100644 index 00000000..e9d226ed --- /dev/null +++ b/test/Hyperbee.Json.Tests/Query/JsonQueryParserRfc6901Tests.cs @@ -0,0 +1,49 @@ +using System.Linq; +using Hyperbee.Json.Query; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Query; + +[TestClass] +public class JsonQueryParserRfc6901Tests +{ + [DataTestMethod] + [DataRow( "/", "[/ => singular]" )] + [DataRow( "/two/some", "[/ => singular][two => singular][some => singular]" )] + [DataRow( "/thing/1:2:3", "[/ => singular][thing => singular][1:2:3 => singular]" )] + [DataRow( "/thing/~1", "[/ => singular][thing => singular][/ => singular]" )] + [DataRow( "/thing/~0", "[/ => singular][thing => singular][~ => singular]" )] + [DataRow( "/store/book/0/author", "[/ => singular][store => singular][book => singular][0 => singular][author => singular]" )] + [DataRow( "/store/*", "[/ => singular][store => singular][* => singular]" )] + [DataRow( "/book/2", "[/ => singular][book => singular][2 => singular]" )] + [DataRow( "/book/-1", "[/ => singular][book => singular][-1 => singular]" )] + [DataRow( "/book/0,1", "[/ => singular][book => singular][0,1 => singular]" )] + [DataRow( "/book/category,author", "[/ => singular][book => singular][category,author => singular]" )] + public void ParseJsonPointer_Rfc6901( string jsonPointer, string expected ) + { + // act + var compiledQuery = JsonQueryParser.Parse( jsonPointer, JsonQueryParserOptions.Rfc6901 ); + + // arrange + var result = GetResultString( compiledQuery.Segments ); + + // assert + Assert.AreEqual( expected, result ); + + return; + + static string GetResultString( JsonSegment segment ) + { + return string.Join( "", segment.AsEnumerable().Select( ConvertToString ) ); + + static string ConvertToString( JsonSegment segment ) + { + var (singular, selectors) = segment; + var selectorType = singular ? "singular" : "group"; + var selectorsString = string.Join( ',', selectors.Select( x => x.Value ).Reverse() ); + + return $"[{selectorsString} => {selectorType}]"; + } + } + } +} diff --git a/test/Hyperbee.Json.Tests/Query/JsonQueryParserRfc9535Tests.cs b/test/Hyperbee.Json.Tests/Query/JsonQueryParserRfc9535Tests.cs new file mode 100644 index 00000000..d04a3c15 --- /dev/null +++ b/test/Hyperbee.Json.Tests/Query/JsonQueryParserRfc9535Tests.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using Hyperbee.Json.Query; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Query +{ + [TestClass] + public class JsonQueryParserRfc9535Tests + { + [DataTestMethod] + [DataRow( "$", "[$ => singular]" )] + [DataRow( "$.two.some", "[$ => singular][two => singular][some => singular]" )] + [DataRow( "$.thing[1:2:3]", "[$ => singular][thing => singular][1:2:3 => group]" )] + [DataRow( "$..thing[?(@.x == 1)]", "[$ => singular][.. => group][thing => singular][?(@.x == 1) => group]" )] + [DataRow( "$['two.some']", "[$ => singular][two.some => singular]" )] + [DataRow( "$.two.some.thing['this.or.that']", "[$ => singular][two => singular][some => singular][thing => singular][this.or.that => singular]" )] + [DataRow( "$.store.book[*].author", "[$ => singular][store => singular][book => singular][* => group][author => singular]" )] + [DataRow( "@..author", "[@ => singular][.. => group][author => singular]" )] + [DataRow( "$.store.*", "[$ => singular][store => singular][* => group]" )] + [DataRow( "$.store..price", "[$ => singular][store => singular][.. => group][price => singular]" )] + [DataRow( "$..book[2]", "[$ => singular][.. => group][book => singular][2 => singular]" )] + [DataRow( "$..book[-1:]", "[$ => singular][.. => group][book => singular][-1: => group]" )] + [DataRow( "$..book[:2]", "[$ => singular][.. => group][book => singular][:2 => group]" )] + [DataRow( "$..book[0,1]", "[$ => singular][.. => group][book => singular][0,1 => group]" )] + [DataRow( "$.store.book[0,1]", "[$ => singular][store => singular][book => singular][0,1 => group]" )] + [DataRow( "$..book['category','author']", "[$ => singular][.. => group][book => singular][category,author => group]" )] + [DataRow( "$..book[?(@.isbn)]", "[$ => singular][.. => group][book => singular][?(@.isbn) => group]" )] + [DataRow( "$..book[?@.isbn]", "[$ => singular][.. => group][book => singular][?@.isbn => group]" )] + [DataRow( "$..book[?(@.price<10)]", "[$ => singular][.. => group][book => singular][?(@.price<10) => group]" )] + [DataRow( "$..book[?@.price<10]", "[$ => singular][.. => group][book => singular][?@.price<10 => group]" )] + [DataRow( "$..*", "[$ => singular][.. => group][* => group]" )] + [DataRow( "$..book[?(@.price == 8.99 && @.category == \"fiction\")]", "[$ => singular][.. => group][book => singular][?(@.price == 8.99 && @.category == \"fiction\") => group]" )] + public void ParseJsonPath_Rfc9535( string jsonPath, string expected ) + { + // act + var compiledQuery = JsonQueryParser.Parse( jsonPath ); + + // arrange + var result = GetResultString( compiledQuery.Segments ); + + // assert + Assert.AreEqual( expected, result ); + + return; + + static string GetResultString( JsonSegment segment ) + { + return string.Join( "", segment.AsEnumerable().Select( ConvertToString ) ); + + static string ConvertToString( JsonSegment segment ) + { + var (singular, selectors) = segment; + var selectorType = singular ? "singular" : "group"; + var selectorsString = string.Join( ',', selectors.Select( x => x.Value ).Reverse() ); + + return $"[{selectorsString} => {selectorType}]"; + } + } + } + + [TestMethod] + public void ShouldFilterExpressionWithParentAxisOperator() + { + // NOT-SUPPORTED: parent axis operator is not supported + + // act & assert + const string jsonPath = "$[*].bookmarks[ ? (@.page == 45)]^^^"; + + Assert.ThrowsException( () => + { + JsonQueryParser.Parse( jsonPath ); + } ); + } + } +} diff --git a/test/Hyperbee.Json.Tests/TestSupport/IJsonDocument.cs b/test/Hyperbee.Json.Tests/TestSupport/IJsonDocument.cs index c198f59d..087abf8c 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/IJsonDocument.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/IJsonDocument.cs @@ -5,5 +5,5 @@ namespace Hyperbee.Json.Tests.TestSupport; public interface IJsonDocument { IEnumerable Select( string query ); - dynamic FromJsonPathPointer( string pathLiteral ); + dynamic FromJsonPathPointer( string pointer ); } diff --git a/test/Hyperbee.Json.Tests/TestDocuments/BookStore.json b/test/Hyperbee.Json.Tests/TestSupport/Json/BookStore.json similarity index 100% rename from test/Hyperbee.Json.Tests/TestDocuments/BookStore.json rename to test/Hyperbee.Json.Tests/TestSupport/Json/BookStore.json diff --git a/test/Hyperbee.Json.Tests/TestSupport/JsonElementHelper.cs b/test/Hyperbee.Json.Tests/TestSupport/JsonElementHelper.cs index 8d13c2c6..3028e9f3 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/JsonElementHelper.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/JsonElementHelper.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Text.Json; using Hyperbee.Json.Extensions; +using Hyperbee.Json.Pointer; namespace Hyperbee.Json.Tests.TestSupport; @@ -9,7 +10,20 @@ public class JsonElementDocument( string source ) : IJsonDocument { private JsonDocument Document { get; } = JsonDocument.Parse( source ); public IEnumerable Select( string query ) => Document.Select( query ).Cast(); - public dynamic FromJsonPathPointer( string pathLiteral ) => Document.RootElement.FromJsonPathPointer( pathLiteral ); + public dynamic FromJsonPathPointer( string pointer ) => JsonPathPointer.FromPointer( Document.RootElement, pointer ); +} + +public static class JsonElementExtensions +{ + public static JsonElement FromJsonPathPointer( this JsonElement source, string pointer ) + { + return JsonPathPointer.FromPointer( source, pointer ); + } + + public static JsonElement FromJsonPointer( this JsonElement source, string pointer ) + { + return JsonPointer.FromPointer( source, pointer ); + } } internal static partial class TestHelper diff --git a/test/Hyperbee.Json.Tests/TestSupport/JsonNodeHelper.cs b/test/Hyperbee.Json.Tests/TestSupport/JsonNodeHelper.cs index f3221a43..6633a6d3 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/JsonNodeHelper.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/JsonNodeHelper.cs @@ -2,6 +2,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using Hyperbee.Json.Extensions; +using Hyperbee.Json.Pointer; namespace Hyperbee.Json.Tests.TestSupport; @@ -10,7 +11,20 @@ public class JsonNodeDocument( string source ) : IJsonDocument private JsonNode Document { get; } = JsonNode.Parse( source ); public IEnumerable Select( string query ) => Document.Select( query ); - public dynamic FromJsonPathPointer( string pathLiteral ) => Document.FromJsonPathPointer( pathLiteral ); + public dynamic FromJsonPathPointer( string pointer ) => JsonPathPointer.FromPointer( Document, pointer ); +} + +public static class JsonNodeExtensions +{ + public static JsonNode FromJsonPathPointer( this JsonNode source, string pointer ) + { + return JsonPathPointer.FromPointer( source, pointer ); + } + + public static JsonNode FromJsonPointer( this JsonNode source, string pointer ) + { + return JsonPointer.FromPointer( source, pointer ); + } } internal static partial class TestHelper diff --git a/test/Hyperbee.Json.Tests/TestSupport/JsonTestBase.cs b/test/Hyperbee.Json.Tests/TestSupport/JsonTestBase.cs index 0920cc3a..cc174480 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/JsonTestBase.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/JsonTestBase.cs @@ -23,7 +23,7 @@ private static Stream GetManifestStream( string resourceName = null ) return Assembly .GetExecutingAssembly() - .GetManifestResourceStream( $"Hyperbee.Json.Tests.TestDocuments.{resourceName}" ); + .GetManifestResourceStream( $"Hyperbee.Json.Tests.TestSupport.Json.{resourceName}" ); } protected static TType GetDocument( string resourceName = null )