diff --git a/Hyperbee.Json.sln b/Hyperbee.Json.sln index 39ba2a45..9db95102 100644 --- a/Hyperbee.Json.sln +++ b/Hyperbee.Json.sln @@ -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 @@ -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 @@ -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} diff --git a/Hyperbee.Json.sln.DotSettings b/Hyperbee.Json.sln.DotSettings index d9cc86a7..3c37ed14 100644 --- a/Hyperbee.Json.sln.DotSettings +++ b/Hyperbee.Json.sln.DotSettings @@ -36,6 +36,11 @@ <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="__" Suffix="" Style="aaBb" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="I" Suffix="" Style="AaBb" /></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static readonly fields (private)"><ElementKinds><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="__" Suffix="" Style="aaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb"><ExtraRule Prefix="" Suffix="" Style="AaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Instance fields (not private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Protected, ProtectedInternal, Internal, Public, PrivateProtected" Description="Static fields (not private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy></Policy> + <Policy><Descriptor Staticness="Any" AccessRightKinds="Any" Description="Types and namespaces"><ElementKinds><Kind Name="NAMESPACE" /><Kind Name="CLASS" /><Kind Name="STRUCT" /><Kind Name="ENUM" /><Kind Name="DELEGATE" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="I" Suffix="" Style="AaBb" /></Policy></Policy> True C:\Development\Hyperbee.Core\Hyperbee.Core.sln.DotSettings @@ -45,6 +50,7 @@ True True True + True True True True diff --git a/README.md b/README.md index 48e4d9c0..4d36399a 100644 --- a/README.md +++ b/README.md @@ -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 (``) 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 @@ -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 @@ -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)]
$..book[-1:]` | The last book in order -|`//book[position()<3]`| `$..book[0,1]`
`$..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)]
$..book[-1:]` | The last book in order +|`//book[position()<3]` | `$..book[0,1]`
`$..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(); - services.AddTransient(); - - return services; -} -``` - ## Code examples A couple of trivial code examples. Review the tests for detailed examples. @@ -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 = @" @@ -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 @@ -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 @@ -237,16 +192,35 @@ var jsonOutput = JsonSerializer.Serialize( 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ö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ö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) diff --git a/src/Hyperbee.Json/Descriptors/Element/ElementTypeDescriptor.cs b/src/Hyperbee.Json/Descriptors/Element/ElementTypeDescriptor.cs new file mode 100644 index 00000000..0dcca040 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/ElementTypeDescriptor.cs @@ -0,0 +1,38 @@ +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 FilterEvaluator _evaluator; + private ElementValueAccessor _accessor; + public Dictionary Functions { get; init; } + + public IValueAccessor Accessor + { + get => _accessor ??= new ElementValueAccessor(); + } + + public IFilterEvaluator FilterEvaluator + { + get => _evaluator ??= new FilterEvaluator( this ); + } + + public FilterFunction GetFilterFunction( ParseExpressionContext context ) => + new FilterElementFunction( context ); + + public ElementTypeDescriptor() + { + Functions = new Dictionary( + [ + new KeyValuePair( CountElementFunction.Name, ( name, arguments, context ) => new CountElementFunction( name, arguments, context ) ), + new KeyValuePair( LengthElementFunction.Name, ( name, arguments, context ) => new LengthElementFunction( name, arguments, context ) ), + new KeyValuePair( MatchElementFunction.Name, ( name, arguments, context ) => new MatchElementFunction( name, arguments, context ) ), + new KeyValuePair( SearchElementFunction.Name, ( name, arguments, context ) => new SearchElementFunction( name, arguments, context ) ), + new KeyValuePair( ValueElementFunction.Name, ( name, arguments, context ) => new ValueElementFunction( name, arguments, context ) ), + ] ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/ElementValueAccessor.cs b/src/Hyperbee.Json/Descriptors/Element/ElementValueAccessor.cs new file mode 100644 index 00000000..5b6e8289 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/ElementValueAccessor.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; + +namespace Hyperbee.Json.Descriptors.Element; + +internal class ElementValueAccessor : IValueAccessor +{ + public IEnumerable<(JsonElement, string)> EnumerateChildren( JsonElement value, bool includeValues = true ) + { + switch ( value.ValueKind ) + { + case JsonValueKind.Array: + { + for ( var index = value.GetArrayLength() - 1; index >= 0; index-- ) + { + var child = value[index]; + + if ( includeValues || child.ValueKind is JsonValueKind.Array or JsonValueKind.Object ) + yield return (child, index.ToString()); + } + + break; + } + case JsonValueKind.Object: + { + if ( includeValues ) + { + foreach ( var child in value.EnumerateObject().Reverse() ) + yield return (child.Value, child.Name); + } + else + { + foreach ( var child in value.EnumerateObject().Where( property => property.Value.ValueKind is JsonValueKind.Array or JsonValueKind.Object ).Reverse() ) + yield return (child.Value, child.Name); + } + + break; + } + } + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public JsonElement GetElementAt( in JsonElement value, int index ) + { + return value[index]; + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool IsObjectOrArray( in JsonElement value ) + { + return value.ValueKind is JsonValueKind.Array or JsonValueKind.Object; + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool IsArray( in JsonElement value, out int length ) + { + if ( value.ValueKind == JsonValueKind.Array ) + { + length = value.GetArrayLength(); + return true; + } + + length = 0; + return false; + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool IsObject( in JsonElement value ) + { + return value.ValueKind is JsonValueKind.Object; + } + + public bool TryGetChildValue( in JsonElement value, string childKey, out JsonElement childValue ) + { + switch ( value.ValueKind ) + { + case JsonValueKind.Object: + if ( value.TryGetProperty( childKey, out childValue ) ) + return true; + break; + + case JsonValueKind.Array: + var index = TryParseInt( childKey ) ?? -1; + + if ( index >= 0 && index < value.GetArrayLength() ) + { + childValue = value[index]; + return true; + } + + break; + + default: + if ( !IsPathOperator( childKey ) ) + throw new ArgumentException( $"Invalid child type '{childKey}'. Expected child to be Object, Array or a path selector.", nameof( value ) ); + break; + } + + childValue = default; + return false; + + static bool IsPathOperator( ReadOnlySpan x ) => x == "*" || x == ".." || x == "$"; + + static int? TryParseInt( ReadOnlySpan numberString ) + { + return numberString == null ? null : int.TryParse( numberString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n ) ? n : null; + } + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/FilterElementHelper.cs b/src/Hyperbee.Json/Descriptors/Element/FilterElementHelper.cs new file mode 100644 index 00000000..ca26050c --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/FilterElementHelper.cs @@ -0,0 +1,60 @@ +using System.Reflection; +using System.Text.Json; + +namespace Hyperbee.Json.Descriptors.Element; + +internal static class FilterElementHelper +{ + public static readonly MethodInfo SelectFirstElementValueMethod; + public static readonly MethodInfo SelectFirstMethod; + public static readonly MethodInfo SelectElementsMethod; + + static FilterElementHelper() + { + var thisType = typeof( FilterElementHelper ); + + SelectFirstElementValueMethod = thisType.GetMethod( nameof( SelectFirstElementValue ), [typeof( JsonElement ), typeof( JsonElement ), typeof( string )] ); + SelectFirstMethod = thisType.GetMethod( nameof( SelectFirst ), [typeof( JsonElement ), typeof( JsonElement ), typeof( string )] ); + SelectElementsMethod = thisType.GetMethod( nameof( SelectElements ), [typeof( JsonElement ), typeof( JsonElement ), typeof( string )] ); + } + + private static bool IsNotEmpty( JsonElement element ) + { + return element.ValueKind switch + { + JsonValueKind.Array => element.EnumerateArray().Any(), + JsonValueKind.Object => element.EnumerateObject().Any(), + _ => false + }; + } + + public static object SelectFirstElementValue( JsonElement current, JsonElement root, string query ) + { + var element = SelectFirst( current, root, query ); + + return element.ValueKind switch + { + JsonValueKind.Number => element.GetSingle(), + JsonValueKind.String => element.GetString(), + JsonValueKind.Object => IsNotEmpty( element ), + JsonValueKind.Array => IsNotEmpty( element ), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => false, + JsonValueKind.Undefined => false, + _ => false + }; + } + + public static JsonElement SelectFirst( JsonElement current, JsonElement root, string query ) + { + return SelectElements( current, root, query ) + .FirstOrDefault(); + } + + public static IEnumerable SelectElements( JsonElement current, JsonElement root, string query ) + { + return JsonPath + .Select( current, root, query ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/CountElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/CountElementFunction.cs new file mode 100644 index 00000000..461ed5c4 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/CountElementFunction.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Element.Functions; + +public class CountElementFunction( string methodName, IList arguments, ParseExpressionContext context ) : + FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "count"; + + private static readonly MethodInfo CountMethod; + + static CountElementFunction() + { + CountMethod = typeof( Enumerable ) + .GetMethods( BindingFlags.Static | BindingFlags.Public ) + .First( m => + m.Name == "Count" && + m.GetParameters().Length == 1 && + m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof( IEnumerable<> ) ) + .MakeGenericMethod( typeof( JsonElement ) ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 1 ) + { + return Expression.Throw( Expression.Constant( new Exception( $"Invalid use of {Name} function" ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + + return Expression.Convert( Expression.Call( + CountMethod, + Expression.Call( FilterElementHelper.SelectElementsMethod, + context.Current, + context.Root, + queryExp ) ) + , typeof( float ) ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/FilterElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/FilterElementFunction.cs new file mode 100644 index 00000000..c8c93c51 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/FilterElementFunction.cs @@ -0,0 +1,15 @@ +using System.Linq.Expressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Element.Functions; + +public class FilterElementFunction( ParseExpressionContext context ) : FilterFunction +{ + protected override Expression GetExpressionImpl( ReadOnlySpan data, ReadOnlySpan item, ref int start, ref int from ) + { + var queryExp = Expression.Constant( item.ToString() ); + + // Create a call expression for the extension method + return Expression.Call( FilterElementHelper.SelectFirstElementValueMethod, context.Current, context.Root, queryExp ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/LengthElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/LengthElementFunction.cs new file mode 100644 index 00000000..7b320373 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/LengthElementFunction.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Element.Functions; + +public class LengthElementFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "length"; + + private static readonly MethodInfo LengthMethod; + + static LengthElementFunction() + { + LengthMethod = typeof( LengthElementFunction ).GetMethod( nameof( Length ), [typeof( JsonElement )] ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 1 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + + return Expression.Call( + LengthMethod, + Expression.Call( FilterElementHelper.SelectFirstMethod, + context.Current, + context.Root, + queryExp ) ); + } + + public static float Length( JsonElement element ) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString()?.Length ?? 0, + JsonValueKind.Array => element.GetArrayLength(), + JsonValueKind.Object => element.EnumerateObject().Count(), + _ => 0 + }; + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/MatchElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/MatchElementFunction.cs new file mode 100644 index 00000000..0300885b --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/MatchElementFunction.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Element.Functions; + +public class MatchElementFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "match"; + + private static readonly MethodInfo MatchMethod; + + static MatchElementFunction() + { + MatchMethod = typeof( MatchElementFunction ).GetMethod( nameof( Match ), [typeof( JsonElement ), typeof( string )] ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 2 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + var regex = Expression.Constant( arguments[1] ); + + return Expression.Call( + MatchMethod, + Expression.Call( FilterElementHelper.SelectFirstMethod, + context.Current, + context.Root, + queryExp ) + , regex ); + } + + public static bool Match( JsonElement element, string regex ) + { + var regexPattern = new Regex( regex.Trim( '\"', '\'' ) ); + var value = $"^{element.GetString()}$"; + + return regexPattern.IsMatch( value ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/SearchElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/SearchElementFunction.cs new file mode 100644 index 00000000..16d3b35c --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/SearchElementFunction.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using System.Text.RegularExpressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Element.Functions; + +public class SearchElementFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "search"; + + private static readonly MethodInfo SearchMethod; + + static SearchElementFunction() + { + SearchMethod = typeof( SearchElementFunction ).GetMethod( nameof( Search ), [typeof( JsonElement ), typeof( string )] ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 2 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + var regex = Expression.Constant( arguments[1] ); + + return Expression.Call( + SearchMethod, + Expression.Call( FilterElementHelper.SelectFirstMethod, + context.Current, + context.Root, + queryExp ) + , regex ); + } + + public static bool Search( JsonElement element, string regex ) + { + var regexPattern = new Regex( regex.Trim( '\"', '\'' ) ); + var value = element.GetString(); + + return value != null && regexPattern.IsMatch( value ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Element/Functions/ValueElementFunction.cs b/src/Hyperbee.Json/Descriptors/Element/Functions/ValueElementFunction.cs new file mode 100644 index 00000000..9c7078cb --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Element/Functions/ValueElementFunction.cs @@ -0,0 +1,25 @@ +using System.Linq.Expressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Element.Functions; + +public class ValueElementFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "value"; + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 1 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + + return Expression.Call( + FilterElementHelper.SelectFirstElementValueMethod, + context.Current, + context.Root, + queryExp ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/ITypeDescriptor.cs b/src/Hyperbee.Json/Descriptors/ITypeDescriptor.cs new file mode 100644 index 00000000..99002dad --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/ITypeDescriptor.cs @@ -0,0 +1,24 @@ +using Hyperbee.Json.Filters; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors; + + +public interface IJsonTypeDescriptor +{ + public Dictionary Functions { get; } + + public FilterFunction GetFilterFunction( ParseExpressionContext context ); +} + +public interface ITypeDescriptor : IJsonTypeDescriptor +{ + public IValueAccessor Accessor { get; } + public IFilterEvaluator FilterEvaluator { get; } + + public void Deconstruct( out IValueAccessor valueAccessor, out IFilterEvaluator filterEvaluator ) + { + valueAccessor = Accessor; + filterEvaluator = FilterEvaluator; + } +} diff --git a/src/Hyperbee.Json/Descriptors/IValueAccessor.cs b/src/Hyperbee.Json/Descriptors/IValueAccessor.cs new file mode 100644 index 00000000..ec60521b --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/IValueAccessor.cs @@ -0,0 +1,11 @@ +namespace Hyperbee.Json.Descriptors; + +public interface IValueAccessor +{ + IEnumerable<(TNode, string)> EnumerateChildren( TNode value, bool includeValues = true ); + TNode GetElementAt( in TNode value, int index ); + bool IsObjectOrArray( in TNode current ); + bool IsArray( in TNode current, out int length ); + bool IsObject( in TNode current ); + bool TryGetChildValue( in TNode current, string childKey, out TNode childValue ); +} diff --git a/src/Hyperbee.Json/Descriptors/Node/FilterNodeHelper.cs b/src/Hyperbee.Json/Descriptors/Node/FilterNodeHelper.cs new file mode 100644 index 00000000..9528b98a --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/FilterNodeHelper.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using Hyperbee.Json.Extensions; + +namespace Hyperbee.Json.Descriptors.Node; + +internal static class FilterNodeHelper +{ + public static readonly MethodInfo SelectFirstElementValueMethod; + public static readonly MethodInfo SelectFirstMethod; + public static readonly MethodInfo SelectElementsMethod; + + static FilterNodeHelper() + { + var thisType = typeof( FilterNodeHelper ); + + SelectFirstElementValueMethod = thisType.GetMethod( nameof( SelectFirstElementValue ), [typeof( JsonNode ), typeof( JsonNode ), typeof( string )] ); + SelectFirstMethod = thisType.GetMethod( nameof( SelectFirst ), [typeof( JsonNode ), typeof( JsonNode ), typeof( string )] ); + SelectElementsMethod = thisType.GetMethod( nameof( SelectElements ), [typeof( JsonNode ), typeof( JsonNode ), typeof( string )] ); + } + + private static bool IsNotEmpty( JsonNode node ) + { + return node.GetValueKind() switch + { + JsonValueKind.Array => node.AsArray().Count != 0, + JsonValueKind.Object => node.AsObject().Count != 0, + _ => false + }; + } + + public static object SelectFirstElementValue( JsonNode current, JsonNode root, string query ) + { + var node = SelectFirst( current, root, query ); + + return node?.GetValueKind() switch + { + JsonValueKind.Number => node.GetNumber(), + JsonValueKind.String => node.GetValue(), + JsonValueKind.Object => IsNotEmpty( node ), + JsonValueKind.Array => IsNotEmpty( node ), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => false, + JsonValueKind.Undefined => false, + _ => false + }; + } + + public static JsonNode SelectFirst( JsonNode current, JsonNode root, string query ) + { + return JsonPath + .Select( current, root, query ) + .FirstOrDefault(); + } + + public static IEnumerable SelectElements( JsonNode current, JsonNode root, string query ) + { + return JsonPath + .Select( current, root, query ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/CountNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/CountNodeFunction.cs new file mode 100644 index 00000000..6aa6f345 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/CountNodeFunction.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Nodes; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Node.Functions; + +public class CountNodeFunction( string methodName, IList arguments, ParseExpressionContext context ) : + FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "count"; + + private static readonly MethodInfo CountMethod; + + static CountNodeFunction() + { + CountMethod = typeof( Enumerable ) + .GetMethods( BindingFlags.Static | BindingFlags.Public ) + .First( m => + m.Name == "Count" && + m.GetParameters().Length == 1 && + m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof( IEnumerable<> ) ) + .MakeGenericMethod( typeof( JsonNode ) ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 1 ) + { + return Expression.Throw( Expression.Constant( new Exception( $"Invalid use of {Name} function" ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + + return Expression.Convert( Expression.Call( + CountMethod, + Expression.Call( FilterNodeHelper.SelectElementsMethod, + context.Current, + context.Root, + queryExp ) ) + , typeof( float ) ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/FilterNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/FilterNodeFunction.cs new file mode 100644 index 00000000..9b8ed8bb --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/FilterNodeFunction.cs @@ -0,0 +1,15 @@ +using System.Linq.Expressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Node.Functions; + +public class FilterNodeFunction( ParseExpressionContext context ) : FilterFunction +{ + protected override Expression GetExpressionImpl( ReadOnlySpan data, ReadOnlySpan item, ref int start, ref int from ) + { + var queryExp = Expression.Constant( item.ToString() ); + + // Create a call expression for the extension method + return Expression.Call( FilterNodeHelper.SelectFirstElementValueMethod, context.Current, context.Root, queryExp ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/LengthNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/LengthNodeFunction.cs new file mode 100644 index 00000000..9b313967 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/LengthNodeFunction.cs @@ -0,0 +1,47 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Node.Functions; + +public class LengthNodeFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "length"; + + private static readonly MethodInfo LengthMethod; + + static LengthNodeFunction() + { + LengthMethod = typeof( LengthNodeFunction ).GetMethod( nameof( Length ), [typeof( JsonNode )] ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 1 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + + return Expression.Call( + LengthMethod, + Expression.Call( FilterNodeHelper.SelectFirstMethod, + context.Current, + context.Root, + queryExp ) ); + } + + public static float Length( JsonNode node ) + { + return node.GetValueKind() switch + { + JsonValueKind.String => node.GetValue()?.Length ?? 0, + JsonValueKind.Array => node.AsArray().Count, + JsonValueKind.Object => node.AsObject().Count, + _ => 0 + }; + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/MatchNodeFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/MatchNodeFunction.cs new file mode 100644 index 00000000..56c86ab7 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/MatchNodeFunction.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Node.Functions; + +public class MatchNodeFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "match"; + + private static readonly MethodInfo MatchMethod; + + static MatchNodeFunction() + { + MatchMethod = typeof( MatchNodeFunction ).GetMethod( nameof( Match ), [typeof( JsonNode ), typeof( string )] ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 2 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + var regex = Expression.Constant( arguments[1] ); + + return Expression.Call( + MatchMethod, + Expression.Call( FilterNodeHelper.SelectFirstMethod, + context.Current, + context.Root, + queryExp ) + , regex ); + } + + public static bool Match( JsonNode node, string regex ) + { + var regexPattern = new Regex( regex.Trim( '\"', '\'' ) ); + var value = $"^{node.GetValue()}$"; + + return regexPattern.IsMatch( value ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/SearchElementFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/SearchElementFunction.cs new file mode 100644 index 00000000..c6322712 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/SearchElementFunction.cs @@ -0,0 +1,47 @@ +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Node.Functions; + +public class SearchNodeFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "search"; + + private static readonly MethodInfo SearchMethod; + + static SearchNodeFunction() + { + SearchMethod = typeof( SearchNodeFunction ).GetMethod( nameof( Search ), [typeof( JsonNode ), typeof( string )] ); + } + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 2 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + var regex = Expression.Constant( arguments[1] ); + + return Expression.Call( + SearchMethod, + Expression.Call( FilterNodeHelper.SelectFirstMethod, + context.Current, + context.Root, + queryExp ) + , regex ); + } + + public static bool Search( JsonNode node, string regex ) + { + var regexPattern = new Regex( regex.Trim( '\"', '\'' ) ); + var value = node.GetValue(); + + return value != null && regexPattern.IsMatch( value ); + } + +} diff --git a/src/Hyperbee.Json/Descriptors/Node/Functions/ValueElementFunction.cs b/src/Hyperbee.Json/Descriptors/Node/Functions/ValueElementFunction.cs new file mode 100644 index 00000000..d34d0b32 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/Functions/ValueElementFunction.cs @@ -0,0 +1,25 @@ +using System.Linq.Expressions; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Descriptors.Node.Functions; + +public class ValueNodeFunction( string methodName, IList arguments, ParseExpressionContext context ) : FilterExtensionFunction( methodName, arguments, context ) +{ + public const string Name = "value"; + + public override Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ) + { + if ( arguments.Count != 1 ) + { + return Expression.Throw( Expression.Constant( new ArgumentException( $"{Name} function has invalid parameter count." ) ) ); + } + + var queryExp = Expression.Constant( arguments[0] ); + + return Expression.Call( + FilterNodeHelper.SelectFirstElementValueMethod, + context.Current, + context.Root, + queryExp ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/NodeTypeDescriptor.cs b/src/Hyperbee.Json/Descriptors/Node/NodeTypeDescriptor.cs new file mode 100644 index 00000000..42003a59 --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/NodeTypeDescriptor.cs @@ -0,0 +1,38 @@ +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 FilterEvaluator _evaluator; + private NodeValueAccessor _accessor; + public Dictionary Functions { get; init; } + + public IValueAccessor Accessor + { + get => _accessor ??= new NodeValueAccessor(); + } + + public IFilterEvaluator FilterEvaluator + { + get => _evaluator ??= new FilterEvaluator( this ); + } + + public FilterFunction GetFilterFunction( ParseExpressionContext context ) => + new FilterNodeFunction( context ); + + public NodeTypeDescriptor() + { + Functions = new Dictionary( + [ + new KeyValuePair( CountNodeFunction.Name, ( name, arguments, context ) => new CountNodeFunction( name, arguments, context ) ), + new KeyValuePair( LengthNodeFunction.Name, ( name, arguments, context ) => new LengthNodeFunction( name, arguments, context ) ), + new KeyValuePair( MatchNodeFunction.Name, ( name, arguments, context ) => new MatchNodeFunction( name, arguments, context ) ), + new KeyValuePair( SearchNodeFunction.Name, ( name, arguments, context ) => new SearchNodeFunction( name, arguments, context ) ), + new KeyValuePair( ValueNodeFunction.Name, ( name, arguments, context ) => new ValueNodeFunction( name, arguments, context ) ), + ] ); + } +} diff --git a/src/Hyperbee.Json/Descriptors/Node/NodeValueAccessor.cs b/src/Hyperbee.Json/Descriptors/Node/NodeValueAccessor.cs new file mode 100644 index 00000000..cb4f7e3f --- /dev/null +++ b/src/Hyperbee.Json/Descriptors/Node/NodeValueAccessor.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json.Nodes; + +namespace Hyperbee.Json.Descriptors.Node; + +internal class NodeValueAccessor : IValueAccessor +{ + public IEnumerable<(JsonNode, string)> EnumerateChildren( JsonNode value, bool includeValues = true ) + { + switch ( value ) + { + case JsonArray arrayValue: + for ( var index = arrayValue.Count - 1; index >= 0; index-- ) + { + var child = arrayValue[index]; + + if ( includeValues || child is JsonObject or JsonArray ) + yield return (child, index.ToString()); + } + + break; + case JsonObject objectValue: + + if ( includeValues ) + { + foreach ( var child in objectValue.Reverse() ) + yield return (child.Value, child.Key); + } + else + { + foreach ( var child in objectValue.Where( property => property.Value is JsonObject or JsonArray ).Reverse() ) + yield return (child.Value, child.Key); + } + + break; + } + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public JsonNode GetElementAt( in JsonNode value, int index ) + { + return value[index]; + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool IsObjectOrArray( in JsonNode value ) + { + return value is JsonObject or JsonArray; + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool IsArray( in JsonNode value, out int length ) + { + if ( value is JsonArray jsonArray ) + { + length = jsonArray.Count; + return true; + } + + length = 0; + return false; + } + + [MethodImpl( MethodImplOptions.AggressiveInlining )] + public bool IsObject( in JsonNode value ) + { + return value is JsonObject; + } + + public bool TryGetChildValue( in JsonNode value, string childKey, out JsonNode childValue ) + { + switch ( value ) + { + case JsonObject valueObject: + { + if ( valueObject.TryGetPropertyValue( childKey, out childValue ) ) + return true; + + break; + } + case JsonArray valueArray: + { + var index = TryParseInt( childKey ) ?? -1; + + if ( index >= 0 && index < valueArray.Count ) + { + childValue = value[index]; + return true; + } + + break; + } + default: + { + if ( !IsPathOperator( childKey ) ) + throw new ArgumentException( $"Invalid child type '{childKey}'. Expected child to be Object, Array or a path selector.", nameof( value ) ); + + break; + } + } + + childValue = default; + return false; + + static bool IsPathOperator( ReadOnlySpan x ) => x == "*" || x == ".." || x == "$"; + + static int? TryParseInt( ReadOnlySpan numberString ) + { + return numberString == null ? null : int.TryParse( numberString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n ) ? n : null; + } + } +} diff --git a/src/Hyperbee.Json/Dynamic/DynamicJsonConverter.cs b/src/Hyperbee.Json/Dynamic/DynamicJsonConverter.cs index e371f1bf..2e0e66e1 100644 --- a/src/Hyperbee.Json/Dynamic/DynamicJsonConverter.cs +++ b/src/Hyperbee.Json/Dynamic/DynamicJsonConverter.cs @@ -76,7 +76,7 @@ private dynamic InternalRead( ref Utf8JsonReader reader, Type typeToConvert, Jso case JsonTokenType.StartArray: - IList array = new List(); + IList array = []; var i = 0; while ( reader.Read() ) diff --git a/src/Hyperbee.Json/Evaluators/IJsonPathScriptEvaluator.cs b/src/Hyperbee.Json/Evaluators/IJsonPathScriptEvaluator.cs deleted file mode 100644 index 5a29cfa3..00000000 --- a/src/Hyperbee.Json/Evaluators/IJsonPathScriptEvaluator.cs +++ /dev/null @@ -1,9 +0,0 @@ - -namespace Hyperbee.Json.Evaluators; - -public delegate object JsonPathEvaluator( string script, TType current, string context ); - -public interface IJsonPathScriptEvaluator -{ - public object Evaluator( string script, TType current, string context ); -} diff --git a/src/Hyperbee.Json/Evaluators/JsonPathCSharpEvaluator.cs b/src/Hyperbee.Json/Evaluators/JsonPathCSharpEvaluator.cs deleted file mode 100644 index c8c9871e..00000000 --- a/src/Hyperbee.Json/Evaluators/JsonPathCSharpEvaluator.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Collections.Concurrent; -using System.Reflection; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using Hyperbee.Json.Extensions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Scripting; -using Microsoft.CodeAnalysis.Scripting; -using Microsoft.CSharp.RuntimeBinder; - -namespace Hyperbee.Json.Evaluators; - - -public abstract partial class JsonPathCSharpEvaluator : IJsonPathScriptEvaluator -{ - // ReSharper disable once StaticMemberInGenericType - private static readonly ConcurrentDictionary> Compiled = new(); - - protected static Regex ThisPropertyRegex => PropertyRegex(); - protected static Regex ThisReservedRegex => ReservedRegex(); - - [GeneratedRegex( "@[A-Za-z_][A-Za-z0-9_]*" )] - private static partial Regex ReservedRegex(); - - [GeneratedRegex( "@\\.[A-Za-z_][A-Za-z0-9_]*" )] - private static partial Regex PropertyRegex(); - - public object Evaluator( string script, TType current, string context ) - { - var compiled = Compiled.GetOrAdd( script, key => - { - var normalizedScript = TransformExpression( script ); - - var references = new List - { - MetadataReference.CreateFromFile( typeof(RuntimeBinderException).GetTypeInfo().Assembly.Location ), - MetadataReference.CreateFromFile( typeof(System.Runtime.CompilerServices.DynamicAttribute).GetTypeInfo().Assembly.Location ) - }; - - var code = CSharpScript.Create( normalizedScript, ScriptOptions.Default.AddReferences( references ), typeof( Globals ) ); - code.Compile(); - - return code; - } ); - - try - { - var globals = ActivateGlobals( current, context ); - - var result = AsyncCurrentThreadHelper.RunSync( - async () => await compiled.RunAsync( globals ).ConfigureAwait( true ) - ); - - return result.ReturnValue; - } - catch ( RuntimeBinderException ) - { - return null; // missing members should act falsy - } - catch ( Exception ex ) - { - throw new JsonPathEvaluatorException( "Error compiling JsonPath expression.", ex ); - } - } - - protected abstract Globals ActivateGlobals( TType current, string context ); - - protected virtual string TransformExpression( string expression ) - { - var result = expression; - - result = ThisPropertyRegex.Replace( result, x => $"This.{x.Value[2..]}" ); // '@.' to 'This.' - result = ThisReservedRegex.Replace( result, x => $"This.{x.Value[1..]}()" ); // '@path' to 'This.path()' - - return result; - } -} - -// we would have liked to declare Globals as a nested type within the evaluator -// but the roslyn script compiler doesn't like it when the parent type is generic. - -public sealed class Globals -{ - internal Globals() - { - } - - public dynamic This { get; internal set; } -} - -public class JsonPathCSharpElementEvaluator : JsonPathCSharpEvaluator -{ - protected override Globals ActivateGlobals( JsonElement current, string context ) => new() { This = current.ToDynamic( context ) }; -} - -public class JsonPathCSharpNodeEvaluator : JsonPathCSharpEvaluator -{ - protected override Globals ActivateGlobals( JsonNode current, string context ) => new() { This = current.ToDynamic() }; -} diff --git a/src/Hyperbee.Json/Evaluators/JsonPathEvaluatorException.cs b/src/Hyperbee.Json/Evaluators/JsonPathEvaluatorException.cs deleted file mode 100644 index 8c2f781f..00000000 --- a/src/Hyperbee.Json/Evaluators/JsonPathEvaluatorException.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Hyperbee.Json.Evaluators; - -[Serializable] -public class JsonPathEvaluatorException : Exception -{ - public JsonPathEvaluatorException() - : base( "JsonPath evaluator exception." ) - { - } - - public JsonPathEvaluatorException( string message ) - : base( message ) - { - } - - public JsonPathEvaluatorException( string message, Exception innerException ) - : base( message, innerException ) - { - } -} diff --git a/src/Hyperbee.Json/Evaluators/JsonPathFuncEvaluator.cs b/src/Hyperbee.Json/Evaluators/JsonPathFuncEvaluator.cs deleted file mode 100644 index d6d49def..00000000 --- a/src/Hyperbee.Json/Evaluators/JsonPathFuncEvaluator.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Hyperbee.Json.Evaluators; - -public class JsonPathFuncEvaluator : IJsonPathScriptEvaluator -{ - private readonly JsonPathEvaluator _evaluator; - - public JsonPathFuncEvaluator( JsonPathEvaluator evaluator ) - { - _evaluator = evaluator; - } - - public object Evaluator( string script, TType current, string context ) - { - return _evaluator?.Invoke( script, current, context ); - } -} diff --git a/src/Hyperbee.Json/Evaluators/JsonPathNullEvaluator.cs b/src/Hyperbee.Json/Evaluators/JsonPathNullEvaluator.cs deleted file mode 100644 index 6dbb5f1e..00000000 --- a/src/Hyperbee.Json/Evaluators/JsonPathNullEvaluator.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Hyperbee.Json.Evaluators; - -public class JsonPathNullEvaluator : IJsonPathScriptEvaluator -{ - public object Evaluator( string script, TType current, string context ) - { - return null; - } -} diff --git a/src/Hyperbee.Json/Extensions/AsyncCurrentThreadHelper.cs b/src/Hyperbee.Json/Extensions/AsyncCurrentThreadHelper.cs deleted file mode 100644 index 07595677..00000000 --- a/src/Hyperbee.Json/Extensions/AsyncCurrentThreadHelper.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Collections.Concurrent; - -// synchronously execute an async method on the current thread using a custom synchronization context -// https://devblogs.microsoft.com/pfxteam/await-synchronizationcontext-and-console-apps/ - -// nameof(AsyncCurrentThreadHelper.RunSync) executes all the async operations on the calling thread -// by leveraging a custom nameof(SynchronizationContext). This prevents blocking the calling thread -// and waiting on the result of a Task.Run which can result in poor performance and thread -// starvation. this method only ever uses the calling thread to manage the async calls vs tying up -// thread pool threads. - -namespace Hyperbee.Json.Extensions; - -internal static class AsyncCurrentThreadHelper -{ - public static void RunSync( Func func ) - { - ArgumentNullException.ThrowIfNull( func ); - - var currentContext = SynchronizationContext.Current; - var syncContext = new SingleThreadSynchronizationContext(); - - try - { - SynchronizationContext.SetSynchronizationContext( syncContext ); - syncContext.InternalRunSync( func ); - } - finally - { - SynchronizationContext.SetSynchronizationContext( currentContext ); - } - } - - public static T RunSync( Func> func ) - { - ArgumentNullException.ThrowIfNull( func ); - - var currentContext = SynchronizationContext.Current; - var syncContext = new SingleThreadSynchronizationContext(); - - try - { - SynchronizationContext.SetSynchronizationContext( syncContext ); - return syncContext.InternalRunSync( func ); - } - finally - { - SynchronizationContext.SetSynchronizationContext( currentContext ); - } - } - - private sealed class SingleThreadSynchronizationContext : SynchronizationContext - { - private readonly BlockingCollection<(SendOrPostCallback callback, object state)> _items = new(); - - public void InternalRunSync( Func func ) - { - var task = func() ?? throw new InvalidOperationException( "No task provided." ); - task.ContinueWith( _ => Complete(), TaskScheduler.Default ); // final continuation - - RunOnCurrentThread(); - - task.GetAwaiter().GetResult(); - } - - public T InternalRunSync( Func> func ) - { - var task = func() ?? throw new InvalidOperationException( "No task provided." ); - task.ContinueWith( _ => Complete(), TaskScheduler.Default ); - - RunOnCurrentThread(); - - return task.GetAwaiter().GetResult(); - } - - public override void Post( SendOrPostCallback callback, object state ) - { - // this method receives posted continuations from the task. - // we add them to our queue for execution by the run loop. - - ArgumentNullException.ThrowIfNull( callback ); - - _items.Add( (callback, state) ); - } - - public override void Send( SendOrPostCallback callback, object state ) - { - throw new NotSupportedException( "Synchronous sending is not supported." ); - } - - private void RunOnCurrentThread() - { - foreach ( var (callback, state) in _items.GetConsumingEnumerable() ) - callback( state ); - } - - private void Complete() => _items.CompleteAdding(); - } -} diff --git a/src/Hyperbee.Json/Extensions/ImmutableStackExtensions.cs b/src/Hyperbee.Json/Extensions/ImmutableStackExtensions.cs deleted file mode 100644 index 30c67984..00000000 --- a/src/Hyperbee.Json/Extensions/ImmutableStackExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Immutable; -using Hyperbee.Json.Tokenizer; - -namespace Hyperbee.Json.Extensions; - -internal static class ImmutableStackExtensions -{ - internal static IImmutableStack Push( this IImmutableStack stack, string selector, SelectorKind kind ) - { - return stack.Push( new JsonPathToken( selector, kind ) ); - } -} diff --git a/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs b/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs index 77b6b3b2..9094e86d 100644 --- a/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs +++ b/src/Hyperbee.Json/Extensions/JsonElementExtensions.cs @@ -1,5 +1,4 @@ using System.Buffers; -using System.Linq.Expressions; using System.Text.Json; using System.Text.Json.Nodes; using Hyperbee.Json.Dynamic; @@ -8,18 +7,6 @@ namespace Hyperbee.Json.Extensions; public static class JsonElementExtensions { - // Is operations - - public static bool IsNullOrUndefined( this JsonElement value ) - { - return value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined; - } - - public static bool IsObjectOrArray( this JsonElement value ) - { - return value.ValueKind is JsonValueKind.Array or JsonValueKind.Object; - } - // To operations public static dynamic ToDynamic( this JsonElement value, string path = null ) => new DynamicJsonElement( ref value, path ); @@ -39,6 +26,7 @@ public static JsonNode ToJsonNode( this JsonElement element ) _ => JsonValue.Create( element ) }; } + public static T ToObject( this JsonElement value, JsonSerializerOptions options = null ) where T : new() { @@ -52,6 +40,25 @@ public static T ToObject( this JsonElement value, JsonSerializerOptions optio return JsonSerializer.Deserialize( ref reader, options ); } + // Deep Equals/Compare extensions + + public static bool DeepEquals( this JsonElement elmA, string strB, JsonDocumentOptions options = default ) + { + if ( strB == null ) + return false; + + var comparer = new JsonElementDeepEqualsComparer( options.MaxDepth ); + using var docB = JsonDocument.Parse( strB, options ); + + return comparer.Equals( elmA, docB.RootElement ); + } + + public static bool DeepEquals( this JsonElement elmA, JsonElement elmB, JsonDocumentOptions options = default ) + { + var comparer = new JsonElementDeepEqualsComparer( options.MaxDepth ); + return comparer.Equals( elmA, elmB ); + } + // Value extensions public static short GetNumberAsInt16( this JsonElement value ) @@ -77,20 +84,4 @@ public static long GetNumberAsInt64( this JsonElement value ) return (long) value.GetDouble(); // for cases where the number contains fractional digits } - - // GetDocument() - // - // As of net 6, JsonElement._parent is private. Provide a way to get to the parent document. - // Use an expression over reflection. The first-time cost is higher but additional calls are faster. - - private static readonly Func ParentAccessor = CreateParentAccessor(); - - private static Func CreateParentAccessor() - { - var param = Expression.Parameter( typeof( JsonElement ), "target" ); - var field = Expression.Field( param, "_parent" ); - return Expression.Lambda>( field, param ).Compile(); - } - - public static JsonDocument GetDocument( this JsonElement value ) => ParentAccessor( value ); } diff --git a/src/Hyperbee.Json/Extensions/JsonHelper.cs b/src/Hyperbee.Json/Extensions/JsonHelper.cs index f418211c..ef3d17e2 100644 --- a/src/Hyperbee.Json/Extensions/JsonHelper.cs +++ b/src/Hyperbee.Json/Extensions/JsonHelper.cs @@ -1,55 +1,18 @@ using System.Text; -using System.Text.Json; -using Hyperbee.Json.Tokenizer; namespace Hyperbee.Json.Extensions; public static class JsonHelper { - // comparison - - public static bool Compare( string strA, string strB, JsonDocumentOptions options = default ) - { - if ( strA == null && strB == null ) - return true; - - if ( strA == null || strB == null ) - return false; - - var comparer = new JsonElementEqualityComparer( options.MaxDepth ); - - using var docA = JsonDocument.Parse( strA, options ); - using var docB = JsonDocument.Parse( strB, options ); - - return comparer.Equals( docA.RootElement, docB.RootElement ); - } - - public static bool Compare( JsonElement elmA, string strB, JsonDocumentOptions options = default ) - { - if ( strB == null ) - return false; - - var comparer = new JsonElementEqualityComparer( options.MaxDepth ); - using var docB = JsonDocument.Parse( strB, options ); - - return comparer.Equals( elmA, docB.RootElement ); - } - - public static bool Compare( JsonElement elmA, JsonElement elmB, JsonDocumentOptions options = default ) - { - var comparer = new JsonElementEqualityComparer( options.MaxDepth ); - return comparer.Equals( elmA, elmB ); - } - // conversion public static ReadOnlySpan NormalizePath( ReadOnlySpan path ) { - var tokens = JsonPathQueryTokenizer.TokenizeNoCache( path ); + var segments = JsonPathQueryTokenizer.TokenizeNoCache( path ); var builder = new StringBuilder(); - foreach ( var token in tokens ) + foreach ( var token in segments.AsEnumerable() ) { builder.Append( '[' ); diff --git a/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs b/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs index 14e8686b..4c292887 100644 --- a/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs +++ b/src/Hyperbee.Json/Extensions/JsonPathSelectExtensions.cs @@ -1,90 +1,22 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Hyperbee.Json.Evaluators; namespace Hyperbee.Json.Extensions; public static class JsonPathSelectExtensions { - // JsonElement - public static IEnumerable Select( this JsonElement element, string query ) { - return new JsonPath( null ).Select( element, query ); - } - - public static IEnumerable Select( this JsonElement element, string query, IJsonPathScriptEvaluator evaluator ) - { - return new JsonPath( evaluator ).Select( element, query ); - } - - public static IEnumerable Select( this JsonElement element, string query, JsonPathEvaluator evaluator ) - { - return new JsonPath( new JsonPathFuncEvaluator( evaluator ) ).Select( element, query ); + return JsonPath.Select( element, query ); } public static IEnumerable Select( this JsonDocument document, string query ) { - return new JsonPath( null ).Select( document.RootElement, query ); - } - - public static IEnumerable Select( this JsonDocument document, string query, IJsonPathScriptEvaluator evaluator ) - { - return new JsonPath( evaluator ).Select( document.RootElement, query ); - } - - public static IEnumerable Select( this JsonDocument document, string query, JsonPathEvaluator evaluator ) - { - return new JsonPath( new JsonPathFuncEvaluator( evaluator ) ).Select( document.RootElement, query ); - } - - // JsonPathElement - - public static IEnumerable SelectPath( this JsonElement element, string query ) - { - return new JsonPath( null ).SelectPath( element, query ); - } - - public static IEnumerable SelectPath( this JsonElement element, string query, IJsonPathScriptEvaluator evaluator ) - { - return new JsonPath( evaluator ).SelectPath( element, query ); + return JsonPath.Select( document.RootElement, query ); } - public static IEnumerable SelectPath( this JsonElement element, string query, JsonPathEvaluator evaluator ) - { - return new JsonPath( new JsonPathFuncEvaluator( evaluator ) ).SelectPath( element, query ); - } - - public static IEnumerable SelectPath( this JsonDocument document, string query ) - { - return new JsonPath( null ).SelectPath( document.RootElement, query ); - } - - public static IEnumerable SelectPath( this JsonDocument document, string query, IJsonPathScriptEvaluator evaluator ) - { - return new JsonPath( evaluator ).SelectPath( document.RootElement, query ); - } - - public static IEnumerable SelectPath( this JsonDocument document, string query, JsonPathEvaluator evaluator ) - { - return new JsonPath( new JsonPathFuncEvaluator( evaluator ) ).SelectPath( document.RootElement, query ); - } - - // JsonNode - public static IEnumerable Select( this JsonNode node, string query ) { - return new Nodes.JsonPathNode( null ).Select( node, query ); - } - - public static IEnumerable Select( this JsonNode node, string query, IJsonPathScriptEvaluator evaluator ) - { - return new Nodes.JsonPathNode( evaluator ).Select( node, query ); - } - - public static IEnumerable Select( this JsonNode node, string query, JsonPathEvaluator evaluator ) - { - return new Nodes.JsonPathNode( new JsonPathFuncEvaluator( evaluator ) ).Select( node, query ); + return JsonPath.Select( node, query ); } } - diff --git a/src/Hyperbee.Json/Extensions/JsonPropertyKeyExtensions.cs b/src/Hyperbee.Json/Extensions/JsonPropertyKeyExtensions.cs index 2241e8da..d5aaa7b1 100644 --- a/src/Hyperbee.Json/Extensions/JsonPropertyKeyExtensions.cs +++ b/src/Hyperbee.Json/Extensions/JsonPropertyKeyExtensions.cs @@ -4,6 +4,8 @@ namespace Hyperbee.Json.Extensions; // DISTINCT from JsonPath these extensions are intended to facilitate 'diving' for Json Properties by key. +// similar to JsonPointer but uses JsonPath notation. +// // syntax supports singular 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. @@ -18,14 +20,9 @@ namespace Hyperbee.Json.Extensions; public static class JsonPropertyKeyExtensions { - public static JsonElement GetPropertyFromKey( this JsonDocument document, ReadOnlySpan propertyPath ) - { - return document.RootElement.GetPropertyFromKey( propertyPath ); - } - public static JsonElement GetPropertyFromKey( this JsonElement jsonElement, ReadOnlySpan propertyPath ) { - if ( jsonElement.IsNullOrUndefined() || propertyPath.IsEmpty ) + if ( IsNullOrUndefined( jsonElement ) || propertyPath.IsEmpty ) return default; var splitter = new JsonPropertyKeySplitter( propertyPath ); @@ -40,11 +37,13 @@ public static JsonElement GetPropertyFromKey( this JsonElement jsonElement, Read jsonElement = jsonElement.TryGetProperty( name!, out var value ) ? value : default; - if ( jsonElement.IsNullOrUndefined() ) + if ( IsNullOrUndefined( jsonElement ) ) return default; } return jsonElement; + + static bool IsNullOrUndefined( JsonElement value ) => value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined; } public static JsonNode GetPropertyFromKey( this JsonNode jsonNode, ReadOnlySpan propertyPath ) diff --git a/src/Hyperbee.Json/Filters/FilterEvaluator.cs b/src/Hyperbee.Json/Filters/FilterEvaluator.cs new file mode 100644 index 00000000..bab74cd2 --- /dev/null +++ b/src/Hyperbee.Json/Filters/FilterEvaluator.cs @@ -0,0 +1,38 @@ +using System.Collections.Concurrent; +using Hyperbee.Json.Descriptors; +using Hyperbee.Json.Filters.Parser; +using Microsoft.CSharp.RuntimeBinder; + +namespace Hyperbee.Json.Filters; + +public sealed class FilterEvaluator : IFilterEvaluator +{ + private readonly IJsonTypeDescriptor _typeDescriptor; + + // ReSharper disable once StaticMemberInGenericType + private static readonly ConcurrentDictionary> Compiled = new(); + + public FilterEvaluator( ITypeDescriptor typeDescriptor ) + { + _typeDescriptor = typeDescriptor; + } + + public object Evaluate( string filter, TNode current, TNode root ) + { + var compiled = Compiled.GetOrAdd( filter, _ => JsonPathExpression.Compile( filter, _typeDescriptor ) ); + + try + { + return compiled( current, root ); + } + catch ( RuntimeBinderException ) + { + return null; // missing members should act falsy + } + catch ( Exception ex ) + { + throw new FilterEvaluatorException( "Error compiling JsonPath expression.", ex ); + } + } +} + diff --git a/src/Hyperbee.Json/Filters/FilterEvaluatorException.cs b/src/Hyperbee.Json/Filters/FilterEvaluatorException.cs new file mode 100644 index 00000000..7ba399e2 --- /dev/null +++ b/src/Hyperbee.Json/Filters/FilterEvaluatorException.cs @@ -0,0 +1,20 @@ +namespace Hyperbee.Json.Filters; + +[Serializable] +public class FilterEvaluatorException : Exception +{ + public FilterEvaluatorException() + : base( "JsonPath filter evaluator exception." ) + { + } + + public FilterEvaluatorException( string message ) + : base( message ) + { + } + + public FilterEvaluatorException( string message, Exception innerException ) + : base( message, innerException ) + { + } +} diff --git a/src/Hyperbee.Json/Filters/IFilterEvaluator.cs b/src/Hyperbee.Json/Filters/IFilterEvaluator.cs new file mode 100644 index 00000000..15a15999 --- /dev/null +++ b/src/Hyperbee.Json/Filters/IFilterEvaluator.cs @@ -0,0 +1,7 @@ + +namespace Hyperbee.Json.Filters; + +public interface IFilterEvaluator +{ + public object Evaluate( string filter, TNode current, TNode root ); +} diff --git a/src/Hyperbee.Json/Filters/Parser/FilterExtensionFunction.cs b/src/Hyperbee.Json/Filters/Parser/FilterExtensionFunction.cs new file mode 100644 index 00000000..b21e899e --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/FilterExtensionFunction.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; + +namespace Hyperbee.Json.Filters.Parser; + +public abstract class FilterExtensionFunction : FilterFunction +{ + private readonly string _methodName; + private readonly IList _arguments; + private readonly ParseExpressionContext _context; + + protected FilterExtensionFunction( string methodName, + IList arguments, + ParseExpressionContext context ) + { + _methodName = methodName; + _arguments = arguments; + _context = context; + } + + public abstract Expression GetExtensionExpression( string methodName, IList arguments, ParseExpressionContext context ); + + protected override Expression GetExpressionImpl( ReadOnlySpan data, ReadOnlySpan item, ref int start, ref int from ) + { + // Convert to extension function shape + return GetExtensionExpression( _methodName, _arguments, _context ); + } +} diff --git a/src/Hyperbee.Json/Filters/Parser/FilterFunction.cs b/src/Hyperbee.Json/Filters/Parser/FilterFunction.cs new file mode 100644 index 00000000..566a714a --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/FilterFunction.cs @@ -0,0 +1,100 @@ +using System.Linq.Expressions; +using static Hyperbee.Json.Filters.Parser.JsonPathExpression; + +namespace Hyperbee.Json.Filters.Parser; + +public class FilterFunction +{ + private readonly FilterFunction _implementation; + + public FilterFunction() + { + _implementation = this; + } + + internal FilterFunction( ReadOnlySpan item, FilterTokenType? type, ParseExpressionContext context ) + { + if ( TryGetParenFunction( item, type, context, out _implementation ) ) + { + return; + } + + if ( TryGetFilterFunction( item, context, out _implementation ) ) + { + return; + } + + if ( TryGetExtensionFunction( item, context, out _implementation ) ) + { + return; + } + + // No functions not found, try to parse this as a literal value. + var literalFunction = new LiteralFunction(); + _implementation = literalFunction; + } + + public Expression GetExpression( ReadOnlySpan data, ReadOnlySpan item, ref int start, ref int from ) + { + return _implementation.GetExpressionImpl( data, item, ref start, ref from ); + } + + protected virtual Expression GetExpressionImpl( ReadOnlySpan data, ReadOnlySpan item, ref int start, ref int from ) + { + // The real implementation will be in the derived classes. + return Expression.Throw( Expression.Constant( new NotImplementedException() ) ); + } + + private static bool TryGetParenFunction( ReadOnlySpan item, FilterTokenType? type, ParseExpressionContext context, out FilterFunction function ) + { + function = null; + + if ( item.Length != 0 || type != FilterTokenType.OpenParen ) + { + return false; + } + + function = new ParenFunction( context ); + return true; + } + + private static bool TryGetFilterFunction( ReadOnlySpan item, ParseExpressionContext context, out FilterFunction function ) + { + switch ( item[0] ) + { + case '@': + function = context.Descriptor.GetFilterFunction( context ); + return true; + case '$': + // Current becomes root + function = context.Descriptor.GetFilterFunction( context with { Current = context.Root } ); + return true; + } + + function = null; + return false; + } + private static bool TryGetExtensionFunction( ReadOnlySpan item, ParseExpressionContext context, out FilterFunction function ) + { + var match = FilterTokenizerRegex.RegexFunction().Match( item.ToString() ); + + if ( match.Groups.Count != 3 ) + { + function = null; + return false; + } + + var method = match.Groups[1].Value; + var arguments = match.Groups[2].Value.Split( ',', options: StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries ); + + if ( context.Descriptor.Functions.TryGetValue( method.ToLowerInvariant(), out var creator ) ) + { + function = creator( method, arguments, context ); + return true; + } + + function = null; + return false; + } + +} diff --git a/src/Hyperbee.Json/Filters/Parser/FilterTokenizerRegex.cs b/src/Hyperbee.Json/Filters/Parser/FilterTokenizerRegex.cs new file mode 100644 index 00000000..66395c48 --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/FilterTokenizerRegex.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace Hyperbee.Json.Filters.Parser; + +internal static partial class FilterTokenizerRegex +{ + [GeneratedRegex( @"([a-z][a-z0-9_]*)\s*\(\s*((?:[^,()]+(?:\s*,\s*)?)*)\s*\)?" )] + internal static partial Regex RegexFunction(); + + [GeneratedRegex( @"^""(?:[^""\\]|\\.)*""$", RegexOptions.ExplicitCapture )] + internal static partial Regex RegexQuotedDouble(); + + [GeneratedRegex( @"^'(?:[^'\\]|\\.)*'$", RegexOptions.ExplicitCapture )] + internal static partial Regex RegexQuoted(); +} diff --git a/src/Hyperbee.Json/Filters/Parser/FilterTruthyExpression.cs b/src/Hyperbee.Json/Filters/Parser/FilterTruthyExpression.cs new file mode 100644 index 00000000..96ea6ee7 --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/FilterTruthyExpression.cs @@ -0,0 +1,34 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace Hyperbee.Json.Filters.Parser; + +public static class FilterTruthyExpression +{ + private static readonly MethodInfo IsTruthyMethod; + + static FilterTruthyExpression() + { + IsTruthyMethod = typeof( FilterTruthyExpression ).GetMethod( nameof( IsTruthy ), [typeof( object )] ); + } + + public static Expression IsTruthyExpression( Expression expression ) => + expression.Type == typeof( bool ) + ? expression + : Expression.Call( IsTruthyMethod, expression ); + + public static bool IsTruthy( object obj ) => !IsFalsy( obj ); + + public static bool IsFalsy( object obj ) + { + return obj switch + { + null => true, + bool boolValue => !boolValue, + string str => string.IsNullOrEmpty( str ) || str == "false", + Array array => array.Length == 0, + IConvertible convertible => !Convert.ToBoolean( convertible ), + _ => false + }; + } +} diff --git a/src/Hyperbee.Json/Filters/Parser/FunctionCreator.cs b/src/Hyperbee.Json/Filters/Parser/FunctionCreator.cs new file mode 100644 index 00000000..805a7443 --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/FunctionCreator.cs @@ -0,0 +1,6 @@ +namespace Hyperbee.Json.Filters.Parser; + +public delegate FilterExtensionFunction FunctionCreator( + string methodName, + IList arguments, + ParseExpressionContext context = null ); diff --git a/src/Hyperbee.Json/Filters/Parser/JsonPathExpression.cs b/src/Hyperbee.Json/Filters/Parser/JsonPathExpression.cs new file mode 100644 index 00000000..7b80853b --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/JsonPathExpression.cs @@ -0,0 +1,344 @@ +using System.Linq.Expressions; +using System.Reflection; +using Hyperbee.Json.Descriptors; + +namespace Hyperbee.Json.Filters.Parser; +// Based off Split-and-Merge Expression Parser +// https://learn.microsoft.com/en-us/archive/msdn-magazine/2015/october/csharp-a-split-and-merge-expression-parser-in-csharp +// Handles `filter-selector` in the https://github.com/ietf-wg-jsonpath/draft-ietf-jsonpath-base/blob/main/sourcecode/abnf/jsonpath-collected.abnf#L69 + +public class JsonPathExpression +{ + public const char EndLine = '\n'; + public const char EndArg = ')'; + + private static readonly char[] ValidPathParts = ['.', '$', '@']; + + private static readonly MethodInfo ObjectEquals = typeof( object ).GetMethod( "Equals", [typeof( object ), typeof( object )] ); + + public static Func Compile( ReadOnlySpan filter, IJsonTypeDescriptor typeDescriptor ) + { + var currentParam = Expression.Parameter( typeof( TNode ) ); + var rootParam = Expression.Parameter( typeof( TNode ) ); + var expressionContext = new ParseExpressionContext( currentParam, rootParam, typeDescriptor ); + var expression = Parse( filter, expressionContext ); + + return Expression + .Lambda>( expression, currentParam, rootParam ) + .Compile(); + } + + public static Expression Parse( ReadOnlySpan filter, ParseExpressionContext context ) + { + var start = 0; + var from = 0; + var expression = Parse( filter, ref start, ref from, EndLine, context ); + + return FilterTruthyExpression.IsTruthyExpression( expression ); + } + + internal static Expression Parse( ReadOnlySpan filter, ref int start, ref int from, char to = EndLine, ParseExpressionContext context = null ) + { + if ( from >= filter.Length || filter[from] == to ) + { + throw new ArgumentException( "Invalid filter", nameof( filter ) ); + } + + var tokens = new List(); + ReadOnlySpan currentPath = null; + char? quote = null; + + do + { + if ( !TryNext( filter, ref start, ref from, ref quote, out var result ) ) + { + continue; + } + + var (ch, type) = result; + + // special handling for "!" + if ( type == FilterTokenType.Not ) + { + tokens.Add( new FilterToken( null, type!.Value ) ); + continue; + } + + if ( StillCollecting( currentPath, ch, type, to ) ) + { + currentPath = filter[start..from].Trim(); + if ( from < filter.Length && filter[from] != to ) + { + continue; + } + } + + start = from; + + // `GetExpression` may call recursively call `Parse` for nested expressions + var func = new FilterFunction( currentPath, type, context ); + var expression = func.GetExpression( filter, currentPath, ref start, ref from ); + + var filterType = ValidType( type ) + ? type!.Value + : UpdateType( filter, ref start, ref from, type, to ); + + tokens.Add( new FilterToken( expression, filterType ) ); + + } while ( from < filter.Length && filter[from] != to ); + + if ( from < filter.Length && (filter[from] == EndArg || filter[from] == to) ) + { + // This happens when called recursively: move one char forward. + from++; + } + + var baseToken = tokens[0]; + var index = 1; + + return Merge( baseToken, ref index, tokens ); + } + + private static bool TryNext( ReadOnlySpan data, ref int start, ref int from, ref char? quote, out (char NextChar, FilterTokenType? Type) result ) + { + var nextChar = data[from++]; + switch ( nextChar ) + { + case '&' when ValidNextCharacter( data, from, '&' ): + from++; + start = from; + result = (nextChar, FilterTokenType.And); + return true; + case '|' when ValidNextCharacter( data, from, '|' ): + from++; + start = from; + result = (nextChar, FilterTokenType.Or); + return true; + case '=' when ValidNextCharacter( data, from, '=' ): + from++; + start = from; + result = (nextChar, FilterTokenType.Equals); + return true; + case '!' when ValidNextCharacter( data, from, '=' ): + from++; + start = from; + result = (nextChar, FilterTokenType.NotEquals); + return true; + case '>' when ValidNextCharacter( data, from, '=' ): + from++; + start = from; + result = (nextChar, FilterTokenType.GreaterThanOrEqual); + return true; + case '<' when ValidNextCharacter( data, from, '=' ): + from++; + start = from; + result = (nextChar, FilterTokenType.LessThanOrEqual); + return true; + case '>': + start = from; + result = (nextChar, FilterTokenType.GreaterThan); + return true; + case '<': + start = from; + result = (nextChar, FilterTokenType.LessThan); + return true; + case '!': + start = from; + result = (nextChar, FilterTokenType.Not); + return true; + case '(': + result = (nextChar, FilterTokenType.OpenParen); + return true; + case ')': + result = (nextChar, FilterTokenType.ClosedParen); + return true; + case ' ' or '\t' when quote == null: + result = (nextChar, null); + return false; // eat whitespace + case '\'' or '\"' when from > 0 && data[from - 1] != '\\': + quote = quote == null ? nextChar : null; + result = (nextChar, null); + return true; + default: + result = (nextChar, null); + return true; + } + } + + private static bool ValidNextCharacter( ReadOnlySpan data, int from, char expected ) => + from < data.Length && data[from] == expected; + + private static bool StillCollecting( ReadOnlySpan item, char ch, FilterTokenType? type, char to ) + { + var stopCollecting = to is EndArg or EndLine + ? EndArg + : to; + + return item.Length == 0 && ch == EndArg || + !(ValidType( type ) || + type == FilterTokenType.OpenParen && (item.Length > 0 && ValidPathParts.Contains( item[0] ) || item.Length == 0) + || ch == stopCollecting); + } + + private static bool ValidType( FilterTokenType? type ) => + type != null && type switch + { + FilterTokenType.Not => true, + FilterTokenType.Equals => true, + FilterTokenType.NotEquals => true, + FilterTokenType.GreaterThanOrEqual => true, + FilterTokenType.GreaterThan => true, + FilterTokenType.LessThanOrEqual => true, + FilterTokenType.LessThan => true, + FilterTokenType.Or => true, + FilterTokenType.And => true, + _ => false + }; + + private static FilterTokenType UpdateType( ReadOnlySpan item, ref int start, ref int from, FilterTokenType? type, char to ) + { + var startType = type; + + if ( from >= item.Length || item[from] == EndArg || item[from] == to ) + { + return FilterTokenType.ClosedParen; + } + + var index = from; + char? quote = null; + while ( !ValidType( startType ) && index < item.Length ) + { + TryNext( item, ref start, ref index, ref quote, out var result ); + startType = result.Type; + } + + from = ValidType( startType ) ? index + : index > from ? index - 1 + : from; + + return startType!.Value; + } + + private static Expression Merge( FilterToken current, ref int index, List listToMerge, bool mergeOneOnly = false ) + { + while ( index < listToMerge.Count ) + { + var next = listToMerge[index++]; + while ( !CanMergeTokens( current, next ) ) + { + Merge( next, ref index, listToMerge, true /* mergeOneOnly */); + } + MergeTokens( current, next ); + if ( mergeOneOnly ) + { + return current.Expression; + } + } + return current.Expression; + } + + private static bool CanMergeTokens( FilterToken left, FilterToken right ) => + // "Not" can never be a right side operator + right.Type != FilterTokenType.Not && GetPriority( left.Type ) >= GetPriority( right.Type ); + + private static int GetPriority( FilterTokenType type ) => + type switch + { + FilterTokenType.Not => 1, + FilterTokenType.And or FilterTokenType.Or => 2, + FilterTokenType.Equals or FilterTokenType.NotEquals or FilterTokenType.GreaterThan or FilterTokenType.GreaterThanOrEqual or FilterTokenType.LessThan or FilterTokenType.LessThanOrEqual => 3, + _ => 0, + }; + + private static void MergeTokens( FilterToken left, FilterToken right ) + { + // TODO: clean up handling numerical, string and object comparing. feels messy. + var isNumerical = left.Expression != null && IsNumerical( left.Expression.Type ) || IsNumerical( right.Expression.Type ); + left.Expression = left.Type switch + { + FilterTokenType.Equals => CompareConvert( isNumerical ? Expression.Equal : Equal, left.Expression, right.Expression, isNumerical ), + FilterTokenType.NotEquals => CompareConvert( isNumerical ? Expression.NotEqual : NotEqual, left.Expression, right.Expression, isNumerical ), + + // Assume/force numerical + FilterTokenType.GreaterThan => CompareConvert( Expression.GreaterThan, left.Expression, right.Expression ), + FilterTokenType.GreaterThanOrEqual => CompareConvert( Expression.GreaterThanOrEqual, left.Expression, right.Expression ), + FilterTokenType.LessThan => CompareConvert( Expression.LessThan, left.Expression, right.Expression ), + FilterTokenType.LessThanOrEqual => CompareConvert( Expression.LessThanOrEqual, left.Expression, right.Expression ), + + FilterTokenType.And => Expression.AndAlso( left.Expression!, right.Expression ), + FilterTokenType.Or => Expression.OrElse( left.Expression!, right.Expression ), + + FilterTokenType.Not => Expression.Not( right.Expression ), + _ => left.Expression + }; + + //TODO: Invalid compares should be false, but is this the best way? + left.Expression = left.Expression == null + ? left.Expression + : Expression.TryCatchFinally( left.Expression, null, [Expression.Catch( typeof( Exception ), Expression.Constant( false ) )] ); + + left.Type = right.Type; + + return; + + // Use Equal Method vs equal operator + static Expression Equal( Expression l, Expression r ) => Expression.Call( ObjectEquals, l, r ); + static Expression NotEqual( Expression l, Expression r ) => Expression.Not( Equal( l, r ) ); + } + + private static Expression CompareConvert( Func compare, Expression left, Expression right, bool isNumerical = true ) + { + // TODO: clean up... I don't like that most of the time the type is an object because it's being boxed to support num/string/ etc + + // force numerical check for <, >, =<, => + if ( isNumerical && left.Type == typeof( object ) && right.Type == typeof( object ) ) + return compare( Expression.Convert( left, typeof( float ) ), Expression.Convert( right, typeof( float ) ) ); + + if ( left.Type == typeof( float ) && right.Type == typeof( object ) ) + return compare( left, Expression.Convert( right, typeof( float ) ) ); + if ( left.Type == typeof( object ) && right.Type == typeof( float ) ) + return compare( Expression.Convert( left, typeof( float ) ), right ); + if ( left.Type == typeof( int ) && right.Type == typeof( object ) ) + return compare( left, Expression.Convert( right, typeof( float ) ) ); + if ( left.Type == typeof( object ) && right.Type == typeof( int ) ) + return compare( Expression.Convert( left, typeof( float ) ), right ); + if ( left.Type == typeof( int ) && right.Type == typeof( int ) ) + return compare( Expression.Convert( left, typeof( float ) ), Expression.Convert( right, typeof( float ) ) ); + if ( left.Type == typeof( object ) && right.Type == typeof( string ) ) + return compare( Expression.Convert( left, typeof( string ) ), right ); + if ( left.Type == typeof( string ) && right.Type == typeof( object ) ) + return compare( left, Expression.Convert( right, typeof( string ) ) ); + + return compare( left, right ); + } + + private static bool IsNumerical( Type type ) + { + return type == typeof( int ) || type == typeof( float ); + } + + internal enum FilterTokenType + { + OpenParen, + ClosedParen, + + Not, + + Equals, + NotEquals, + + LessThan, + LessThanOrEqual, + GreaterThan, + GreaterThanOrEqual, + + Or, + And + } + + internal class FilterToken( Expression expression, FilterTokenType type ) + { + public Expression Expression { get; set; } = expression; + public FilterTokenType Type { get; set; } = type; + } +} diff --git a/src/Hyperbee.Json/Filters/Parser/LiteralFunction.cs b/src/Hyperbee.Json/Filters/Parser/LiteralFunction.cs new file mode 100644 index 00000000..80ba2cd3 --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/LiteralFunction.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; + +namespace Hyperbee.Json.Filters.Parser; + + +public class LiteralFunction : FilterFunction +{ + protected override Expression GetExpressionImpl( ReadOnlySpan data, ReadOnlySpan item, ref int start, ref int from ) + { + // strings double or single + if ( FilterTokenizerRegex.RegexQuotedDouble().IsMatch( item ) ) + return Expression.Constant( TrimQuotes( item ).ToString() ); + if ( FilterTokenizerRegex.RegexQuoted().IsMatch( item ) ) + return Expression.Constant( TrimQuotes( item ).ToString() ); + + // known literals (true, false, null) + if ( item.Equals( KnownLiterals.TrueSpan, StringComparison.OrdinalIgnoreCase ) ) + return Expression.Constant( true ); + if ( item.Equals( KnownLiterals.FalseSpan, StringComparison.OrdinalIgnoreCase ) ) + return Expression.Constant( false ); + if ( item.Equals( KnownLiterals.NullSpan, StringComparison.OrdinalIgnoreCase ) ) + return Expression.Constant( null ); + + // numbers + // TODO: Currently assuming all numbers are floats since we don't know what's in the data or the other side of the operator yet. + return Expression.Constant( float.Parse( item ) ); + + static ReadOnlySpan TrimQuotes( ReadOnlySpan input ) + { + if ( input.Length < 2 ) + return input; + + if ( input[0] == '\'' && input[^1] == '\'' || input[0] == '\"' && input[^1] == '\"' ) + return input[1..^1]; + + return input; + } + } + + internal ref struct KnownLiterals + { + internal static ReadOnlySpan TrueSpan => "true".AsSpan(); + internal static ReadOnlySpan FalseSpan => "false".AsSpan(); + internal static ReadOnlySpan NullSpan => "null".AsSpan(); + } +} diff --git a/src/Hyperbee.Json/Filters/Parser/ParenFunction.cs b/src/Hyperbee.Json/Filters/Parser/ParenFunction.cs new file mode 100644 index 00000000..dc261e35 --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/ParenFunction.cs @@ -0,0 +1,11 @@ +using System.Linq.Expressions; + +namespace Hyperbee.Json.Filters.Parser; + +internal class ParenFunction( ParseExpressionContext context ) : FilterFunction +{ + protected override Expression GetExpressionImpl( ReadOnlySpan data, ReadOnlySpan item, ref int start, ref int from ) + { + return JsonPathExpression.Parse( data, ref start, ref from, JsonPathExpression.EndArg, context ); + } +} diff --git a/src/Hyperbee.Json/Filters/Parser/ParseExpressionContext.cs b/src/Hyperbee.Json/Filters/Parser/ParseExpressionContext.cs new file mode 100644 index 00000000..de743c6e --- /dev/null +++ b/src/Hyperbee.Json/Filters/Parser/ParseExpressionContext.cs @@ -0,0 +1,10 @@ +using System.Linq.Expressions; +using Hyperbee.Json.Descriptors; + +namespace Hyperbee.Json.Filters.Parser; + +public record ParseExpressionContext( + Expression Current, + Expression Root, + IJsonTypeDescriptor Descriptor +); diff --git a/src/Hyperbee.Json/Hyperbee.Json.csproj b/src/Hyperbee.Json/Hyperbee.Json.csproj index 7f6a3a31..7859620d 100644 --- a/src/Hyperbee.Json/Hyperbee.Json.csproj +++ b/src/Hyperbee.Json/Hyperbee.Json.csproj @@ -43,6 +43,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + \ No newline at end of file diff --git a/src/Hyperbee.Json/JsonElementEqualityComparer.cs b/src/Hyperbee.Json/JsonElementDeepEqualsComparer.cs similarity index 95% rename from src/Hyperbee.Json/JsonElementEqualityComparer.cs rename to src/Hyperbee.Json/JsonElementDeepEqualsComparer.cs index 5cc06b27..a2a923f1 100644 --- a/src/Hyperbee.Json/JsonElementEqualityComparer.cs +++ b/src/Hyperbee.Json/JsonElementDeepEqualsComparer.cs @@ -16,7 +16,7 @@ namespace Hyperbee.Json; // example 1: // -// var comparer = new JsonElementEqualityComparer(); +// var comparer = new JsonElementDeepEqualsComparer(); // using var doc1 = JsonDocument.Parse( referenceJson ); // using var doc2 = JsonDocument.Parse( resultJson ); // @@ -33,13 +33,13 @@ namespace Hyperbee.Json; // // var result = JsonHelper.Compare( referenceJson, resultJson ); -public class JsonElementEqualityComparer : IEqualityComparer +public class JsonElementDeepEqualsComparer : IEqualityComparer { - public JsonElementEqualityComparer() + public JsonElementDeepEqualsComparer() { } - public JsonElementEqualityComparer( int maxHashDepth ) => MaxHashDepth = maxHashDepth; + public JsonElementDeepEqualsComparer( int maxHashDepth ) => MaxHashDepth = maxHashDepth; private int MaxHashDepth { get; } diff --git a/src/Hyperbee.Json/JsonElementInternal.cs b/src/Hyperbee.Json/JsonElementInternal.cs new file mode 100644 index 00000000..7431fbd9 --- /dev/null +++ b/src/Hyperbee.Json/JsonElementInternal.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using System.Reflection.Emit; +using System.Text.Json; + +namespace Hyperbee.Json; + +internal static class JsonElementInternal +{ + internal static readonly Func GetIdx; + internal static readonly Func GetParent; + + static JsonElementInternal() + { + // Create DynamicMethod for _idx field + + var idxField = typeof( JsonElement ).GetField( "_idx", BindingFlags.NonPublic | BindingFlags.Instance ); + + if ( idxField == null ) + throw new MissingFieldException( nameof( JsonElement ), "_idx" ); + + var getIdxDynamicMethod = new DynamicMethod( nameof( GetIdx ), typeof( int ), [typeof( JsonElement )], typeof( JsonElement ) ); + var ilIdx = getIdxDynamicMethod.GetILGenerator(); + ilIdx.Emit( OpCodes.Ldarg_0 ); + ilIdx.Emit( OpCodes.Ldfld, idxField ); + ilIdx.Emit( OpCodes.Ret ); + + GetIdx = (Func) getIdxDynamicMethod.CreateDelegate( typeof( Func ) ); + + // Create DynamicMethod for _parent field + + var parentField = typeof( JsonElement ).GetField( "_parent", BindingFlags.NonPublic | BindingFlags.Instance ); + + if ( parentField == null ) + throw new MissingFieldException( nameof( JsonElement ), "_parent" ); + + var getParentDynamicMethod = new DynamicMethod( nameof( GetParent ), typeof( JsonDocument ), [typeof( JsonElement )], typeof( JsonElement ) ); + var ilParent = getParentDynamicMethod.GetILGenerator(); + ilParent.Emit( OpCodes.Ldarg_0 ); + ilParent.Emit( OpCodes.Ldfld, parentField ); + ilParent.Emit( OpCodes.Ret ); + + GetParent = (Func) getParentDynamicMethod.CreateDelegate( typeof( Func ) ); + } +} diff --git a/src/Hyperbee.Json/JsonElementPositionComparer.cs b/src/Hyperbee.Json/JsonElementPositionComparer.cs new file mode 100644 index 00000000..13defe34 --- /dev/null +++ b/src/Hyperbee.Json/JsonElementPositionComparer.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace Hyperbee.Json; + +internal class JsonElementPositionComparer : IEqualityComparer +{ + public bool Equals( JsonElement x, JsonElement y ) + { + // check for quick out + + if ( x.ValueKind != y.ValueKind ) + return false; + + // We want a fast comparer that will tell us if two JsonElements point to the same exact + // backing data in the parent JsonDocument. JsonElement is a struct, and a value comparison + // for equality won't give us reliable results and would be expensive. + // + // The internal JsonElement constructor takes parent and idx arguments that are saves as fields. + // + // idx: is an index used to get the position of the JsonElement in the backing data. + // parent: is the owning JsonDocument (could be null in an enumeration). + // + // These arguments are stored in private fields and are not exposed. While note ideal, we + // will access these fields through dynamic methods to use for our comparison. + + // check parent documents + + // BF: JsonElement ctor notes that parent may be null in some enumeration conditions. + // This check may not be reliable. If so, should be ok to remove the parent check. + + var xParent = JsonElementInternal.GetParent( x ); + var yParent = JsonElementInternal.GetParent( y ); + + if ( !ReferenceEquals( xParent, yParent ) ) + return false; + + // check idx values + + return JsonElementInternal.GetIdx( x ) == JsonElementInternal.GetIdx( y ); + } + + public int GetHashCode( JsonElement obj ) + { + var parent = JsonElementInternal.GetParent( obj ); + var idx = JsonElementInternal.GetIdx( obj ); + + return HashCode.Combine( parent, idx ); + } +} diff --git a/src/Hyperbee.Json/JsonPath.cs b/src/Hyperbee.Json/JsonPath.cs index 7eb47ff2..251da891 100644 --- a/src/Hyperbee.Json/JsonPath.cs +++ b/src/Hyperbee.Json/JsonPath.cs @@ -1,4 +1,5 @@ #region License + // C# Implementation of JSONPath[1] // // [1] http://goessner.net/articles/JsonPath/ @@ -28,125 +29,93 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. // + #endregion -using System.Collections.Immutable; using System.Globalization; -using System.Text.Json; -using System.Text.RegularExpressions; -using Hyperbee.Json.Evaluators; -using Hyperbee.Json.Extensions; +using Hyperbee.Json.Descriptors; using Hyperbee.Json.Memory; -using Hyperbee.Json.Tokenizer; namespace Hyperbee.Json; + // https://ietf-wg-jsonpath.github.io/draft-ietf-jsonpath-base/draft-ietf-jsonpath-base.html // https://github.com/ietf-wg-jsonpath/draft-ietf-jsonpath-base -public sealed partial class JsonPath +public static class JsonPath { - public static IJsonPathScriptEvaluator DefaultEvaluator { get; set; } = new JsonPathCSharpElementEvaluator(); - private readonly IJsonPathScriptEvaluator _evaluator; - - // generated regex - - [GeneratedRegex( "^(-?[0-9]*):?(-?[0-9]*):?(-?[0-9]*)$" )] - private static partial Regex RegexSlice(); - - [GeneratedRegex( @"^\?\((.*?)\)$" )] - private static partial Regex RegexPathFilter(); - - [GeneratedRegex( @"^[0-9*]+$" )] - private static partial Regex RegexNumber(); - - // property names can be simple (@.property) if they contain no SpecialCharacters, - // otherwise they require bracket notation (@['property']). + private static readonly ITypeDescriptor Descriptor = JsonTypeDescriptorRegistry.GetDescriptor(); - private static readonly char[] SpecialCharacters = ['.', ' ', '\'', '/', '"', '[', ']', '(', ')', '\t', '\n', '\r', '\f', '\b', '\\', '\u0085', '\u2028', '\u2029']; - private static string GetPath( string prefix, int childKey ) => $"{prefix}[{childKey}]"; - - private static string GetPath( string prefix, string childKey, JsonValueKind tokenKind ) + public static IEnumerable Select( in TNode value, string query ) { - if ( tokenKind == JsonValueKind.Array ) - return $"{prefix}[{childKey}]"; - - return childKey.IndexOfAny( SpecialCharacters ) == -1 ? $"{prefix}.{childKey}" : $@"{prefix}['{childKey}']"; + return EnumerateMatches( value, value, query ); } - // ctor - - public JsonPath() - : this( null ) + internal static IEnumerable Select( in TNode value, TNode root, string query ) { + return EnumerateMatches( value, root, query ); } - public JsonPath( IJsonPathScriptEvaluator evaluator ) + private static IEnumerable EnumerateMatches( in TNode value, in TNode root, string query ) { - _evaluator = evaluator ?? DefaultEvaluator ?? new JsonPathCSharpElementEvaluator(); - } - - public IEnumerable Select( in JsonElement value, string query ) - { - return SelectPath( value, query ).Select( x => x.Value ); - } - - public IEnumerable SelectPath( in JsonElement value, string query ) - { - if ( string.IsNullOrWhiteSpace( query ) ) - throw new ArgumentNullException( nameof( query ) ); + ArgumentException.ThrowIfNullOrWhiteSpace( query ); // quick out - if ( query == "$" ) - return new[] { new JsonPathElement( value, query ) }; + if ( query == "$" || query == "@" ) + return [value]; // tokenize - var tokens = JsonPathQueryTokenizer.Tokenize( query ); + var segments = JsonPathQueryTokenizer.Tokenize( query ); - // initiate the expression walk + if ( !segments.IsEmpty ) + { + var selector = segments.Selectors[0].Value; // first selector in segment - if ( !tokens.IsEmpty && tokens.Peek().Selectors.First().Value == "$" ) - tokens = tokens.Pop(); + if ( selector == "$" || selector == "@" ) + segments = segments.Next; + } - return ExpressionVisitor( new VisitorArgs( value, tokens, "$" ), _evaluator.Evaluator ); + return EnumerateMatches( root, new NodeArgs( value, segments ) ); } - private static IEnumerable ExpressionVisitor( VisitorArgs args, JsonPathEvaluator evaluator ) + private static IEnumerable EnumerateMatches( TNode root, NodeArgs args ) { - var nodes = new Stack( 4 ); - void PushNode( in JsonElement v, in IImmutableStack t, string p ) => nodes.Push( new VisitorArgs( v, t, p ) ); + var stack = new Stack( 16 ); + + var (accessor, filterEvaluator) = Descriptor; do { // deconstruct the next args node - var (current, tokens, path) = args; + var (current, segments) = args; - if ( tokens.IsEmpty ) + if ( segments.IsEmpty ) { - if ( !string.IsNullOrEmpty( path ) ) - yield return new JsonPathElement( current, path ); - + yield return current; continue; } - // pop the next token from the stack + // get the current segment, and then move the segments + // reference to the next segment in the list + + var segment = segments; // get current segment + var (selector, _) = segment.Selectors[0]; // first selector in segment; - tokens = tokens.Pop( out var token ); - var selector = token.Selectors.First().Value; + segments = segments.Next; - // make sure we have a container value + // make sure we have a complex value - if ( !current.IsObjectOrArray() ) + if ( !accessor.IsObjectOrArray( current ) ) throw new InvalidOperationException( "Object or Array expected." ); // try to access object or array using KEY value - if ( token.Singular ) + if ( segment.Singular ) { - if ( TryGetChildValue( current, selector, out var childValue ) ) - PushNode( childValue, tokens, GetPath( path, selector, current.ValueKind ) ); + if ( accessor.TryGetChildValue( current, selector, out var childValue ) ) + Push( stack, childValue, segments ); continue; } @@ -155,62 +124,58 @@ private static IEnumerable ExpressionVisitor( VisitorArgs args, if ( selector == "*" ) { - foreach ( var childKey in EnumerateKeys( current ) ) - PushNode( current, tokens.Push( childKey, SelectorKind.UnspecifiedSingular ), path ); + foreach ( var (_, childKey) in accessor.EnumerateChildren( current ) ) + { + Push( stack, current, segments.Insert( childKey, SelectorKind.UnspecifiedSingular ) ); // (Dot | Index) + } + continue; } - // descendant + // descendant if ( selector == ".." ) { - foreach ( var childKey in EnumerateKeys( current ) ) + foreach ( var (childValue, _) in accessor.EnumerateChildren( current, includeValues: false ) ) // child arrays or objects only { - if ( !TryGetChildValue( current, childKey, out var childValue ) ) - continue; - - if ( childValue.IsObjectOrArray() ) - PushNode( childValue, tokens.Push( "..", SelectorKind.UnspecifiedGroup ), GetPath( path, childKey, current.ValueKind ) ); + Push( stack, childValue, segments.Insert( "..", SelectorKind.UnspecifiedGroup ) ); // Descendant } - PushNode( current, tokens, path ); + Push( stack, current, segments ); continue; } // union - foreach ( var childSelector in token.Selectors.Select( x => x.Value ) ) + for ( var i = 0; i < segment.Selectors.Length; i++ ) // using 'for' for performance { + var childSelector = segment.Selectors[i].Value; + // [(exp)] if ( childSelector.Length > 2 && childSelector[0] == '(' && childSelector[^1] == ')' ) { - if ( evaluator( childSelector, current, path[(path.LastIndexOf( ';' ) + 1)..] ) is not string evalSelector ) + if ( filterEvaluator.Evaluate( childSelector, current, root ) is not string filterSelector ) continue; - var selectorKind = evalSelector != "*" && evalSelector != ".." && !RegexSlice().IsMatch( evalSelector ) + var filterSelectorKind = filterSelector != "*" && filterSelector != ".." && !JsonPathRegex.RegexSlice().IsMatch( filterSelector ) // (Dot | Index) | Wildcard, Descendant, Slice ? SelectorKind.UnspecifiedSingular : SelectorKind.UnspecifiedGroup; - PushNode( current, tokens.Push( evalSelector, selectorKind ), path ); + Push( stack, current, segments.Insert( filterSelector, filterSelectorKind ) ); continue; } - // [?(exp)] + // [?exp] - if ( childSelector.Length > 3 && childSelector[0] == '?' && childSelector[1] == '(' && childSelector[^1] == ')' ) + if ( childSelector[0] == '?' ) { - foreach ( var childKey in EnumerateKeys( current ) ) + foreach ( var (childValue, childKey) in accessor.EnumerateChildren( current ) ) { - if ( !TryGetChildValue( current, childKey, out var childValue ) ) - continue; - - var childContext = GetPath( path, childKey, current.ValueKind ); - var filter = evaluator( RegexPathFilter().Replace( childSelector, "$1" ), childValue, childContext ); + var filterValue = filterEvaluator.Evaluate( JsonPathRegex.RegexPathFilter().Replace( childSelector, "$1" ), childValue, root ); - // treat the filter result as truthy if the evaluator returned a non-convertible object instance. - if ( filter is not null and not IConvertible || Convert.ToBoolean( filter, CultureInfo.InvariantCulture ) ) - PushNode( current, tokens.Push( childKey, SelectorKind.UnspecifiedSingular ), path ); + if ( Truthy( filterValue ) ) + Push( stack, current, segments.Insert( childKey, SelectorKind.UnspecifiedSingular ) ); // (Name | Index) } continue; @@ -218,152 +183,98 @@ private static IEnumerable ExpressionVisitor( VisitorArgs args, // [name1,name2,...] or [#,#,...] or [start:end:step] - if ( current.ValueKind == JsonValueKind.Array ) + if ( accessor.IsArray( current, out var length ) ) { - if ( RegexNumber().IsMatch( childSelector ) ) + if ( JsonPathRegex.RegexNumber().IsMatch( childSelector ) ) { // [#,#,...] - PushNode( current[int.Parse( childSelector )], tokens, GetPath( path, childSelector, JsonValueKind.Array ) ); + Push( stack, accessor.GetElementAt( current, int.Parse( childSelector ) ), segments ); continue; } // [start:end:step] Python slice syntax - if ( RegexSlice().IsMatch( childSelector ) ) + if ( JsonPathRegex.RegexSlice().IsMatch( childSelector ) ) { foreach ( var index in EnumerateSlice( current, childSelector ) ) - PushNode( current[index], tokens, GetPath( path, index ) ); + Push( stack, accessor.GetElementAt( current, index ), segments ); continue; } // [name1,name2,...] - foreach ( var index in EnumerateArrayIndicies( current ) ) - PushNode( current[index], tokens.Push( childSelector, SelectorKind.UnspecifiedSingular ), GetPath( path, index ) ); + foreach ( var index in EnumerateArrayIndices( length ) ) + Push( stack, accessor.GetElementAt( current, index ), segments.Insert( childSelector, SelectorKind.UnspecifiedSingular ) ); // Name continue; } // [name1,name2,...] - if ( current.ValueKind == JsonValueKind.Object ) + if ( accessor.IsObject( current ) ) { - if ( RegexSlice().IsMatch( childSelector ) || RegexNumber().IsMatch( childSelector ) ) + if ( JsonPathRegex.RegexSlice().IsMatch( childSelector ) || JsonPathRegex.RegexNumber().IsMatch( childSelector ) ) continue; // [name1,name2,...] - if ( TryGetChildValue( current, childSelector, out var childValue ) ) - PushNode( childValue, tokens, GetPath( path, childSelector, JsonValueKind.Object ) ); + if ( accessor.TryGetChildValue( current, childSelector, out var childValue ) ) + Push( stack, childValue, segments ); } } - } while ( nodes.TryPop( out args ) ); - } + } while ( stack.TryPop( out args ) ); - // because we are using stack processing we will enumerate object members and array - // indicies in reverse order. this preserves the logical (left-to-right, top-down) - // order of the match results that are returned to the user. + yield break; - private static IEnumerable EnumerateKeys( JsonElement value ) - { - return value.ValueKind switch - { - JsonValueKind.Array => EnumerateArrayIndicies( value ).Select( x => x.ToString() ), - JsonValueKind.Object => EnumeratePropertyNames( value ), - _ => throw new NotSupportedException() - }; + static void Push( Stack n, in TNode v, in JsonPathSegment s ) => n.Push( new NodeArgs( v, s ) ); } - private static IEnumerable EnumerateArrayIndicies( JsonElement value ) + private static bool Truthy( object value ) { - for ( var index = value.GetArrayLength() - 1; index >= 0; index-- ) - yield return index; + return value is not null and not IConvertible || Convert.ToBoolean( value, CultureInfo.InvariantCulture ); } - private static IEnumerable EnumeratePropertyNames( JsonElement value ) + private static IEnumerable EnumerateArrayIndices( int length ) { - // Select() before the Reverse() to reduce size of allocation - return value.EnumerateObject().Select( x => x.Name ).Reverse(); + for ( var index = length - 1; index >= 0; index-- ) + yield return index; } - private static IEnumerable EnumerateSlice( JsonElement value, string sliceExpr ) + private static IEnumerable EnumerateSlice( TNode value, string sliceExpr ) { - if ( value.ValueKind != JsonValueKind.Array ) + if ( !Descriptor.Accessor.IsArray( value, out var length ) ) yield break; - var (lower, upper, step) = SliceSyntaxHelper.ParseExpression( sliceExpr, value.GetArrayLength(), reverse: true ); + var (lower, upper, step) = SliceSyntaxHelper.ParseExpression( sliceExpr, length, reverse: true ); switch ( step ) { case 0: - yield break; - + { + yield break; + } case > 0: - for ( var index = lower; index < upper; index += step ) - yield return index; - - break; + { + for ( var index = lower; index < upper; index += step ) + yield return index; + break; + } case < 0: - for ( var index = upper; index > lower; index += step ) - yield return index; - - break; - } - } - - private static bool TryGetChildValue( in JsonElement value, ReadOnlySpan childKey, out JsonElement childValue ) - { - static int? TryParseInt( ReadOnlySpan numberString ) - { - return numberString == null ? null : int.TryParse( numberString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n ) ? n : null; - } - - static bool IsPathOperator( ReadOnlySpan x ) => x == "*" || x == ".." || x == "$"; - - switch ( value.ValueKind ) - { - case JsonValueKind.Object: - if ( value.TryGetProperty( childKey, out childValue ) ) - return true; - break; - - case JsonValueKind.Array: - var index = TryParseInt( childKey ) ?? -1; - - if ( index >= 0 && index < value.GetArrayLength() ) { - childValue = value[index]; - return true; + for ( var index = upper; index > lower; index += step ) + yield return index; + break; } - - break; - - default: - if ( !IsPathOperator( childKey ) ) - throw new ArgumentException( $"Invalid child type '{childKey.ToString()}'. Expected child to be Object, Array or a path selector.", nameof( value ) ); - break; } - - childValue = default; - return false; } - private sealed class VisitorArgs + private sealed class NodeArgs( in TNode value, in JsonPathSegment segment ) { - public readonly JsonElement Value; - public readonly IImmutableStack Tokens; - public readonly string Path; - - public VisitorArgs( in JsonElement value, in IImmutableStack tokens, string path ) - { - Tokens = tokens; - Value = value; - Path = path; - } + public readonly TNode Value = value; + public readonly JsonPathSegment Segment = segment; - public void Deconstruct( out JsonElement value, out IImmutableStack tokens, out string path ) + public void Deconstruct( out TNode value, out JsonPathSegment segment ) { value = Value; - tokens = Tokens; - path = Path; + segment = Segment; } } diff --git a/src/Hyperbee.Json/JsonPathBuilder.cs b/src/Hyperbee.Json/JsonPathBuilder.cs new file mode 100644 index 00000000..192a2939 --- /dev/null +++ b/src/Hyperbee.Json/JsonPathBuilder.cs @@ -0,0 +1,99 @@ +using System.Text.Json; + +namespace Hyperbee.Json; + +public class JsonPathBuilder +{ + private readonly JsonElement _rootElement; + private readonly JsonElementPositionComparer _comparer = new(); + private readonly Dictionary _parentMap = []; + + public JsonPathBuilder( JsonDocument rootDocument ) + : this( rootDocument.RootElement ) + { + } + + public JsonPathBuilder( JsonElement rootElement ) + { + _rootElement = rootElement; + + // avoid allocating full paths for every node by building + // a dictionary of (parentId, segment) pairs. + + _parentMap[GetUniqueId( _rootElement )] = (-1, "$"); // seed parent map with root + } + + public string GetPath( in JsonElement targetElement ) + { + // quick out + + var targetId = GetUniqueId( targetElement ); + + if ( _parentMap.ContainsKey( targetId ) ) + return BuildPath( targetId, _parentMap ); + + // take a walk + + var stack = new Stack( [_rootElement] ); + + while ( stack.Count > 0 ) + { + var currentElement = stack.Pop(); + var elementId = GetUniqueId( currentElement ); + + if ( _comparer.Equals( currentElement, targetElement ) ) + return BuildPath( elementId, _parentMap ); + + switch ( currentElement.ValueKind ) + { + case JsonValueKind.Object: + foreach ( var property in currentElement.EnumerateObject() ) + { + var childElementId = GetUniqueId( property.Value ); + + if ( !_parentMap.ContainsKey( childElementId ) ) + _parentMap[childElementId] = (elementId, $".{property.Name}"); + + stack.Push( property.Value ); + } + break; + + case JsonValueKind.Array: + var arrayIdx = 0; + foreach ( var element in currentElement.EnumerateArray() ) + { + var childElementId = GetUniqueId( element ); + + if ( !_parentMap.ContainsKey( childElementId ) ) + _parentMap[childElementId] = (elementId, $"[{arrayIdx}]"); + + stack.Push( element ); + arrayIdx++; + } + break; + } + } + + return null; // target not found + } + + private static int GetUniqueId( in JsonElement element ) + { + return JsonElementInternal.GetIdx( element ); + } + + private static string BuildPath( in int elementId, Dictionary parentMap ) + { + var pathSegments = new Stack(); + var currentId = elementId; + + while ( currentId != -1 ) + { + var (parentId, segment) = parentMap[currentId]; + pathSegments.Push( segment ); + currentId = parentId; + } + + return string.Join( string.Empty, pathSegments ); + } +} diff --git a/src/Hyperbee.Json/JsonPathElement.cs b/src/Hyperbee.Json/JsonPathElement.cs deleted file mode 100644 index ec0de83c..00000000 --- a/src/Hyperbee.Json/JsonPathElement.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; - -namespace Hyperbee.Json; - -public readonly struct JsonPathElement -{ - public JsonElement Value { get; } - - public string Path { get; } - - public ReadOnlySpan Name => GetName( Path ); - - public JsonPathElement( JsonElement value, string path ) - { - Value = value; - Path = path; - } - - public static implicit operator JsonElement( JsonPathElement pathElement ) => pathElement.Value; - - private static ReadOnlySpan GetName( ReadOnlySpan path ) - { - var index = path.LastIndexOf( '\'' ); - - var count = 0; - while ( --index > 0 ) - { - if ( path[index] == '\'' ) - { - if ( index == 0 || path[index - 1] != '\\' ) // make sure this isn't escaped \' - return path.Slice( index + 1, count ); - } - - count++; - } - - return []; - } -} diff --git a/src/Hyperbee.Json/Tokenizer/JsonPathQueryTokenizer.cs b/src/Hyperbee.Json/JsonPathQueryTokenizer.cs similarity index 84% rename from src/Hyperbee.Json/Tokenizer/JsonPathQueryTokenizer.cs rename to src/Hyperbee.Json/JsonPathQueryTokenizer.cs index f0e5ae2a..e6cf8b91 100644 --- a/src/Hyperbee.Json/Tokenizer/JsonPathQueryTokenizer.cs +++ b/src/Hyperbee.Json/JsonPathQueryTokenizer.cs @@ -1,51 +1,56 @@ using System.Collections.Concurrent; -using System.Collections.Immutable; using System.Text.RegularExpressions; -namespace Hyperbee.Json.Tokenizer; +namespace Hyperbee.Json; // https://ietf-wg-jsonpath.github.io/draft-ietf-jsonpath-base/draft-ietf-jsonpath-base.html // https://github.com/ietf-wg-jsonpath/draft-ietf-jsonpath-base +[Flags] internal enum SelectorKind { - Undefined, + Undefined = 0x0, + + // subtype + Singular = 0x1, + Group = 0x2, // dot notation - Root, - Dot, + Root = 0x4 | Singular, + Dot = 0x8 | Singular, // union notation - Name, - Slice, - Filter, - Index, + Name = 0x10 | Singular, + Slice = 0x20 | Group, + Filter = 0x40 | Group, + Index = 0x80 | Singular, // - Wildcard, - Descendant, + Wildcard = 0x100 | Group, + Descendant = 0x200 | Group, // internal reserved for runtime processing - UnspecifiedSingular, // singular selector (root, name or index) - UnspecifiedGroup // non-singular selector + Unspecified = 0x400, + UnspecifiedSingular = Unspecified | Singular, // singular selector (root, name or index) + UnspecifiedGroup = Unspecified | Group // non-singular selector } public static partial class JsonPathQueryTokenizer { - private static readonly ConcurrentDictionary> JsonPathTokens = new(); + private static readonly ConcurrentDictionary JsonPathTokens = new(); - [GeneratedRegex( @"^(-?[0-9]*):?(-?[0-9]*):?(-?[0-9]*)$" )] + [GeneratedRegex( @"^(-?[0-9]*):?(-?[0-9]*):?(-?[0-9]*)$", RegexOptions.ExplicitCapture )] private static partial Regex RegexSlice(); - [GeneratedRegex( @"^\??\((.*?)\)$" )] + [GeneratedRegex( @"^\?\(?(.*?)\)?$", RegexOptions.ExplicitCapture )] private static partial Regex RegexFilter(); [GeneratedRegex( @"^[0-9*]+$" )] private static partial Regex RegexNumber(); - [GeneratedRegex( @"^""(?:[^""\\]|\\.)*""$" )] + [GeneratedRegex( @"^""(?:[^""\\]|\\.)*""$", RegexOptions.ExplicitCapture )] private static partial Regex RegexQuotedDouble(); - [GeneratedRegex( @"^'(?:[^'\\]|\\.)*'$" )] + [GeneratedRegex( @"^'(?:[^'\\]|\\.)*'$", RegexOptions.ExplicitCapture )] private static partial Regex RegexQuoted(); private enum Scanner @@ -68,7 +73,7 @@ private static string GetSelector( Scanner scanner, ReadOnlySpan buffer, i return length <= 0 ? null : buffer.Slice( start, length ).Trim().ToString(); } - private static void InsertToken( ICollection tokens, SelectorDescriptor selector ) + private static void InsertToken( ICollection tokens, SelectorDescriptor selector ) { if ( selector?.Value == null ) return; @@ -76,29 +81,29 @@ private static void InsertToken( ICollection tokens, SelectorDesc InsertToken( tokens, [selector] ); } - private static void InsertToken( ICollection tokens, SelectorDescriptor[] selectors ) + private static void InsertToken( ICollection tokens, SelectorDescriptor[] selectors ) { if ( selectors == null || selectors.Length == 0 ) return; - tokens.Add( new JsonPathToken( selectors ) ); + tokens.Add( new JsonPathSegment( selectors ) ); } - internal static IImmutableStack Tokenize( string query ) + internal static JsonPathSegment Tokenize( string query ) { return JsonPathTokens.GetOrAdd( query, x => TokenFactory( x.AsSpan() ) ); } - internal static IImmutableStack TokenizeNoCache( ReadOnlySpan query ) + internal static JsonPathSegment TokenizeNoCache( ReadOnlySpan query ) { return TokenFactory( query ); } - private static IImmutableStack TokenFactory( ReadOnlySpan query ) + private static JsonPathSegment TokenFactory( ReadOnlySpan query ) { // transform jsonpath patterns like "$.store.book[*]..author" to an array of tokens [ $, store, book, *, .., author ] - var tokens = new List(); + var tokens = new List(); var i = 0; var n = query.Length; @@ -106,6 +111,7 @@ private static IImmutableStack TokenFactory( ReadOnlySpan q var selectorStart = 0; var bracketDepth = 0; + var parenDepth = 0; var literalDelimiter = '\''; var selectors = new List(); @@ -126,6 +132,7 @@ private static IImmutableStack TokenFactory( ReadOnlySpan q case ' ': case '\t': break; + case '@': // Technically invalid, but allows `@` to work on sub queries without changing tokenizer case '$': if ( i < n && query[i] != '.' && query[i] != '[' ) throw new NotSupportedException( "Invalid character after `$`." ); @@ -277,10 +284,18 @@ private static IImmutableStack TokenFactory( ReadOnlySpan q case '[': // handle nested `[` (not called for first bracket) bracketDepth++; break; + case '(': // handle nested `(` (not called for first bracket) + parenDepth++; + break; + case ')': + parenDepth--; + break; case ',': case ']': if ( c == ']' && --bracketDepth > 0 ) // handle nested `]` break; + if ( parenDepth > 0 ) + break; // get the child item atom @@ -292,7 +307,7 @@ private static IImmutableStack TokenFactory( ReadOnlySpan q if ( string.IsNullOrEmpty( selectorValue ) ) // [] is not valid throw new NotSupportedException( "Invalid bracket expression syntax. Bracket expression cannot be empty." ); - selectorKind = GetElementSelectorKind( selectorValue ); + selectorKind = GetSelectorKind( selectorValue ); if ( selectorKind == SelectorKind.Undefined ) throw new NotSupportedException( $"Invalid bracket expression syntax. Unrecognized selector format at pos {i - 1}." ); @@ -318,7 +333,7 @@ private static IImmutableStack TokenFactory( ReadOnlySpan q break; case ']': scanner = Scanner.DotChild; - InsertToken( tokens, selectors.ToArray() ); + InsertToken( tokens, [.. selectors] ); selectors.Clear(); break; } @@ -387,11 +402,19 @@ private static IImmutableStack TokenFactory( ReadOnlySpan q } ); } - // finished - return ImmutableStack.Create( ((IEnumerable) tokens).Reverse().ToArray() ); + // fixup nameof(Segment.Next) properties + + for ( var index = 0; index < tokens.Count; index++ ) + { + tokens[index].Next = index == tokens.Count - 1 + ? JsonPathSegment.Terminal + : tokens[index + 1]; + } + + return tokens.First(); } - private static SelectorKind GetElementSelectorKind( string selector ) + private static SelectorKind GetSelectorKind( string selector ) { if ( RegexFilter().IsMatch( selector ) ) return SelectorKind.Filter; diff --git a/src/Hyperbee.Json/JsonPathRegex.cs b/src/Hyperbee.Json/JsonPathRegex.cs new file mode 100644 index 00000000..8ed21716 --- /dev/null +++ b/src/Hyperbee.Json/JsonPathRegex.cs @@ -0,0 +1,17 @@ +using System.Text.RegularExpressions; + +namespace Hyperbee.Json; + +internal partial class JsonPathRegex +{ + // generated regex + + [GeneratedRegex( "^(-?[0-9]*):?(-?[0-9]*):?(-?[0-9]*)$" )] + internal static partial Regex RegexSlice(); + + [GeneratedRegex( @"^\?\(?(.*?)\)?$" )] + internal static partial Regex RegexPathFilter(); + + [GeneratedRegex( @"^[0-9*]+$" )] + internal static partial Regex RegexNumber(); +} diff --git a/src/Hyperbee.Json/JsonPathSegment.cs b/src/Hyperbee.Json/JsonPathSegment.cs new file mode 100644 index 00000000..b15ad5be --- /dev/null +++ b/src/Hyperbee.Json/JsonPathSegment.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; + +namespace Hyperbee.Json; + +[DebuggerDisplay( "{Value}, SelectorKind = {SelectorKind}" )] +internal record SelectorDescriptor +{ + public SelectorKind SelectorKind { get; init; } + public string Value { get; init; } + + public void Deconstruct( out string value, out SelectorKind selectorKind ) + { + value = Value; + selectorKind = SelectorKind; + } +} + +[DebuggerTypeProxy( typeof( SegmentDebugView ) )] +[DebuggerDisplay( "First = ({Selectors[0]}), Singular = {Singular}, Count = {Selectors.Length}" )] +internal class JsonPathSegment +{ + internal static readonly JsonPathSegment Terminal = new(); + + public bool IsEmpty => Next == null; + + public bool Singular { get; } // singular is true when the selector resolves to one and only one element + + public JsonPathSegment Next { get; set; } + public SelectorDescriptor[] Selectors { get; init; } + + private JsonPathSegment() { } + + public JsonPathSegment( JsonPathSegment next, string selector, SelectorKind kind ) + { + Next = next; + Selectors = + [ + new SelectorDescriptor { SelectorKind = kind, Value = selector } + ]; + Singular = IsSingular(); + } + + public JsonPathSegment( SelectorDescriptor[] selectors ) + { + Selectors = selectors; + Singular = IsSingular(); + } + + public JsonPathSegment Insert( string selector, SelectorKind kind ) => new( this, selector, kind ); + + public IEnumerable AsEnumerable() + { + var current = this; + + while ( current != Terminal ) + { + yield return current; + + current = current.Next; + } + } + + public void Deconstruct( out bool singular, out SelectorDescriptor[] selectors ) + { + singular = Singular; + selectors = Selectors; + } + + private bool IsSingular() + { + if ( Selectors.Length != 1 ) + return false; + + var selectorKind = Selectors[0].SelectorKind; + + return (selectorKind & SelectorKind.Singular) == SelectorKind.Singular; + } + + internal class SegmentDebugView( JsonPathSegment instance ) + { + [DebuggerBrowsable( DebuggerBrowsableState.RootHidden )] + public SelectorDescriptor[] Selectors => instance.Selectors; + + [DebuggerBrowsable( DebuggerBrowsableState.Collapsed )] + public JsonPathSegment Next => instance.Next; + } +} diff --git a/src/Hyperbee.Json/JsonTypeDescriptorRegistry.cs b/src/Hyperbee.Json/JsonTypeDescriptorRegistry.cs new file mode 100644 index 00000000..6d63ec50 --- /dev/null +++ b/src/Hyperbee.Json/JsonTypeDescriptorRegistry.cs @@ -0,0 +1,31 @@ +using Hyperbee.Json.Descriptors; +using Hyperbee.Json.Descriptors.Element; +using Hyperbee.Json.Descriptors.Node; + +namespace Hyperbee.Json; + +public class JsonTypeDescriptorRegistry +{ + private static readonly Dictionary Descriptors = []; + + static JsonTypeDescriptorRegistry() + { + Register( new ElementTypeDescriptor() ); + Register( new NodeTypeDescriptor() ); + } + + public static void Register( ITypeDescriptor descriptor ) + { + Descriptors[typeof( TNode )] = descriptor; + } + + public static ITypeDescriptor GetDescriptor() + { + if ( Descriptors.TryGetValue( typeof( TNode ), out var descriptor ) ) + { + return descriptor as ITypeDescriptor; + } + + throw new InvalidOperationException( $"No JSON descriptors registered for type {typeof( TNode )}." ); + } +} diff --git a/src/Hyperbee.Json/Nodes/JsonPath.cs b/src/Hyperbee.Json/Nodes/JsonPath.cs deleted file mode 100644 index 74af4170..00000000 --- a/src/Hyperbee.Json/Nodes/JsonPath.cs +++ /dev/null @@ -1,351 +0,0 @@ -#region License -// C# Implementation of JSONPath[1] -// -// [1] http://goessner.net/articles/JsonPath/ -// [2] https://github.com/atifaziz/JSONPath -// -// The MIT License -// -// Copyright (c) 2019 Brenton Farmer. All rights reserved. -// Portions Copyright (c) 2007 Atif Aziz. All rights reserved. -// Portions Copyright (c) 2007 Stefan Goessner (goessner.net) -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -// -#endregion - -using System.Collections.Immutable; -using System.Globalization; -using System.Text.Json.Nodes; -using System.Text.RegularExpressions; -using Hyperbee.Json.Evaluators; -using Hyperbee.Json.Extensions; -using Hyperbee.Json.Memory; -using Hyperbee.Json.Tokenizer; - -namespace Hyperbee.Json.Nodes; -// https://ietf-wg-jsonpath.github.io/draft-ietf-jsonpath-base/draft-ietf-jsonpath-base.html -// https://github.com/ietf-wg-jsonpath/draft-ietf-jsonpath-base - -public sealed partial class JsonPathNode -{ - public static IJsonPathScriptEvaluator DefaultEvaluator { get; set; } = new JsonPathCSharpNodeEvaluator(); - private readonly IJsonPathScriptEvaluator _evaluator; - - // generated regex - - [GeneratedRegex( "^(-?[0-9]*):?(-?[0-9]*):?(-?[0-9]*)$" )] - private static partial Regex RegexSlice(); - - [GeneratedRegex( @"^\?\((.*?)\)$" )] - private static partial Regex RegexPathFilter(); - - [GeneratedRegex( @"^[0-9*]+$" )] - private static partial Regex RegexNumber(); - - // ctor - - public JsonPathNode() - : this( null ) - { - } - - public JsonPathNode( IJsonPathScriptEvaluator evaluator ) - { - _evaluator = evaluator ?? DefaultEvaluator ?? new JsonPathCSharpNodeEvaluator(); - } - - public IEnumerable Select( in JsonNode value, string query ) - { - if ( string.IsNullOrWhiteSpace( query ) ) - throw new ArgumentNullException( nameof( query ) ); - - // quick out - - if ( query == "$" ) - return new[] { value }; - - // tokenize - - var tokens = JsonPathQueryTokenizer.Tokenize( query ); - - // initiate the expression walk - - if ( !tokens.IsEmpty && tokens.Peek().Selectors.First().Value == "$" ) - tokens = tokens.Pop(); - - return ExpressionVisitor( new VisitorArgs( value, tokens ), _evaluator.Evaluator ); - } - - private static IEnumerable ExpressionVisitor( VisitorArgs args, JsonPathEvaluator evaluator ) - { - var nodes = new Stack( 4 ); - void PushNode( in JsonNode v, in IImmutableStack t ) => nodes.Push( new VisitorArgs( v, t ) ); - - do - { - // deconstruct the next args node - - var (current, tokens) = args; - - if ( tokens.IsEmpty ) - { - yield return current; - continue; - } - - // pop the next token from the stack - - tokens = tokens.Pop( out var token ); - var selector = token.Selectors.First().Value; - - // make sure we have a container value - - if ( !(current is JsonObject || current is JsonArray) ) - throw new InvalidOperationException( "Object or Array expected." ); - - // try to access object or array using KEY value - - if ( token.Singular ) - { - if ( TryGetChildValue( current, selector, out var childValue ) ) - PushNode( childValue, tokens ); - - continue; - } - - // wildcard - - if ( selector == "*" ) - { - foreach ( var childKey in EnumerateKeys( current ) ) - PushNode( current, tokens.Push( childKey, SelectorKind.UnspecifiedSingular ) ); // (Dot | Index) - continue; - } - - // descendant - - if ( selector == ".." ) - { - foreach ( var childKey in EnumerateKeys( current ) ) - { - if ( !TryGetChildValue( current, childKey, out var childValue ) ) - continue; - - if ( childValue is JsonObject || childValue is JsonArray ) - PushNode( childValue, tokens.Push( "..", SelectorKind.UnspecifiedGroup ) ); // Descendant - } - - PushNode( current, tokens ); - continue; - } - - // union - - foreach ( var childSelector in token.Selectors.Select( x => x.Value ) ) - { - // [(exp)] - - if ( childSelector.Length > 2 && childSelector[0] == '(' && childSelector[^1] == ')' ) - { - if ( evaluator( childSelector, current, current.GetPath() ) is not string evalSelector ) - continue; - - var selectorKind = evalSelector != "*" && evalSelector != ".." && !RegexSlice().IsMatch( evalSelector ) // (Dot | Index) | Wildcard, Descendant, Slice - ? SelectorKind.UnspecifiedSingular - : SelectorKind.UnspecifiedGroup; - - PushNode( current, tokens.Push( evalSelector, selectorKind ) ); - continue; - } - - // [?(exp)] - - if ( childSelector.Length > 3 && childSelector[0] == '?' && childSelector[1] == '(' && childSelector[^1] == ')' ) - { - foreach ( var childKey in EnumerateKeys( current ) ) - { - if ( !TryGetChildValue( current, childKey, out var childValue ) ) - continue; - - var filter = evaluator( RegexPathFilter().Replace( childSelector, "$1" ), childValue, childValue.GetPath() ); - - // treat the filter result as truthy if the evaluator returned a non-convertible object instance. - if ( filter is not null and not IConvertible || Convert.ToBoolean( filter, CultureInfo.InvariantCulture ) ) - PushNode( current, tokens.Push( childKey, SelectorKind.UnspecifiedSingular ) ); // (Name | Index) - } - - continue; - } - - // [name1,name2,...] or [#,#,...] or [start:end:step] - - if ( current is JsonArray currentArray ) - { - if ( RegexNumber().IsMatch( childSelector ) ) - { - // [#,#,...] - PushNode( current[int.Parse( childSelector )], tokens ); - continue; - } - - // [start:end:step] Python slice syntax - if ( RegexSlice().IsMatch( childSelector ) ) - { - foreach ( var index in EnumerateSlice( current, childSelector ) ) - PushNode( current[index], tokens ); - continue; - } - - // [name1,name2,...] - foreach ( var index in EnumerateArrayIndicies( currentArray ) ) - PushNode( current[index], tokens.Push( childSelector, SelectorKind.UnspecifiedSingular ) ); // Name - - continue; - } - - // [name1,name2,...] - - if ( current is JsonObject ) - { - if ( RegexSlice().IsMatch( childSelector ) || RegexNumber().IsMatch( childSelector ) ) - continue; - - // [name1,name2,...] - if ( TryGetChildValue( current, childSelector, out var childValue ) ) - PushNode( childValue, tokens ); - } - } - - } while ( nodes.TryPop( out args ) ); - } - - // because we are using stack processing we will enumerate object members and array - // indicies in reverse order. this preserves the logical (left-to-right, top-down) - // order of the match results that are returned to the user. - - private static IEnumerable EnumerateKeys( JsonNode value ) - { - return value switch - { - JsonArray valueArray => EnumerateArrayIndicies( valueArray ).Select( x => x.ToString() ), - JsonObject valueObject => EnumeratePropertyNames( valueObject ), - _ => throw new NotSupportedException() - }; - } - - private static IEnumerable EnumerateArrayIndicies( JsonArray value ) - { - for ( var index = value.Count - 1; index >= 0; index-- ) - yield return index; - } - - private static IEnumerable EnumeratePropertyNames( JsonObject value ) - { - // Select() before the Reverse() to reduce size of allocation - return value.Select( x => x.Key ).Reverse(); - } - - private static IEnumerable EnumerateSlice( JsonNode value, string sliceExpr ) - { - if ( value is not JsonArray valueArray ) - yield break; - - var (lower, upper, step) = SliceSyntaxHelper.ParseExpression( sliceExpr, valueArray.Count, reverse: true ); - - switch ( step ) - { - case 0: - { - yield break; - } - case > 0: - { - for ( var index = lower; index < upper; index += step ) - yield return index; - break; - } - case < 0: - { - for ( var index = upper; index > lower; index += step ) - yield return index; - break; - } - } - } - - private static bool TryGetChildValue( in JsonNode value, ReadOnlySpan childKey, out JsonNode childValue ) - { - static int? TryParseInt( ReadOnlySpan numberString ) - { - return numberString == null ? null : int.TryParse( numberString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n ) ? n : null; - } - - static bool IsPathOperator( ReadOnlySpan x ) => x == "*" || x == ".." || x == "$"; - - switch ( value ) - { - case JsonObject valueObject: - { - if ( valueObject.TryGetPropertyValue( childKey.ToString(), out childValue ) ) - return true; - break; - } - case JsonArray valueArray: - { - var index = TryParseInt( childKey ) ?? -1; - - if ( index >= 0 && index < valueArray.Count ) - { - childValue = value[index]; - return true; - } - - break; - } - default: - { - if ( !IsPathOperator( childKey ) ) - throw new ArgumentException( $"Invalid child type '{childKey.ToString()}'. Expected child to be Object, Array or a path selector.", nameof( value ) ); - break; - } - } - - childValue = default; - return false; - } - - private sealed class VisitorArgs - { - public readonly JsonNode Value; - public readonly IImmutableStack Tokens; - - public VisitorArgs( in JsonNode value, in IImmutableStack tokens ) - { - Tokens = tokens; - Value = value; - } - - public void Deconstruct( out JsonNode value, out IImmutableStack tokens ) - { - value = Value; - tokens = Tokens; - } - } -} diff --git a/src/Hyperbee.Json/Tokenizer/JsonPathFilterTokenizer.cs b/src/Hyperbee.Json/Tokenizer/JsonPathFilterTokenizer.cs deleted file mode 100644 index 79cc45e6..00000000 --- a/src/Hyperbee.Json/Tokenizer/JsonPathFilterTokenizer.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace Hyperbee.Json.Tokenizer; -// https://ietf-wg-jsonpath.github.io/draft-ietf-jsonpath-base/draft-ietf-jsonpath-base.html -// https://github.com/ietf-wg-jsonpath/draft-ietf-jsonpath-base - -/* BF TODO - -internal static class JsonPathFilterTokenizer // STUB for filter parser -{ - private static readonly ConcurrentDictionary> JsonPathTokens = new(); - - private static readonly Regex RegexSlice = new( @"^(-?[0-9]*):?(-?[0-9]*):?(-?[0-9]*)$", RegexOptions.Compiled); - private static readonly Regex RegexFilter = new( @"^\??\((.*?)\)$", RegexOptions.Compiled); - private static readonly Regex RegexNumber = new( @"^[0-9*]+$", RegexOptions.Compiled); - private static readonly Regex RegexQuotedDouble = new( @"^""(?:[^""\\]|\\.)*""$", RegexOptions.Compiled); - private static readonly Regex RegexQuoted = new( @"^'(?:[^'\\]|\\.)*'$", RegexOptions.Compiled); - - private enum Scanner - { - Start, - DotChild, - UnionStart, - UnionChildLiteral, - UnionChildLiteralFinal, - UnionChild, - UnionNext, - UnionFinal, - Final - } - - private enum SelectorKind - { - Quoted, - Slice, - Filter, - Index, - Wildcard, - Tree, - Unknown - } - - private enum OperationKind - { - Equals, - NotEquals, - LessThan, - LessThanOrEquals, - GreaterThan, - GreaterThanOrEquals, - Match, - In, - NotIn, - SubsetOf, - AnyOf, - NoneOf, - Size, - Empty, - And, - Or - } - - private enum ValueKind - { - Undefined, - Root, - Current, - Literal - } - - private record Operation - { - public OperationKind OperationKind { get; set; } - public Identifier Left { get; set; } - public Identifier Right { get; set; } - } - - private record Identifier - { - public ValueKind ValueKind { get; set; } - public string Value { get; set; } - } -} - -*/ diff --git a/src/Hyperbee.Json/Tokenizer/JsonPathToken.cs b/src/Hyperbee.Json/Tokenizer/JsonPathToken.cs deleted file mode 100644 index 92ab161b..00000000 --- a/src/Hyperbee.Json/Tokenizer/JsonPathToken.cs +++ /dev/null @@ -1,58 +0,0 @@ - -using System.Diagnostics; - -namespace Hyperbee.Json.Tokenizer; - -[DebuggerDisplay( "SelectorKind = {SelectorKind}, Value = {Value}" )] -internal record SelectorDescriptor -{ - public SelectorKind SelectorKind { get; init; } - public string Value { get; init; } -} - -[DebuggerTypeProxy( typeof( JsonPathTokenDebugView ) )] -[DebuggerDisplay( "Singular = {Singular}, SelectorCount = {Selectors.Length}" )] -internal record JsonPathToken -{ - public SelectorDescriptor[] Selectors { get; init; } - - public bool Singular - { - get - { - if ( Selectors.Length != 1 ) - return false; - - return Selectors[0].SelectorKind == SelectorKind.UnspecifiedSingular || // prioritize runtime value - Selectors[0].SelectorKind == SelectorKind.Dot || - Selectors[0].SelectorKind == SelectorKind.Index || - Selectors[0].SelectorKind == SelectorKind.Name || - Selectors[0].SelectorKind == SelectorKind.Root; - } - } - - public JsonPathToken( string selector, SelectorKind kind ) - { - Selectors = - [ - new SelectorDescriptor { SelectorKind = kind, Value = selector } - ]; - } - - public JsonPathToken( SelectorDescriptor[] selectors ) - { - Selectors = selectors; - } - - public void Deconstruct( out bool singular, out SelectorDescriptor[] selectors ) - { - singular = Singular; - selectors = Selectors; - } - - internal class JsonPathTokenDebugView( JsonPathToken instance ) - { - [DebuggerBrowsable( DebuggerBrowsableState.RootHidden )] - public SelectorDescriptor[] Selectors => instance.Selectors; - } -} diff --git a/test/Hyperbee.Json.Benchmark/Config.cs b/test/Hyperbee.Json.Benchmark/Config.cs new file mode 100644 index 00000000..1a993c97 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/Config.cs @@ -0,0 +1,32 @@ +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Exporters; +using BenchmarkDotNet.Jobs; +using BenchmarkDotNet.Loggers; +using BenchmarkDotNet.Validators; + +namespace Hyperbee.Json.Benchmark; + +public class Config : ManualConfig +{ + public Config() + { + AddJob( Job.ShortRun ); + AddExporter( MarkdownExporter.GitHub ); + AddValidator( JitOptimizationsValidator.DontFailOnError ); + AddLogger( ConsoleLogger.Default ); + AddColumnProvider( + DefaultColumnProviders.Job, + DefaultColumnProviders.Params, + DefaultColumnProviders.Descriptor, + DefaultColumnProviders.Metrics, + DefaultColumnProviders.Statistics + ); + + AddDiagnoser( MemoryDiagnoser.Default ); + + Orderer = new FastestToSlowestByParamOrderer(); + ArtifactsPath = "benchmark"; + } +} diff --git a/test/Hyperbee.Json.Benchmark/FastestToSlowestByParamOrderer.cs b/test/Hyperbee.Json.Benchmark/FastestToSlowestByParamOrderer.cs new file mode 100644 index 00000000..cfde22b7 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/FastestToSlowestByParamOrderer.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Order; +using BenchmarkDotNet.Reports; +using BenchmarkDotNet.Running; + +namespace Hyperbee.Json.Benchmark; + +public class FastestToSlowestByParamOrderer : IOrderer +{ + public IEnumerable GetExecutionOrder( + ImmutableArray benchmarksCase, + IEnumerable order = null ) => + benchmarksCase + .OrderByDescending( benchmark => benchmark.Parameters["X"] ) + .ThenBy( benchmark => benchmark.Descriptor.WorkloadMethodDisplayInfo ); + + public IEnumerable GetSummaryOrder( ImmutableArray benchmarksCase, Summary summary ) => + benchmarksCase + .OrderBy( benchmark => benchmark.Parameters.DisplayInfo ) + .ThenBy( benchmark => summary[benchmark]?.ResultStatistics?.Mean ?? double.MaxValue ); + + public string GetHighlightGroupKey( BenchmarkCase benchmarkCase ) => null; + + public string GetLogicalGroupKey( ImmutableArray allBenchmarksCases, BenchmarkCase benchmarkCase ) => + benchmarkCase.Parameters.DisplayInfo; + + public IEnumerable> GetLogicalGroupOrder( + IEnumerable> logicalGroups, + IEnumerable order = null ) => + logicalGroups.OrderBy( it => it.Key ); + + public bool SeparateLogicalGroups => true; +} diff --git a/test/Hyperbee.Json.Benchmark/Hyperbee.Json.Benchmark.csproj b/test/Hyperbee.Json.Benchmark/Hyperbee.Json.Benchmark.csproj new file mode 100644 index 00000000..7a800ec0 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/Hyperbee.Json.Benchmark.csproj @@ -0,0 +1,27 @@ + + + + Exe + net8.0 + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/test/Hyperbee.Json.Benchmark/JsonPathExpressionParser.cs b/test/Hyperbee.Json.Benchmark/JsonPathExpressionParser.cs new file mode 100644 index 00000000..a0f297a1 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/JsonPathExpressionParser.cs @@ -0,0 +1,44 @@ +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; +using BenchmarkDotNet.Attributes; +using Hyperbee.Json.Descriptors.Element; +using Hyperbee.Json.Descriptors.Node; +using Hyperbee.Json.Filters.Parser; + +namespace Hyperbee.Json.Benchmark; + +public class JsonPathExpressionParser +{ + private ParseExpressionContext _nodeExpressionContext; + private ParseExpressionContext _elementExpressionContext; + + [Params( "(\"world\" == 'world') && (true || false)" )] + public string Filter; + + [GlobalSetup] + public void Setup() + { + _nodeExpressionContext = new ParseExpressionContext( + Expression.Parameter( typeof( JsonNode ) ), + Expression.Parameter( typeof( JsonNode ) ), + new NodeTypeDescriptor() ); + + _elementExpressionContext = new ParseExpressionContext( + Expression.Parameter( typeof( JsonElement ) ), + Expression.Parameter( typeof( JsonElement ) ), + new ElementTypeDescriptor() ); + } + + [Benchmark] + public void JsonPathFilterParser_JsonElement() + { + JsonPathExpression.Parse( Filter, _elementExpressionContext ); + } + + [Benchmark] + public void JsonPathFilterParser_JsonNode() + { + JsonPathExpression.Parse( Filter, _nodeExpressionContext ); + } +} diff --git a/test/Hyperbee.Json.Benchmark/JsonPathParseAndSelect.cs b/test/Hyperbee.Json.Benchmark/JsonPathParseAndSelect.cs new file mode 100644 index 00000000..e5d08ad0 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/JsonPathParseAndSelect.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using BenchmarkDotNet.Attributes; +using Hyperbee.Json.Extensions; +using JsonCons.JsonPath; +using Newtonsoft.Json.Linq; +using JsonEverything = Json.Path; + +namespace Hyperbee.Json.Benchmark; + +public class JsonPathParseAndSelect +{ + [Params( + "$.store.book[0]", + "$.store.book[?(@.price == 8.99)]", + "$..*" + )] + public string Filter; + + [Params( + """ + { + "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 + } + } + } + """ + )] + public string Document; + + [Benchmark] + public void JsonPath_Hyperbee_JsonElement() + { + var element = JsonDocument.Parse( Document ).RootElement; + var _ = element.Select( Filter ).First(); + } + + [Benchmark] + public void JsonPath_Hyperbee_JsonNode() + { + var node = JsonNode.Parse( Document )!; + var _ = node.Select( Filter ).First(); + } + + [Benchmark] + public void JsonPath_Newtonsoft_JObject() + { + var jObject = JObject.Parse( Document ); + var _ = jObject.SelectTokens( Filter ).First(); + } + + [Benchmark] + public void JsonPath_JsonEverything_JsonNode() + { + var path = JsonEverything.JsonPath.Parse( Filter ); + var node = JsonNode.Parse( Document )!; + var _ = path.Evaluate( node ).Matches!.First(); + } + + [Benchmark] + public void JsonPath_JsonCons_JsonElement() + { + var path = JsonSelector.Parse( Filter )!; + var element = JsonDocument.Parse( Document ).RootElement; + var _ = path.Select( element ).First(); + } +} diff --git a/test/Hyperbee.Json.Benchmark/JsonPathSelectEvaluator.cs b/test/Hyperbee.Json.Benchmark/JsonPathSelectEvaluator.cs new file mode 100644 index 00000000..9f67d9dd --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/JsonPathSelectEvaluator.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using BenchmarkDotNet.Attributes; +using Hyperbee.Json.Extensions; +using Newtonsoft.Json.Linq; + +namespace Hyperbee.Json.Benchmark; + +public class JsonPathSelectEvaluator +{ + [Params( + "$", + "$.store.book[0]", + "$.store..price", + "$..book[0]", + "$.store.*", + "$.store.book[*].author", + "$.store.book[-1:]", + "$.store.book[0,1]", + "$.store.book['category','author']", + "$..book[?(@.isbn)]", + "$.store.book[?(@.price == 8.99)]", + "$..*", + "$..book[?(@.price == 8.99 && @.category == 'fiction')]" + )] + public string Filter; + + public JsonNode _node; + public JsonElement _element; + + private JObject _jObject; + + [GlobalSetup] + public void Setup() + { + const string document = """ + { + "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 + } + } + } + """; + + _jObject = JObject.Parse( document ); + + _node = JsonNode.Parse( document )!; + _element = JsonDocument.Parse( document ).RootElement; + } + + [Benchmark] + public void JsonPath_Hyperbee_JsonElement() + { + var _ = _element.Select( Filter ).ToArray(); + } + + [Benchmark] + public void JsonPath_Hyperbee_JsonNode() + { + var _ = _node.Select( Filter ).ToArray(); + } + + [Benchmark] + public void JsonPath_Newtonsoft_JObject() + { + var _ = _jObject.SelectTokens( Filter ).ToArray(); + } +} diff --git a/test/Hyperbee.Json.Benchmark/Program.cs b/test/Hyperbee.Json.Benchmark/Program.cs new file mode 100644 index 00000000..6da9a234 --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/Program.cs @@ -0,0 +1,5 @@ +//NOTE: Should be run with `dotnet run -c release` in the project folder +using BenchmarkDotNet.Running; +using Hyperbee.Json.Benchmark; + +BenchmarkSwitcher.FromAssembly( typeof( Program ).Assembly ).Run( args, new Config() ); 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 new file mode 100644 index 00000000..ef9c894e --- /dev/null +++ b/test/Hyperbee.Json.Benchmark/benchmark/results/Hyperbee.Json.Benchmark.JsonPathSelectEvaluator-report-github.md @@ -0,0 +1,19 @@ +``` + +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.Tests/Builder/JsonPathBuilderTests.cs b/test/Hyperbee.Json.Tests/Builder/JsonPathBuilderTests.cs new file mode 100644 index 00000000..923342ac --- /dev/null +++ b/test/Hyperbee.Json.Tests/Builder/JsonPathBuilderTests.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using Hyperbee.Json.Extensions; +using Hyperbee.Json.Tests.TestSupport; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Builder; + +[TestClass] +public class JsonPathBuilderTests : JsonTestBase +{ + [DataTestMethod] + [DataRow( "$['store']['book'][0]['author']", "$.store.book[0].author" )] + [DataRow( "$['store']['book'][1]['author']", "$.store.book[1].author" )] + [DataRow( "$['store']['book'][2]['author']", "$.store.book[2].author" )] + [DataRow( "$['store']['book'][3]['author']", "$.store.book[3].author" )] + public void Should_GetPath( string key, string expected ) + { + var source = GetDocument(); + var target = source.RootElement.GetPropertyFromKey( key ); + + var builder = new JsonPathBuilder( source ); + var result = builder.GetPath( target ); + + Assert.AreEqual( result, expected ); + + var resultCached = builder.GetPath( target ); + + Assert.AreEqual( resultCached, expected ); + } +} diff --git a/test/Hyperbee.Json.Tests/Evaluators/JsonPathExpressionTests.cs b/test/Hyperbee.Json.Tests/Evaluators/JsonPathExpressionTests.cs new file mode 100644 index 00000000..1132b2d4 --- /dev/null +++ b/test/Hyperbee.Json.Tests/Evaluators/JsonPathExpressionTests.cs @@ -0,0 +1,231 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using System.Text.Json; +using System.Text.Json.Nodes; +using Hyperbee.Json.Descriptors.Element; +using Hyperbee.Json.Descriptors.Node; +using Hyperbee.Json.Extensions; +using Hyperbee.Json.Filters.Parser; +using Hyperbee.Json.Tests.TestSupport; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Hyperbee.Json.Tests.Evaluators; + +[TestClass] +public class JsonPathExpressionTests : JsonTestBase +{ + [DataTestMethod] + [DataRow( "true", true, typeof( JsonElement ) )] + [DataRow( "false", false, typeof( JsonElement ) )] + [DataRow( "1 == 1", true, typeof( JsonElement ) )] + [DataRow( "(1 == 1)", true, typeof( JsonElement ) )] + [DataRow( "(1 != 2)", true, typeof( JsonElement ) )] + [DataRow( "!(1 == 2)", true, typeof( JsonElement ) )] + [DataRow( "(\"world\" == 'world') && (true || false)", true, typeof( JsonElement ) )] + [DataRow( "(\"world\" == 'world') || true", true, typeof( JsonElement ) )] + [DataRow( "(\"world\" == 'world') || 1 == 1", true, typeof( JsonElement ) )] + [DataRow( "!('World' != 'World') && !(1 == 2 || 1 == 3)", true, typeof( JsonElement ) )] + [DataRow( "true", true, typeof( JsonNode ) )] + [DataRow( "false", false, typeof( JsonNode ) )] + [DataRow( "1 == 1", true, typeof( JsonNode ) )] + [DataRow( "(1 == 1)", true, typeof( JsonNode ) )] + [DataRow( "(1 != 2)", true, typeof( JsonNode ) )] + [DataRow( "!(1 == 2)", true, typeof( JsonNode ) )] + [DataRow( "(\"world\" == 'world') && (true || false)", true, typeof( JsonNode ) )] + [DataRow( "(\"world\" == 'world') || true", true, typeof( JsonNode ) )] + [DataRow( "(\"world\" == 'world') || 1 == 1", true, typeof( JsonNode ) )] + [DataRow( "!('World' != 'World') && !(1 == 2 || 1 == 3)", true, typeof( JsonNode ) )] + public void Should_MatchExpectedResult_WhenUsingConstants( string filter, bool expected, Type sourceType ) + { + // arrange + var (expression, param) = GetExpression( filter, sourceType ); + + // act + var result = Execute( expression, param, sourceType ); + + // assert + Assert.AreEqual( expected, result ); + } + + [DataTestMethod] + [DataRow( "@.store.bicycle.price < 10", false, typeof( JsonElement ) )] + [DataRow( "@.store.bicycle.price > 15", true, typeof( JsonElement ) )] + [DataRow( "@.store.book[0].category == \"reference\"", true, typeof( JsonElement ) )] + [DataRow( "@.store.book[0].category == 'reference'", true, typeof( JsonElement ) )] + [DataRow( "@.store.book[0].category == @.store.book[1].category", false, typeof( JsonElement ) )] + [DataRow( "@.store.bicycle.price > @.store.bicycle.price", false, typeof( JsonElement ) )] + [DataRow( "@.store.bicycle", true, typeof( JsonElement ) )] + [DataRow( "@.store.book", true, typeof( JsonElement ) )] + [DataRow( "@.store.nothing", false, typeof( JsonElement ) )] + [DataRow( "@.store.bicycle.price", true, typeof( JsonElement ) )] + [DataRow( "@.store.book[0].category", true, typeof( JsonElement ) )] + [DataRow( "@.store.bicycle.price < 10", false, typeof( JsonNode ) )] + [DataRow( "@.store.bicycle.price > 15", true, typeof( JsonNode ) )] + [DataRow( "@.store.book[0].category == \"reference\"", true, typeof( JsonNode ) )] + [DataRow( "@.store.book[0].category == 'reference'", true, typeof( JsonNode ) )] + [DataRow( "@.store.book[0].category == @.store.book[1].category", false, typeof( JsonNode ) )] + [DataRow( "@.store.bicycle.price > @.store.bicycle.price", false, typeof( JsonNode ) )] + [DataRow( "@.store.bicycle", true, typeof( JsonNode ) )] + [DataRow( "@.store.book", true, typeof( JsonNode ) )] + [DataRow( "@.store.nothing", false, typeof( JsonNode ) )] + [DataRow( "@.store.bicycle.price", true, typeof( JsonNode ) )] + [DataRow( "@.store.book[0].category", true, typeof( JsonNode ) )] + public void Should_MatchExpectedResult_WhenUsingJsonPath( string filter, bool expected, Type sourceType ) + { + // arrange & act + var result = CompileAndExecute( filter, sourceType ); + + // assert + Assert.AreEqual( expected, result ); + } + + [DataTestMethod] + [DataRow( "$.store.book[?(@.price > 20)].price", 22.99F, typeof( JsonElement ) )] + [DataRow( "$.store.book[?(@.category == 'reference')].price", 8.95F, typeof( JsonElement ) )] + [DataRow( "$.store.book[?(@.price < 9.00 && @.category == 'reference')].price", 8.95F, typeof( JsonElement ) )] + [DataRow( "$.store.book[?(match(@.title, \"Sayings*\" ))].price", 8.95F, typeof( JsonElement ) )] + [DataRow( "$.store.book[?(@.category == $.store.book[0].category)].price", 8.95F, typeof( JsonElement ) )] + [DataRow( "$.store.book[?(@.price > 20)].price", 22.99F, typeof( JsonNode ) )] + [DataRow( "$.store.book[?(@.category == 'reference')].price", 8.95F, typeof( JsonNode ) )] + [DataRow( "$.store.book[?(@.price < 9.00 && @.category == 'reference')].price", 8.95F, typeof( JsonNode ) )] + [DataRow( "$.store.book[?(match(@.title, \"Sayings*\" ))].price", 8.95F, typeof( JsonNode ) )] + [DataRow( "$.store.book[?(@.category == $.store.book[0].category)].price", 8.95F, typeof( JsonNode ) )] + public void Should_ReturnExpectedResult_WhenUsingExpressionEvaluator( string filter, float expected, Type sourceType ) + { + // arrange & act + var result = Select( filter, sourceType ); + + // assert + Assert.AreEqual( expected, result ); + } + + [DataTestMethod] + [DataRow( "count(@.store.book) == 1", true, typeof( JsonElement ) )] + [DataRow( "count(@.store.book.*) == 4", true, typeof( JsonElement ) )] + [DataRow( "length(@.store.book) == 4", true, typeof( JsonElement ) )] + [DataRow( "length(@.store.book[0].category) == 9", true, typeof( JsonElement ) )] + [DataRow( "match(@.store.book[0].title, \"Sayings*\" )", true, typeof( JsonElement ) )] + [DataRow( "search(@.store.book[0].author, \"[Nn]igel Rees\" )", true, typeof( JsonElement ) )] + [DataRow( "value(@.store.book[0].author) == \"Nigel Rees\"", true, typeof( JsonElement ) )] + [DataRow( "count(@.store.book) == 1", true, typeof( JsonNode ) )] + [DataRow( "count(@.store.book.*) == 4", true, typeof( JsonNode ) )] + [DataRow( "length(@.store.book) == 4", true, typeof( JsonNode ) )] + [DataRow( "length(@.store.book[0].category) == 9", true, typeof( JsonNode ) )] + [DataRow( "match(@.store.book[0].title, \"Sayings*\" )", true, typeof( JsonNode ) )] + [DataRow( "search(@.store.book[0].author, \"[Nn]igel Rees\" )", true, typeof( JsonNode ) )] + [DataRow( "value(@.store.book[0].author) == \"Nigel Rees\"", true, typeof( JsonNode ) )] + public void Should_MatchExpectedResult_WhenUsingFunctions( string filter, bool expected, Type sourceType ) + { + // arrange & act + var result = CompileAndExecute( filter, sourceType ); + + // assert + Assert.AreEqual( expected, result ); + } + + [DataTestMethod] + [DataRow( "unknown_literal", typeof( JsonElement ) )] + [DataRow( "'unbalanced string\"", typeof( JsonElement ) )] + [DataRow( " \t ", typeof( JsonElement ) )] + [DataRow( "1 === 1", typeof( JsonElement ) )] + [DataRow( "(1 == 1(", typeof( JsonElement ) )] + [DataRow( "(1 == 1)(", typeof( JsonElement ) )] + [DataRow( "(1 == ", typeof( JsonElement ) )] + [DataRow( "== 1", typeof( JsonElement ) )] + [DataRow( "badMethod(1)", typeof( JsonElement ) )] + public void Should_FailToParse_WhenUsingInvalidFilters( string filter, Type sourceType ) + { + try + { + GetExpression( filter, sourceType ); + } + catch + { + // Most are FormatExceptions, but some are ArgumentExceptions + return; + } + + Assert.Fail( "Did not throw an exception" ); + } + + private static (Expression, ParameterExpression) GetExpression( string filter, Type sourceType ) + { + var param = Expression.Parameter( sourceType ); + var expression = sourceType == typeof( JsonElement ) + ? JsonPathExpression.Parse( filter, new ParseExpressionContext( + param, + param, + new ElementTypeDescriptor() ) ) + : JsonPathExpression.Parse( filter, new ParseExpressionContext( + param, + param, + new NodeTypeDescriptor() ) ); + + return (expression, param); + } + + private static bool Execute( Expression expression, ParameterExpression param, Type sourceType ) + { + if ( sourceType == typeof( JsonElement ) ) + { + var func = Expression + .Lambda>( expression, param ) + .Compile(); + + return func( new JsonElement() ); + } + else if ( sourceType == typeof( JsonNode ) ) + { + var func = Expression + .Lambda>( expression, param ) + .Compile(); + + return func( JsonNode.Parse( "{}" ) ); + } + else + { + throw new NotImplementedException(); + } + } + + private static bool CompileAndExecute( string filter, Type sourceType ) + { + if ( sourceType == typeof( JsonElement ) ) + { + var source = GetDocument(); + var func = JsonPathExpression.Compile( filter, new ElementTypeDescriptor() ); + + return func( source.RootElement, source.RootElement ); + } + else + { + // arrange + var source = GetDocument(); + var func = JsonPathExpression.Compile( filter, new NodeTypeDescriptor() ); + + // act + return func( source, source ); + } + } + + private static float Select( string filter, Type sourceType ) + { + if ( sourceType == typeof( JsonElement ) ) + { + // arrange + var source = GetDocument(); + + // act + return source.Select( filter ).First().GetSingle(); + } + else + { + // arrange + var source = GetDocument(); + + // act + return source.Select( filter ).First().GetValue(); + } + } +} diff --git a/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj b/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj index 78ac9adc..9c8a1c7a 100644 --- a/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj +++ b/test/Hyperbee.Json.Tests/Hyperbee.Json.Tests.csproj @@ -8,6 +8,7 @@ + diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreTests.cs b/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreTests.cs index 641afb27..1c1fc699 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreTests.cs +++ b/test/Hyperbee.Json.Tests/Query/JsonPathBookstoreTests.cs @@ -102,12 +102,11 @@ public void ThePriceOfEverythingInTheStore( string query, Type sourceType ) public void TheThirdBook( string query, Type sourceType ) { var source = GetDocumentProxy( sourceType ); - var match = source.SelectPath( query ).ToList(); + var match = source.Select( query ).ToList(); var expected = source.GetPropertyFromKey( "$['store']['book'][2]" ); Assert.IsTrue( match.Count == 1 ); - Assert.AreEqual( expected, match[0].Value ); - Assert.AreEqual( "$.store.book[2]", match[0].Path ); + Assert.AreEqual( expected, match[0] ); } [DataTestMethod] @@ -116,11 +115,10 @@ public void TheThirdBook( string query, Type sourceType ) public void TheLastBookInOrder( string query, Type sourceType ) { var source = GetDocumentProxy( sourceType ); - var match = source.SelectPath( query ).Single(); + var match = source.Select( query ).Single(); var expected = source.GetPropertyFromKey( "$['store']['book'][3]" ); - Assert.AreEqual( expected, match.Value ); - Assert.AreEqual( "$.store.book[3]", match.Path ); + Assert.AreEqual( expected, match ); } [DataTestMethod] @@ -166,6 +164,8 @@ public void TheCategoriesAndAuthorsOfAllBooks( string query, Type sourceType ) } [DataTestMethod] + [DataRow( "$..book[?@.isbn]", typeof( JsonDocument ) )] + [DataRow( "$..book[?@.isbn]", typeof( JsonNode ) )] [DataRow( "$..book[?(@.isbn)]", typeof( JsonDocument ) )] [DataRow( "$..book[?(@.isbn)]", typeof( JsonNode ) )] public void FilterAllBooksWithIsbnNumber( string query, Type sourceType ) @@ -184,6 +184,8 @@ public void FilterAllBooksWithIsbnNumber( string query, Type sourceType ) [DataTestMethod] [DataRow( "$..book[?(@.price<10)]", typeof( JsonDocument ) )] [DataRow( "$..book[?(@.price<10)]", typeof( JsonNode ) )] + [DataRow( "$..book[?@.price<10]", typeof( JsonDocument ) )] + [DataRow( "$..book[?@.price<10]", typeof( JsonNode ) )] public void FilterAllBooksCheaperThan10( string query, Type sourceType ) { var source = GetDocumentProxy( sourceType ); @@ -239,34 +241,15 @@ public void AllMembersOfJsonStructure( string query, Type sourceType ) Assert.IsTrue( expected.SequenceEqual( matches ) ); } - [DataTestMethod] - [DataRow( @"$.store.book[?(@path != ""$.store.book[0]"")]", typeof( JsonDocument ) )] - [DataRow( @"$.store.book[?(@path != ""$.store.book[0]"")]", typeof( JsonNode ) )] - public void AllBooksBesidesThatAtThePathPointingToTheFirst( string query, Type sourceType ) - { - var source = GetDocumentProxy( sourceType ); - var matches = source.Select( query ); - - var expected = new[] - { - source.GetPropertyFromKey( "$['store']['book'][1]" ), - source.GetPropertyFromKey( "$['store']['book'][2]" ), - source.GetPropertyFromKey( "$['store']['book'][3]" ) - }; - - Assert.IsTrue( expected.SequenceEqual( matches ) ); - } - [DataTestMethod] [DataRow( @"$..book[?(@.price == 8.99 && @.category == ""fiction"")]", typeof( JsonDocument ) )] [DataRow( @"$..book[?(@.price == 8.99 && @.category == ""fiction"")]", typeof( JsonNode ) )] public void FilterAllBooksUsingLogicalAndInScript( string query, Type sourceType ) { var source = GetDocumentProxy( sourceType ); - var match = source.SelectPath( query ).Single(); + var match = source.Select( query ).Single(); var expected = source.GetPropertyFromKey( "$['store']['book'][2]" ); - Assert.AreEqual( expected, match.Value ); - Assert.AreEqual( "$.store.book[2]", match.Path ); + Assert.AreEqual( expected, match ); } } diff --git a/test/Hyperbee.Json.Tests/Query/JsonPathDotNotationTests.cs b/test/Hyperbee.Json.Tests/Query/JsonPathDotNotationTests.cs index 616a8633..99283e8b 100644 --- a/test/Hyperbee.Json.Tests/Query/JsonPathDotNotationTests.cs +++ b/test/Hyperbee.Json.Tests/Query/JsonPathDotNotationTests.cs @@ -20,7 +20,7 @@ public void DotBracketNotationWithoutQuotes( string query, Type sourceType ) Assert.ThrowsException( () => { - var _ = source.SelectPath( query ).ToList(); + var _ = source.Select( query ).ToList(); } ); } @@ -34,7 +34,7 @@ public void DotBracketNotationWithEmptyPath( string query, Type sourceType ) Assert.ThrowsException( () => { - var _ = source.SelectPath( query ).ToList(); + var _ = source.Select( query ).ToList(); } ); } @@ -68,7 +68,7 @@ public void DotNotationWithoutDot( string query, Type sourceType ) Assert.ThrowsException( () => { - var _ = source.SelectPath( query ).ToList(); + var _ = source.Select( query ).ToList(); } ); } } diff --git a/test/Hyperbee.Json.Tests/TestSupport/IJsonPathProxy.cs b/test/Hyperbee.Json.Tests/TestSupport/IJsonPathProxy.cs index 93755689..50a9987c 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/IJsonPathProxy.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/IJsonPathProxy.cs @@ -6,7 +6,6 @@ public interface IJsonPathProxy { object Source { get; } IEnumerable Select( string query ); - IEnumerable SelectPath( string query ); dynamic GetPropertyFromKey( string pathLiteral ); IEnumerable ArrayEmpty { get; } } diff --git a/test/Hyperbee.Json.Tests/TestSupport/JsonDocumentProxy.cs b/test/Hyperbee.Json.Tests/TestSupport/JsonDocumentProxy.cs index 0798df15..faebb1d3 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/JsonDocumentProxy.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/JsonDocumentProxy.cs @@ -6,14 +6,11 @@ namespace Hyperbee.Json.Tests.TestSupport; -public class JsonDocumentProxy : IJsonPathProxy +public class JsonDocumentProxy( string source ) : IJsonPathProxy { - public JsonDocumentProxy( string source ) => Internal = JsonDocument.Parse( source ); - - protected JsonDocument Internal { get; set; } + protected JsonDocument Internal { get; set; } = JsonDocument.Parse( source ); public object Source => Internal; public IEnumerable Select( string query ) => Internal.Select( query ).Cast(); - public IEnumerable SelectPath( string query ) => Internal.SelectPath( query ).Select( x => new JsonPathPair { Path = x.Path, Value = x.Value } ); - public dynamic GetPropertyFromKey( string pathLiteral ) => Internal.GetPropertyFromKey( pathLiteral ); + public dynamic GetPropertyFromKey( string pathLiteral ) => Internal.RootElement.GetPropertyFromKey( pathLiteral ); public IEnumerable ArrayEmpty => Array.Empty().Cast(); } diff --git a/test/Hyperbee.Json.Tests/TestSupport/JsonNodeProxy.cs b/test/Hyperbee.Json.Tests/TestSupport/JsonNodeProxy.cs index 0b36a70c..741d97dc 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/JsonNodeProxy.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/JsonNodeProxy.cs @@ -1,31 +1,16 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Generic; using System.Text.Json.Nodes; using Hyperbee.Json.Extensions; namespace Hyperbee.Json.Tests.TestSupport; -public class JsonNodeProxy : IJsonPathProxy +public class JsonNodeProxy( string source ) : IJsonPathProxy { - public JsonNodeProxy( string source ) => Internal = JsonNode.Parse( source ); - - protected JsonNode Internal { get; set; } + protected JsonNode Internal { get; set; } = JsonNode.Parse( source ); public object Source => Internal; public IEnumerable Select( string query ) => Internal.Select( query ); - public IEnumerable SelectPath( string query ) - { - return Internal - .Select( query ) - .Select( node => new JsonPathPair - { - Path = node.GetPath(), - Value = node - } ); - } - public dynamic GetPropertyFromKey( string pathLiteral ) => Internal.GetPropertyFromKey( pathLiteral ); - public IEnumerable ArrayEmpty => Array.Empty(); + public IEnumerable ArrayEmpty => []; } diff --git a/test/Hyperbee.Json.Tests/TestSupport/JsonPathPair.cs b/test/Hyperbee.Json.Tests/TestSupport/JsonPathPair.cs index 668a2f81..2592216d 100644 --- a/test/Hyperbee.Json.Tests/TestSupport/JsonPathPair.cs +++ b/test/Hyperbee.Json.Tests/TestSupport/JsonPathPair.cs @@ -1,7 +1,7 @@ namespace Hyperbee.Json.Tests.TestSupport; -public record JsonPathPair -{ - public string Path { get; init; } - public dynamic Value { get; init; } -} +// public record JsonPathPair +// { +// public string Path { get; init; } +// public dynamic Value { get; init; } +// } diff --git a/test/Hyperbee.Json.Tests/Tokenizer/JsonPathFilterTokenizerTests.cs b/test/Hyperbee.Json.Tests/Tokenizer/JsonPathFilterTokenizerTests.cs deleted file mode 100644 index 8727a36f..00000000 --- a/test/Hyperbee.Json.Tests/Tokenizer/JsonPathFilterTokenizerTests.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Hyperbee.Json.Tests.Tokenizer; - -[TestClass] -public class JsonPathFilterTokenizerTests -{ - [DataTestMethod] - [DataRow( "?(@.price < 10)", "@:price|LessThan|literal:10" )] - [DataRow( "?(@.price == 8.99 && @.category == 'fiction')", "{@:price|Equals|literal:8.99};{And};{@:category|Equals|literal:'fiction'}" )] - [DataRow( "?(@.price == 8.99 || @.category == 'fiction')", "{@:price|Equals|literal:8.99};{Or};{@:category|Equals|literal:'fiction'}" )] - public void Should_tokenize_filter( string filter, string expected ) - { - // arrange - - // act - - //var result = JsonPathFilter.Tokenize( filter ).ToList(); - - // assert - Assert.IsTrue( true ); - } -} diff --git a/test/Hyperbee.Json.Tests/Tokenizer/JsonPathQueryTokenizerTests.cs b/test/Hyperbee.Json.Tests/Tokenizer/JsonPathQueryTokenizerTests.cs index 3ec315c8..4569000d 100644 --- a/test/Hyperbee.Json.Tests/Tokenizer/JsonPathQueryTokenizerTests.cs +++ b/test/Hyperbee.Json.Tests/Tokenizer/JsonPathQueryTokenizerTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Linq; -using Hyperbee.Json.Tokenizer; +using System.Linq; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Hyperbee.Json.Tests.Tokenizer; @@ -12,11 +10,11 @@ public class JsonPathQueryTokenizerTests [DataRow( "$", "{$|k}" )] [DataRow( "$.two.some", "{$|k};{two|k};{some|k}" )] [DataRow( "$.thing[1:2:3]", "{$|k};{thing|k};{1:2:3|s}" )] - [DataRow( @"$..thing[?(@.x == 1)]", "{$|k};{..|s};{thing|k};{?(@.x == 1)|s}" )] + [DataRow( "$..thing[?(@.x == 1)]", "{$|k};{..|s};{thing|k};{?(@.x == 1)|s}" )] [DataRow( "$['two.some']", "{$|k};{two.some|k}" )] [DataRow( "$.two.some.thing['this.or.that']", "{$|k};{two|k};{some|k};{thing|k};{this.or.that|k}" )] [DataRow( "$.store.book[*].author", "{$|k};{store|k};{book|k};{*|s};{author|k}" )] - [DataRow( "$..author", "{$|k};{..|s};{author|k}" )] + [DataRow( "@..author", "{@|k};{..|s};{author|k}" )] [DataRow( "$.store.*", "{$|k};{store|k};{*|s}" )] [DataRow( "$.store..price", "{$|k};{store|k};{..|s};{price|k}" )] [DataRow( "$..book[2]", "{$|k};{..|s};{book|k};{2|k}" )] @@ -26,26 +24,27 @@ public class JsonPathQueryTokenizerTests [DataRow( "$.store.book[0,1]", "{$|k};{store|k};{book|k};{1,0|s}" )] [DataRow( "$..book['category','author']", "{$|k};{..|s};{book|k};{author,category|s}" )] [DataRow( "$..book[?(@.isbn)]", "{$|k};{..|s};{book|k};{?(@.isbn)|s}" )] + [DataRow( "$..book[?@.isbn]", "{$|k};{..|s};{book|k};{?@.isbn|s}" )] [DataRow( "$..book[?(@.price<10)]", "{$|k};{..|s};{book|k};{?(@.price<10)|s}" )] + [DataRow( "$..book[?@.price<10]", "{$|k};{..|s};{book|k};{?@.price<10|s}" )] [DataRow( "$..*", "{$|k};{..|s};{*|s}" )] - [DataRow( @"$.store.book[?(@path !== ""$['store']['book'][0]"")]", @"{$|k};{store|k};{book|k};{?(@path !== ""$['store']['book'][0]"")|s}" )] - [DataRow( @"$..book[?(@.price == 8.99 && @.category == ""fiction"")]", @"{$|k};{..|s};{book|k};{?(@.price == 8.99 && @.category == ""fiction"")|s}" )] + [DataRow( """$.store.book[?(@path !== "$['store']['book'][0]")]""", """{$|k};{store|k};{book|k};{?(@path !== "$['store']['book'][0]")|s}""" )] + [DataRow( """$..book[?(@.price == 8.99 && @.category == "fiction")]""", """{$|k};{..|s};{book|k};{?(@.price == 8.99 && @.category == "fiction")|s}""" )] public void Should_tokenize_json_path( string jsonPath, string expected ) { // arrange - - static string TokensToString( IEnumerable tokens ) + static string TokensToString( JsonPathSegment segment ) { - static string TokenToString( JsonPathToken token ) + return string.Join( ';', segment.AsEnumerable().Select( TokenToString ) ); + + static string TokenToString( JsonPathSegment segment ) { - var (keySelector, selectors) = token; + var (keySelector, selectors) = segment; var selectorType = keySelector ? "k" : "s"; var selectorsString = string.Join( ',', selectors.Select( x => x.Value ) ); return $"{{{selectorsString}|{selectorType}}}"; } - - return string.Join( ';', tokens.Select( TokenToString ) ); } // act