Skip to content

Commit

Permalink
[FEATURE]: Use Expressions For Filters (#7)
Browse files Browse the repository at this point in the history
* Rewrite filter evaluator to use expressions and to conform to RFC-9535

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matt Edwards <[email protected]>
Co-authored-by: github-actions <[email protected]>
Co-authored-by: Brenton Farmer <[email protected]>
  • Loading branch information
4 people authored Jun 14, 2024
1 parent c28b37f commit fc4fe81
Show file tree
Hide file tree
Showing 80 changed files with 2,813 additions and 1,358 deletions.
7 changes: 7 additions & 0 deletions Hyperbee.Json.sln
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hyperbee.Json.Tests", "test\Hyperbee.Json.Tests\Hyperbee.Json.Tests.csproj", "{97886205-1467-4EE6-B3DA-496CA3D086E4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hyperbee.Json.Benchmark", "test\Hyperbee.Json.Benchmark\Hyperbee.Json.Benchmark.csproj", "{45C24D4B-4A0B-4FF1-AC66-38374D2455E9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -48,6 +50,10 @@ Global
{97886205-1467-4EE6-B3DA-496CA3D086E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{97886205-1467-4EE6-B3DA-496CA3D086E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{97886205-1467-4EE6-B3DA-496CA3D086E4}.Release|Any CPU.Build.0 = Release|Any CPU
{45C24D4B-4A0B-4FF1-AC66-38374D2455E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{45C24D4B-4A0B-4FF1-AC66-38374D2455E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{45C24D4B-4A0B-4FF1-AC66-38374D2455E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{45C24D4B-4A0B-4FF1-AC66-38374D2455E9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -56,6 +62,7 @@ Global
{1FA7CE2A-C9DA-4DC3-A242-5A7EAF8EE4FC} = {870D9301-BE3D-44EA-BF9C-FCC2E87FE4CD}
{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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {32874F5B-B467-4F28-A8E2-82C2536FB228}
Expand Down
6 changes: 6 additions & 0 deletions Hyperbee.Json.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticReadonly/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="__" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PublicFields/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="_" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=TypesAndNamespaces/@EntryIndexedValue">&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="I" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=15b5b1f1_002D457c_002D4ca6_002Db278_002D5615aedc07d3/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="__" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=4a98fdf6_002D7d98_002D4f5a_002Dafeb_002Dea44ad98c70c/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb"&gt;&lt;ExtraRule Prefix="" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=53eecf85_002Dd821_002D40e8_002Dac97_002Dfdb734542b84/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Instance fields (not private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;Kind Name="READONLY_FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="_" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=70345118_002D4b40_002D4ece_002D937c_002Dbbeb7a0b2e70/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Static" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Static fields (not private)"&gt;&lt;ElementKinds&gt;&lt;Kind Name="FIELD" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="_" Suffix="" Style="aaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/UserRules/=a0b4bc4d_002Dd13b_002D4a37_002Db37e_002Dc9c6864e4302/@EntryIndexedValue">&lt;Policy&gt;&lt;Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"&gt;&lt;ElementKinds&gt;&lt;Kind Name="NAMESPACE" /&gt;&lt;Kind Name="CLASS" /&gt;&lt;Kind Name="STRUCT" /&gt;&lt;Kind Name="ENUM" /&gt;&lt;Kind Name="DELEGATE" /&gt;&lt;/ElementKinds&gt;&lt;/Descriptor&gt;&lt;Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"&gt;&lt;ExtraRule Prefix="I" Suffix="" Style="AaBb" /&gt;&lt;/Policy&gt;&lt;/Policy&gt;</s:String>
<s:Boolean x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=C7A83AA09B781941AB1F1DC475E56D3B/@KeyIndexDefined">True</s:Boolean>
<s:String x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=C7A83AA09B781941AB1F1DC475E56D3B/AbsolutePath/@EntryValue">C:\Development\Hyperbee.Core\Hyperbee.Core.sln.DotSettings</s:String>
<s:String x:Key="/Default/Environment/InjectedLayers/FileInjectedLayer/=C7A83AA09B781941AB1F1DC475E56D3B/RelativePath/@EntryValue"></s:String>
Expand All @@ -45,6 +50,7 @@
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpPlaceEmbeddedOnSameLineMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ECSharpUseContinuousIndentInsideBracesMigration/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EPredefinedNamingRulesToUserRulesUpgrade/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/GrammarAndSpelling/GrammarChecking/Exceptions/=the_0020expire/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/GrammarAndSpelling/GrammarChecking/Exceptions/=the_0020if/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Analytics/@EntryIndexedValue">True</s:Boolean>
Expand Down
164 changes: 69 additions & 95 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,42 @@
# JSONPath

A C# implementation of JSONPath for .NET `System.Text.Json` with a plugable expression selector.
JSON Path is a query language for JSON documents inspired by XPath. JSONPath defines
a string syntax for selecting and extracting JSON values from within a given JSON document.

## Why
This library is a C# implementation of JSONPath for .NET `System.Text.Json` and `System.Text.Json.Nodes`.

.NET `System.Text.Json` lacks support for JSONPath. The primary goal of this project is to create a JSONPath library for .NET that will
The implementation

* Directly leverage `System.Text.Json`
* Align with the draft JSONPath Specification
* Works natively with both `JsonDocument` (`JsonElement`) and `JsonNode`
* Can be extended to support other JSON models
* Aligns with the draft JSONPath Specification RFC 9535
* [Working Draft](https://github.com/ietf-wg-jsonpath/draft-ietf-jsonpath-base).
* [Editor Copy](https://ietf-wg-jsonpath.github.io/draft-ietf-jsonpath-base/draft-ietf-jsonpath-base.html)
* Function according to the emerging consensus of use based on the majority of existing implementations; except through concious exception or deference to the RFC.
* [Parser Comparison](https://cburgmer.github.io/json-path-comparison)
* Provide a plugable model for expression script handling.
* Functions according to the emerging consensus of use based on the majority of existing
implementations; except through concious exception or deference to the RFC.
* [Parser Comparison Results](https://cburgmer.github.io/json-path-comparison)
* [Parser Comparison GitHub](https://github.com/cburgmer/json-path-comparison/tree/master)


## JSONPath Expressions
## JSONPath Syntax

JSONPath expressions always refers to a JSON structure in the same way as XPath
expression are used in combination with an XML document. Since a JSON structure is
usually anonymous and doesn't necessarily have a root member object JSONPath
assumes the abstract name `$` assigned to the outer level object.
JSONPath expressions refer to a JSON structure in the same way as XPath expressions
are used in combination with an XML document. JSONPath assumes the name `$` is assigned
to the root level object.

JSONPath expressions can use the dot-notation:
JSONPath expressions can use dot-notation:

$.store.book[0].title

or the bracket-notation:
or bracket-notation:

$['store']['book'][0]['title']

for input paths. Internal or output paths will always be converted to the more
general bracket-notation.

JSONPath allows the wildcard symbol `*` for member names and array indices. It
borrows the descendant operator `..` from [E4X][e4x] and the array slice
borrows the descendant operator `..` from [E4X][e4x], and the array slice
syntax proposal `[start:end:step]` from ECMASCRIPT 4.

Expressions of the underlying scripting language (`<expr>`) can be used as an
alternative to explicit names or indices, as in:
Expressions can be used as an alternative to explicit names or indices, as in:

$.store.book[(@.length-1)].title

Expand All @@ -54,15 +52,15 @@ syntax elements with its XPath counterparts:
|:----------|:-------------------|:-----------------------------------------------------------
| `/` | `$` | The root object/element
| `.` | `@` | The current object/element
| `/` | `.` or `[]` | Child operator
| `/` | `.` or `[]` | Child operator
| `..` | n/a | Parent operator
| `//` | `..` | Recursive descent. JSONPath borrows this syntax from E4X.
| `*` | `*` | Wildcard. All objects/elements regardless their names.
| `//` | `..` | Recursive descent. JSONPath borrows this syntax from E4X.
| `*` | `*` | Wildcard. All objects/elements regardless their names.
| `@` | n/a | Attribute access. JSON structures don't have attributes.
| `[]` | `[]` | Subscript operator. XPath uses it to iterate over element collections and for [predicates][xpath-predicates]. In Javascript and JSON it is the native array operator.
| `\|` | `[,]` | Union operator in XPath results in a combination of node sets. JSONPath allows alternate names or array indices as a set.
| n/a | `[start:end:step]`| Array slice operator borrowed from ES4.
| `[]` | `?()` | Applies a filter (script) expression.
| `[]` | `[]` | Subscript operator. XPath uses it to iterate over element collections and for [predicates][xpath-predicates]. In Javascript and JSON it is the native array operator.
| `\|` | `[,]` | Union operator in XPath results in a combination of node sets. JSONPath allows alternate names or array indices as a set.
| n/a | `[start:end:step]` | Array slice operator borrowed from ES4.
| `[]` | `?()` | Applies a filter (script) expression.
| n/a | `()` | Script expression, using the underlying script engine.
| `()` | n/a | Grouping in XPath

Expand Down Expand Up @@ -106,67 +104,20 @@ Given a simple JSON structure that represents a bookstore:

| XPath | JSONPath | Result | Notes
|:----------------------|:--------------------------|:---------------------------------------|:------
|`/store/book/author` | `$.store.book[*].author` | The authors of all books in the store
|`//author` | `$..author` | All authors
|`/store/*` | `$.store.*` | All things in store, which are some books and a red bicycle
|`/store//price` | `$.store..price` | The price of everything in the store
|`//book[3]` | `$..book[2]` | The third book
|`//book[last()]` | `$..book[(@.length-1)]<br>$..book[-1:]` | The last book in order
|`//book[position()<3]`| `$..book[0,1]`<br>`$..book[:2]`| The first two books
|`/store/book/author` | `$.store.book[*].author` | The authors of all books in the store
|`//author` | `$..author` | All authors
|`/store/*` | `$.store.*` | All things in store, which are some books and a red bicycle
|`/store//price` | `$.store..price` | The price of everything in the store
|`//book[3]` | `$..book[2]` | The third book
|`//book[last()]` | `$..book[(@.length-1)]<br>$..book[-1:]` | The last book in order
|`//book[position()<3]` | `$..book[0,1]`<br>`$..book[:2]`| The first two books
|`//book/*[self::category|self::author]` or `//book/(category,author)` in XPath 2.0 | `$..book[category,author]` | The categories and authors of all books
|`//book[isbn]` | `$..book[?(@.isbn)]` | Filter all books with `isbn` number
|`//book[price<10]` | `$..book[?(@.price<10)]` | Filter all books cheapier than 10
|`//*[price>19]/..` | `$..[?(@.price>19)]` | Categories with things more expensive than 19 | Parent (caret) not present in original spec
|`//*` | `$..*` | All elements in XML document; all members of JSON structure
|`//book[isbn]` | `$..book[?(@.isbn)]` | Filter all books with `isbn` number
|`//book[price<10]` | `$..book[?(@.price<10)]` | Filter all books cheapier than 10
|`//*[price>19]/..` | `$..[?(@.price>19)]` | Categories with things more expensive than 19 | Parent (caret) not present in original spec
|`//*` | `$..*` | All elements in XML document; all members of JSON structure
|`/store/book/[position()!=1]` | `$.store.book[?(@path !== "$[\'store\'][\'book\'][0]")]` | All books besides that at the path pointing to the first | `@path` not present in original spec

## Script Evaluators

`Hyperbee.Json` provides out-of-the-box expression evaluators for handling JsonPath filter selectors.

| Name | Description |
| ----------------------- | ----------- |
| JsonPathCSharpEvaluator | A Roslyn based expression evaluator that supports `[(@...)]` and `[?(@...)]` expresison syntax|
| JsonPathFuncEvaluator | A simple `Func<>` evaluator suitable for simple, custom expression handling |
| JsonPathNullEvaluator | An evaluator that does nothing |

You can create your own evaluator by deriving from `IJsonPathScriptEvaluator`.

```csharp
public class JsonPathFuncEvaluator : IJsonPathScriptEvaluator
{
private readonly JsonPathEvaluator _evaluator;

public JsonPathFuncEvaluator( JsonPathEvaluator evaluator )
{
_evaluator = evaluator;
}

public object Evaluator( string script, JsonElement current, string context )
{
return _evaluator?.Invoke( script, current, context );
}
}
```

You can set a global default for the evaluator.

```csharp
JsonPath.DefaultEvaluator = new JsonPathCSharpEvaluator();
```

Or you can wire it up through dependency injection.

```csharp
public static IServiceCollection AddJsonPath( this IServiceCollection services, IConfiguration config )
{
services.AddTransient<IJsonPathScriptEvaluator,JsonPathCSharpEvaluator>();
services.AddTransient<JsonPath>();

return services;
}
```

## Code examples
A couple of trivial code examples. Review the tests for detailed examples.

Expand All @@ -180,13 +131,13 @@ const string json = @"
]";

var document = JsonDocument.Parse( json );
var match = document.SelectPath( "$[-1:]" ).Single();
var match = document.Select( "$[-1:]" ).Single();

Assert.IsTrue( match.Value.GetString() == "third" );
```

**Example 2** Select all elemets that have a `key` property with a value less than 42.
This example leverages bracket expressions using the `Roslyn` jsonpath script evaluator.
This example leverages bracket expressions using the default `Expression` jsonpath filter evaluator.

```csharp
const string json = @"
Expand All @@ -203,7 +154,7 @@ const string json = @"
]";

var document = JsonDocument.Parse( json );
var matches = document.SelectPath( "$[?(@.key<42)]", JsonPathCSharpEvaluator.Evaluator );
var matches = document.Select( "$[?(@.key<42)]" );

// outputs 0 -1 41 41.9999
Expand All @@ -213,13 +164,17 @@ foreach( var element in matches )
};

```
## Helper Classes

In addition to JSONPath processing, a few additional helper classes are provided to support dynamic property access,
property diving, and element comparisons.

## Dynamic Object Serialization
### Dynamic Object Serialization

Basic support is provided for serializing to and from dynamic objects through the use of a custom `JsonConverter`.
The `DynamicJsonConverter` converter class is useful for simple scenareos. It is intended as a simple helper for basic use cases only.

### DynamicJsonConverter
#### DynamicJsonConverter

```csharp
var serializerOptions = new JsonSerializerOptions
Expand All @@ -237,16 +192,35 @@ var jsonOutput = JsonSerializer.Serialize<dynamic>( jobject, serializerOptions )
Assert.IsTrue( jsonInput == jsonOutput );
```

#### Enum handling
##### Enum handling

When deserializing, the converter will treat enumerations as strings. You can override this behavior by setting
the `TryReadValueHandler` on the converter. This handler will allow you to intercept and convert string and
numeric values during the deserialization process.

### Equality Helpers

| Method | Description
|:-----------------------------------|:-----------
| `JsonElement.DeepEquals` | Performs a deep equals comparison
| `JsonElementEqualityDeepComparer` | A deep equals equality comparer

### Property Diving

| Method | Description
|:-----------------------------------|:-----------
| `JsonElement.GetPropertyFromKey` | Dives for properties using absolute bracket location keys like `$['store']['book'][2]['author']`

### JsonElement Helpers

| Method | Description
|:-----------------------------------|:-----------
| `JsonPathBuilder` | Returns the JsonPath location string for a given element

## Acknowlegements

This project builds on the work of:

[Stefan G&ouml;ssner - Original JSONPath specification dated 2007-02-21](http://goessner.net/articles/JsonPath/#e2)
[Atif Aziz - .NET JSONPath](https://github.com/atifaziz/JSONPath)
[Christoph Burgmer - Parser Consensus tests](https://cburgmer.github.io/json-path-comparison)
* [Stefan G&ouml;ssner - Original JSONPath specification dated 2007-02-21](http://goessner.net/articles/JsonPath/#e2)
* [Atif Aziz - .NET JSONPath](https://github.com/atifaziz/JSONPath)
* [Christoph Burgmer - Parser Consensus tests](https://cburgmer.github.io/json-path-comparison)
Loading

0 comments on commit fc4fe81

Please sign in to comment.