diff --git a/.github/workflows/ci-bdnbenchmark.yml b/.github/workflows/ci-bdnbenchmark.yml
index af5751db67..16bc0ca14a 100644
--- a/.github/workflows/ci-bdnbenchmark.yml
+++ b/.github/workflows/ci-bdnbenchmark.yml
@@ -42,7 +42,7 @@ jobs:
os: [ ubuntu-latest, windows-latest ]
framework: [ 'net8.0' ]
configuration: [ 'Release' ]
- test: [ 'Operations.BasicOperations', 'Operations.ObjectOperations', 'Operations.HashObjectOperations', 'Cluster.ClusterMigrate', 'Cluster.ClusterOperations', 'Lua.LuaScripts', 'Lua.LuaScriptCacheOperations','Lua.LuaRunnerOperations','Operations.CustomOperations', 'Operations.RawStringOperations', 'Operations.ScriptOperations', 'Operations.ModuleOperations', 'Operations.PubSubOperations', 'Network.BasicOperations', 'Network.RawStringOperations' ]
+ test: [ 'Operations.BasicOperations', 'Operations.ObjectOperations', 'Operations.HashObjectOperations', 'Cluster.ClusterMigrate', 'Cluster.ClusterOperations', 'Lua.LuaScripts', 'Lua.LuaScriptCacheOperations','Lua.LuaRunnerOperations','Operations.CustomOperations', 'Operations.RawStringOperations', 'Operations.ScriptOperations', 'Operations.JsonOperations', 'Operations.ModuleOperations', 'Operations.PubSubOperations', 'Network.BasicOperations', 'Network.RawStringOperations' ]
steps:
- name: Check out code
uses: actions/checkout@v4
diff --git a/Garnet.sln b/Garnet.sln
index 668e5f3133..243a3aac08 100644
--- a/Garnet.sln
+++ b/Garnet.sln
@@ -97,8 +97,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommandInfoUpdater", "playg
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SampleModule", "playground\SampleModule\SampleModule.csproj", "{A8CA619E-8F13-4EF8-943F-2D5E3FEBFB3F}"
EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GarnetJSON", "playground\GarnetJSON\GarnetJSON.csproj", "{2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}"
-EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MigrateBench", "playground\MigrateBench\MigrateBench.csproj", "{6B66B394-E410-4B61-9A5A-1595FF6F5E08}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "hosting", "hosting", "{01823EA4-4446-4D66-B268-DFEE55951964}"
@@ -111,6 +109,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Garnet.resources", "libs\re
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NoOpModule", "playground\NoOpModule\NoOpModule.csproj", "{D4C9A1A0-7053-F072-21F5-4E0C5827136D}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{D8A9CE6E-91B9-4B84-B44A-2BCF1161A793}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GarnetJSON", "modules\GarnetJSON\GarnetJSON.csproj", "{1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -295,14 +297,6 @@ Global
{A8CA619E-8F13-4EF8-943F-2D5E3FEBFB3F}.Release|Any CPU.Build.0 = Release|Any CPU
{A8CA619E-8F13-4EF8-943F-2D5E3FEBFB3F}.Release|x64.ActiveCfg = Release|Any CPU
{A8CA619E-8F13-4EF8-943F-2D5E3FEBFB3F}.Release|x64.Build.0 = Release|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Debug|x64.ActiveCfg = Debug|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Debug|x64.Build.0 = Debug|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Release|Any CPU.Build.0 = Release|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Release|x64.ActiveCfg = Release|Any CPU
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F}.Release|x64.Build.0 = Release|Any CPU
{6B66B394-E410-4B61-9A5A-1595FF6F5E08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6B66B394-E410-4B61-9A5A-1595FF6F5E08}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B66B394-E410-4B61-9A5A-1595FF6F5E08}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -335,6 +329,14 @@ Global
{D4C9A1A0-7053-F072-21F5-4E0C5827136D}.Release|Any CPU.Build.0 = Release|Any CPU
{D4C9A1A0-7053-F072-21F5-4E0C5827136D}.Release|x64.ActiveCfg = Release|Any CPU
{D4C9A1A0-7053-F072-21F5-4E0C5827136D}.Release|x64.Build.0 = Release|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Debug|x64.Build.0 = Debug|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Release|x64.ActiveCfg = Release|Any CPU
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -364,14 +366,15 @@ Global
{9F6E4734-6341-4A9C-A7FF-636A39D8BEAD} = {346A5A53-51E4-4A75-B7E6-491D950382CE}
{9BE474A2-1547-43AC-B4F2-FB48A01FA995} = {69A71E2C-00E3-42F3-854E-BE157A24834E}
{A8CA619E-8F13-4EF8-943F-2D5E3FEBFB3F} = {69A71E2C-00E3-42F3-854E-BE157A24834E}
- {2C8F1F5D-31E5-4D00-A46E-F3B1D9BC098F} = {69A71E2C-00E3-42F3-854E-BE157A24834E}
{6B66B394-E410-4B61-9A5A-1595FF6F5E08} = {69A71E2C-00E3-42F3-854E-BE157A24834E}
{697766CD-2046-46D9-958A-0FD3B46C98D4} = {01823EA4-4446-4D66-B268-DFEE55951964}
{DF2DD03E-87EE-482A-9FBA-6C8FBC23BDC5} = {697766CD-2046-46D9-958A-0FD3B46C98D4}
{A48412B4-FD60-467E-A5D9-F155CAB4F907} = {147FCE31-EC09-4C90-8E4D-37CA87ED18C3}
{D4C9A1A0-7053-F072-21F5-4E0C5827136D} = {69A71E2C-00E3-42F3-854E-BE157A24834E}
+ {1A5DF817-D0DD-4F0B-AE3A-C9CD0E03C9D5} = {D8A9CE6E-91B9-4B84-B44A-2BCF1161A793}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
+ FileExplorer =
SolutionGuid = {2C02C405-4798-41CA-AF98-61EDFEF6772E}
EndGlobalSection
EndGlobal
diff --git a/NOTICE.md b/NOTICE.md
index 110ef87356..19857aeb03 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -104,3 +104,28 @@ 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.
+
+## Newtonsoft.Json
+
+**Source**: https://github.com/JamesNK/Newtonsoft.Json
+
+The MIT License (MIT)
+
+Copyright (c) 2007 James Newton-King
+
+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.
diff --git a/benchmark/BDN.benchmark/BDN.benchmark.csproj b/benchmark/BDN.benchmark/BDN.benchmark.csproj
index 71c1961eee..f26b108154 100644
--- a/benchmark/BDN.benchmark/BDN.benchmark.csproj
+++ b/benchmark/BDN.benchmark/BDN.benchmark.csproj
@@ -18,7 +18,7 @@
-
+
diff --git a/benchmark/BDN.benchmark/Json/JsonPathQuery.cs b/benchmark/BDN.benchmark/Json/JsonPathQuery.cs
new file mode 100644
index 0000000000..b4cc9dc238
--- /dev/null
+++ b/benchmark/BDN.benchmark/Json/JsonPathQuery.cs
@@ -0,0 +1,74 @@
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Engines;
+using GarnetJSON.JSONPath;
+using System.Text.Json.Nodes;
+
+namespace BDN.benchmark.Json;
+
+[MemoryDiagnoser]
+public class JsonPathQuery
+{
+ private JsonNode _jsonNode;
+
+ private readonly Consumer _consumer = new Consumer();
+
+ [Params(
+ "$.store.book[0].title",
+ "$.store.book[*].author",
+ "$.store.book[?(@.price < 10)].title",
+ "$.store.bicycle.color",
+ "$.store.book[*]", // all books
+ "$.store..price", // all prices using recursive descent
+ "$..author", // all authors using recursive descent
+ "$.store.book[?(@.price > 10 && @.price < 20)]", // filtered by price range
+ "$.store.book[?(@.category == 'fiction')]", // filtered by category
+ "$.store.book[-1:]", // last book
+ "$.store.book[:2]", // first two books
+ "$.store.book[?(@.author =~ /.*Waugh/)]", // regex match on author
+ "$..book[0,1]", // union of array indices
+ "$..*", // recursive descent all nodes
+ "$..['bicycle','price']", // recursive descent specfic node with name match
+ "$..[?(@.price < 10)]", // recursive descent specfic node with conditionally match
+ "$.store.book[?(@.author && @.title)]", // existence check
+ "$.store.*" // wildcard child
+ )]
+ public string JsonPath { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ var jsonString = """
+ {
+ "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
+ }
+ ],
+ "bicycle": {
+ "color": "red",
+ "price": 19.95
+ }
+ }
+ }
+ """;
+
+ _jsonNode = JsonNode.Parse(jsonString);
+ }
+
+ [Benchmark]
+ public void SelectNodes()
+ {
+ var result = _jsonNode.SelectNodes(JsonPath);
+ result.Consume(_consumer);
+ }
+}
\ No newline at end of file
diff --git a/benchmark/BDN.benchmark/Operations/JsonOperations.cs b/benchmark/BDN.benchmark/Operations/JsonOperations.cs
new file mode 100644
index 0000000000..2f03c12d08
--- /dev/null
+++ b/benchmark/BDN.benchmark/Operations/JsonOperations.cs
@@ -0,0 +1,135 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+using System.Text;
+using BenchmarkDotNet.Attributes;
+using Embedded.server;
+
+namespace BDN.benchmark.Operations
+{
+ ///
+ /// Benchmark for ModuleOperations
+ ///
+ [MemoryDiagnoser]
+ public class JsonOperations : OperationsBase
+ {
+ // Existing commands
+ static ReadOnlySpan JSONGETCMD => "*3\r\n$8\r\nJSON.GET\r\n$2\r\nk3\r\n$1\r\n$\r\n"u8;
+ static ReadOnlySpan JSONSETCMD => "*4\r\n$8\r\nJSON.SET\r\n$2\r\nk3\r\n$4\r\n$.f2\r\n$1\r\n2\r\n"u8;
+
+ // New commands for different JsonPath patterns
+ static ReadOnlySpan JSONGET_DEEP => "*3\r\n$8\r\nJSON.GET\r\n$4\r\nbig1\r\n$12\r\n$.data[0].id\r\n"u8;
+ static ReadOnlySpan JSONGET_ARRAY => "*3\r\n$8\r\nJSON.GET\r\n$4\r\nbig1\r\n$13\r\n$.data[*]\r\n"u8;
+ static ReadOnlySpan JSONGET_ARRAY_ELEMENTS => "*3\r\n$8\r\nJSON.GET\r\n$4\r\nbig1\r\n$13\r\n$.data[*].id\r\n"u8;
+ static ReadOnlySpan JSONGET_FILTER => "*3\r\n$8\r\nJSON.GET\r\n$4\r\nbig1\r\n$29\r\n$.data[?(@.active==true)]\r\n"u8;
+ static ReadOnlySpan JSONGET_RECURSIVE => "*3\r\n$8\r\nJSON.GET\r\n$4\r\nbig1\r\n$4\r\n$..*\r\n"u8;
+
+ Request jsonGetCmd;
+ Request jsonSetCmd;
+ Request jsonGetDeepCmd;
+ Request jsonGetArrayCmd;
+ Request jsonGetArrayElementsCmd;
+ Request jsonGetFilterCmd;
+ Request jsonGetRecursiveCmd;
+
+ private static string GenerateLargeJson(int items)
+ {
+ var data = new StringBuilder();
+ data.Append("{\"data\":[");
+
+ for (int i = 0; i < items; i++)
+ {
+ if (i > 0) data.Append(',');
+ data.Append($$"""
+ {
+ "id": {{i}},
+ "name": "Item{{i}}",
+ "active": {{(i % 2 == 0).ToString().ToLower()}},
+ "value": {{i * 100}},
+ "nested": {
+ "level1": {
+ "level2": {
+ "value": {{i}}
+ }
+ }
+ }
+ }
+ """);
+ }
+
+ data.Append("]}");
+ return data.ToString();
+ }
+
+ private void RegisterModules()
+ {
+ server.Register.NewModule(new NoOpModule.NoOpModule(), [], out _);
+ server.Register.NewModule(new GarnetJSON.Module(), [], out _);
+ }
+
+ public override void GlobalSetup()
+ {
+ base.GlobalSetup();
+ RegisterModules();
+
+ SetupOperation(ref jsonGetCmd, JSONGETCMD);
+ SetupOperation(ref jsonSetCmd, JSONSETCMD);
+ SetupOperation(ref jsonGetDeepCmd, JSONGET_DEEP);
+ SetupOperation(ref jsonGetArrayCmd, JSONGET_ARRAY);
+ SetupOperation(ref jsonGetArrayElementsCmd, JSONGET_ARRAY_ELEMENTS);
+ SetupOperation(ref jsonGetFilterCmd, JSONGET_FILTER);
+ SetupOperation(ref jsonGetRecursiveCmd, JSONGET_RECURSIVE);
+
+ // Setup test data
+ var largeJson = GenerateLargeJson(20);
+ SlowConsumeMessage(Encoding.UTF8.GetBytes($"*4\r\n$8\r\nJSON.SET\r\n$4\r\nbig1\r\n$1\r\n$\r\n${largeJson.Length}\r\n{largeJson}\r\n"));
+
+ // Existing setup
+ SlowConsumeMessage("*4\r\n$8\r\nJSON.SET\r\n$2\r\nk3\r\n$1\r\n$\r\n$14\r\n{\"f1\":{\"a\":1}}\r\n"u8);
+ SlowConsumeMessage(JSONGETCMD);
+ SlowConsumeMessage(JSONSETCMD);
+ }
+
+ [Benchmark]
+ public void ModuleJsonGetCommand()
+ {
+ Send(jsonGetCmd);
+ }
+
+ [Benchmark]
+ public void ModuleJsonSetCommand()
+ {
+ Send(jsonSetCmd);
+ }
+
+ [Benchmark]
+ public void ModuleJsonGetDeepPath()
+ {
+ Send(jsonGetDeepCmd);
+ }
+
+ [Benchmark]
+ public void ModuleJsonGetArrayPath()
+ {
+ Send(jsonGetArrayCmd);
+ }
+
+ [Benchmark]
+ public void ModuleJsonGetArrayElementsPath()
+ {
+ Send(jsonGetArrayElementsCmd);
+ }
+
+ [Benchmark]
+ public void ModuleJsonGetFilterPath()
+ {
+ Send(jsonGetFilterCmd);
+ }
+
+ [Benchmark]
+ public void ModuleJsonGetRecursive()
+ {
+ Send(jsonGetRecursiveCmd);
+ }
+ }
+}
\ No newline at end of file
diff --git a/benchmark/BDN.benchmark/Operations/ModuleOperations.cs b/benchmark/BDN.benchmark/Operations/ModuleOperations.cs
index c77723ed4d..5769f40e7a 100644
--- a/benchmark/BDN.benchmark/Operations/ModuleOperations.cs
+++ b/benchmark/BDN.benchmark/Operations/ModuleOperations.cs
@@ -30,12 +30,6 @@ public class ModuleOperations : OperationsBase
static ReadOnlySpan NOOPTXN => "*1\r\n$18\r\nNoOpModule.NOOPTXN\r\n"u8;
Request noOpTxn;
- static ReadOnlySpan JSONGETCMD => "*3\r\n$8\r\nJSON.GET\r\n$2\r\nk3\r\n$1\r\n$\r\n"u8;
- Request jsonGetCmd;
-
- static ReadOnlySpan JSONSETCMD => "*4\r\n$8\r\nJSON.SET\r\n$2\r\nk3\r\n$4\r\n$.f2\r\n$1\r\n2\r\n"u8;
- Request jsonSetCmd;
-
private void RegisterModules()
{
server.Register.NewModule(new NoOpModule.NoOpModule(), [], out _);
@@ -54,9 +48,6 @@ public override void GlobalSetup()
SetupOperation(ref noOpProc, NOOPPROC);
SetupOperation(ref noOpTxn, NOOPTXN);
- SetupOperation(ref jsonGetCmd, JSONGETCMD);
- SetupOperation(ref jsonSetCmd, JSONSETCMD);
-
SlowConsumeMessage("*3\r\n$3\r\nSET\r\n$2\r\nk1\r\n$1\r\nc\r\n"u8);
SlowConsumeMessage(NOOPCMDREAD);
SlowConsumeMessage(NOOPCMDRMW);
@@ -65,8 +56,6 @@ public override void GlobalSetup()
SlowConsumeMessage(NOOPPROC);
SlowConsumeMessage(NOOPTXN);
SlowConsumeMessage("*4\r\n$8\r\nJSON.SET\r\n$2\r\nk3\r\n$1\r\n$\r\n$14\r\n{\"f1\":{\"a\":1}}\r\n"u8);
- SlowConsumeMessage(JSONGETCMD);
- SlowConsumeMessage(JSONSETCMD);
}
[Benchmark]
@@ -104,17 +93,5 @@ public void ModuleNoOpTxn()
{
Send(noOpTxn);
}
-
- [Benchmark]
- public void ModuleJsonGetCommand()
- {
- Send(jsonGetCmd);
- }
-
- [Benchmark]
- public void ModuleJsonSetCommand()
- {
- Send(jsonSetCmd);
- }
}
}
\ No newline at end of file
diff --git a/libs/server/Custom/CustomCommandUtils.cs b/libs/server/Custom/CustomCommandUtils.cs
index 3be9ba56b9..51e7f16b4c 100644
--- a/libs/server/Custom/CustomCommandUtils.cs
+++ b/libs/server/Custom/CustomCommandUtils.cs
@@ -93,6 +93,15 @@ public static unsafe void WriteBulkString(ref (IMemoryOwner, int) output,
public static unsafe void WriteError(ref (IMemoryOwner, int) output, string errorMessage)
{
var bytes = System.Text.Encoding.ASCII.GetBytes(errorMessage);
+ WriteError(ref output, bytes);
+ }
+
+ ///
+ /// Create output as error message, from given string
+ ///
+ public static unsafe void WriteError(ref (IMemoryOwner, int) output, ReadOnlySpan errorMessage)
+ {
+ var bytes = errorMessage;
// Get space for error
var len = 1 + bytes.Length + 2;
output.Item1 = MemoryPool.Rent(len);
@@ -142,5 +151,23 @@ public static unsafe void WriteSimpleString(ref (IMemoryOwner, int) output
}
output.Item2 = len;
}
+
+ ///
+ /// Writes bytes directly into a rented memory buffer without any encoding transformation.
+ ///
+ /// A tuple containing the memory owner and the length of written data.
+ /// The source bytes to write.
+ public static unsafe void WriteDirect(ref (IMemoryOwner, int) output, ReadOnlySpan bytes)
+ {
+ output.Item1 = MemoryPool.Rent(bytes.Length);
+ fixed (byte* ptr = output.Item1.Memory.Span)
+ {
+ var curr = ptr;
+ // NOTE: Expected to always have enough space to write into pre-allocated buffer
+ var success = RespWriteUtils.TryWriteDirect(bytes, ref curr, ptr + bytes.Length);
+ Debug.Assert(success, "Insufficient space in pre-allocated buffer");
+ }
+ output.Item2 = bytes.Length;
+ }
}
}
\ No newline at end of file
diff --git a/libs/server/Custom/CustomObjectFunctions.cs b/libs/server/Custom/CustomObjectFunctions.cs
index 630a323cd3..868337e929 100644
--- a/libs/server/Custom/CustomObjectFunctions.cs
+++ b/libs/server/Custom/CustomObjectFunctions.cs
@@ -29,6 +29,13 @@ public abstract class CustomObjectFunctions
///
protected static unsafe void WriteError(ref (IMemoryOwner, int) output, string errorMessage) => CustomCommandUtils.WriteError(ref output, errorMessage);
+ ///
+ /// Writes the specified bytes directly to the output.
+ ///
+ /// The output buffer and its length.
+ /// The bytes to write.
+ protected static unsafe void WriteDirect(ref (IMemoryOwner, int) output, ReadOnlySpan bytes) => CustomCommandUtils.WriteDirect(ref output, bytes);
+
///
/// Get argument from input, at specified offset (starting from 0)
///
diff --git a/libs/server/Custom/RespCustomObjectOutputWriterExtensions.cs b/libs/server/Custom/RespCustomObjectOutputWriterExtensions.cs
new file mode 100644
index 0000000000..5dd561cbbf
--- /dev/null
+++ b/libs/server/Custom/RespCustomObjectOutputWriterExtensions.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+using System;
+using System.Buffers;
+using System.Text;
+
+namespace Garnet.server
+{
+ ///
+ /// Provides extension methods for handling custom object output writing operations.
+ ///
+ public static class RespCustomObjectOutputWriterExtensions
+ {
+ ///
+ /// Aborts the execution of the current object store command and outputs
+ /// an error message to indicate a wrong number of arguments for the given command.
+ ///
+ /// Name of the command that caused the error message.
+ /// true if the command was completely consumed, false if the input on the receive buffer was incomplete.
+ public static bool AbortWithWrongNumberOfArguments(this ref (IMemoryOwner, int) output, string cmdName)
+ {
+ var errorMessage = Encoding.ASCII.GetBytes(string.Format(CmdStrings.GenericErrWrongNumArgs, cmdName));
+
+ return output.AbortWithErrorMessage(errorMessage);
+ }
+
+ ///
+ /// Aborts the execution of the current object store command and outputs a given error message.
+ ///
+ /// Error message to print to result stream.
+ /// true if the command was completely consumed, false if the input on the receive buffer was incomplete.
+ public static bool AbortWithErrorMessage(this ref (IMemoryOwner, int) output, ReadOnlySpan errorMessage)
+ {
+ CustomCommandUtils.WriteError(ref output, errorMessage);
+
+ return true;
+ }
+
+ ///
+ /// Aborts the execution of the current object store command and outputs a given error message.
+ ///
+ /// Error message to print to result stream.
+ /// true if the command was completely consumed, false if the input on the receive buffer was incomplete.
+ public static bool AbortWithErrorMessage(this ref (IMemoryOwner, int) output, string errorMessage)
+ {
+ CustomCommandUtils.WriteError(ref output, errorMessage);
+
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/libs/server/Resp/CmdStrings.cs b/libs/server/Resp/CmdStrings.cs
index bdafb73705..98ad256604 100644
--- a/libs/server/Resp/CmdStrings.cs
+++ b/libs/server/Resp/CmdStrings.cs
@@ -8,7 +8,7 @@ namespace Garnet.server
///
/// Command strings for RESP protocol
///
- static partial class CmdStrings
+ public static partial class CmdStrings
{
///
/// Request strings
diff --git a/libs/server/Resp/RespEnums.cs b/libs/server/Resp/RespEnums.cs
index fd52e74538..c2bf735540 100644
--- a/libs/server/Resp/RespEnums.cs
+++ b/libs/server/Resp/RespEnums.cs
@@ -16,7 +16,7 @@ internal enum EtagOption : byte
WithETag,
}
- internal enum ExistOptions : byte
+ public enum ExistOptions : byte
{
None,
NX,
diff --git a/playground/GarnetJSON/GarnetJSON.csproj b/modules/GarnetJSON/GarnetJSON.csproj
similarity index 57%
rename from playground/GarnetJSON/GarnetJSON.csproj
rename to modules/GarnetJSON/GarnetJSON.csproj
index 403112f7ac..373076ffab 100644
--- a/playground/GarnetJSON/GarnetJSON.csproj
+++ b/modules/GarnetJSON/GarnetJSON.csproj
@@ -12,12 +12,12 @@
enable
-
-
+
+
-
+
diff --git a/modules/GarnetJSON/GarnetJsonObject.cs b/modules/GarnetJSON/GarnetJsonObject.cs
new file mode 100644
index 0000000000..24a27a0719
--- /dev/null
+++ b/modules/GarnetJSON/GarnetJsonObject.cs
@@ -0,0 +1,351 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+using System.Diagnostics;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Garnet.server;
+using GarnetJSON.JSONPath;
+
+namespace GarnetJSON
+{
+ ///
+ /// Represents a factory for creating instances of .
+ ///
+ public class GarnetJsonObjectFactory : CustomObjectFactory
+ {
+ ///
+ /// Creates a new instance of with the specified type.
+ ///
+ /// The type of the object.
+ /// A new instance of .
+ public override CustomObjectBase Create(byte type)
+ => new GarnetJsonObject(type);
+
+ ///
+ /// Deserializes a from the specified binary reader.
+ ///
+ /// The type of the object.
+ /// The binary reader to deserialize from.
+ /// A deserialized instance of .
+ public override CustomObjectBase Deserialize(byte type, BinaryReader reader)
+ => new GarnetJsonObject(type, reader);
+ }
+
+ ///
+ /// Represents a JSON object that supports SET and GET operations using JSON path.
+ ///
+ public class GarnetJsonObject : CustomObjectBase
+ {
+ private static readonly JsonSerializerOptions DefaultJsonSerializerOptions = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+ private static readonly JsonSerializerOptions IndentedJsonSerializerOptions = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, WriteIndented = true };
+
+ private JsonNode? rootNode;
+
+ ///
+ /// Initializes a new instance of the class with the specified type.
+ ///
+ /// The type of the object.
+ public GarnetJsonObject(byte type)
+ : base(type, 0, MemoryUtils.DictionaryOverhead)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class by deserializing from the specified binary reader.
+ ///
+ /// The type of the object.
+ /// The binary reader to deserialize from.
+ public GarnetJsonObject(byte type, BinaryReader reader)
+ : base(type, reader)
+ {
+ Debug.Assert(reader != null);
+
+ var jsonString = reader.ReadString();
+ rootNode = JsonNode.Parse(jsonString);
+ }
+
+ ///
+ /// Initializes a new instance of the class by cloning another instance.
+ ///
+ /// The instance to clone.
+ public GarnetJsonObject(GarnetJsonObject obj)
+ : base(obj)
+ {
+ rootNode = obj.rootNode;
+ }
+
+ ///
+ /// Creates a new instance of that is a clone of the current instance.
+ ///
+ /// A new instance of that is a clone of the current instance.
+ public override CustomObjectBase CloneObject() => new GarnetJsonObject(this);
+
+ ///
+ /// Serializes the to the specified binary writer.
+ ///
+ /// The binary writer to serialize to.
+ public override void SerializeObject(BinaryWriter writer)
+ {
+ if (rootNode == null) return;
+
+ writer.Write(rootNode.ToJsonString());
+ }
+
+ ///
+ /// Disposes the instance.
+ ///
+ public override void Dispose() { }
+
+ ///
+ public override unsafe void Scan(long start, out List items, out long cursor, int count = 10,
+ byte* pattern = default, int patternLength = 0, bool isNoValue = false) =>
+ throw new NotImplementedException();
+
+ ///
+ /// Tries to get the JSON values for the specified paths and writes them to the output stream.
+ ///
+ /// The JSON paths to get the values for.
+ /// The output stream to write the values to.
+ /// The error message if the operation fails.
+ /// The string to use for indentation.
+ /// The string to use for new lines.
+ /// The string to use for spaces.
+ /// True if the operation is successful; otherwise, false.
+ public bool TryGet(ReadOnlySpan paths, Stream output, out ReadOnlySpan errorMessage, string? indent = null, string? newLine = null, string? space = null)
+ {
+ if (paths.Length == 1)
+ {
+ return TryGet(paths[0].ReadOnlySpan, output, out errorMessage, indent, newLine, space);
+ }
+
+ output.WriteByte((byte)'{');
+ var isFirst = true;
+ foreach (var item in paths)
+ {
+ if (!isFirst)
+ {
+ output.WriteByte((byte)',');
+ }
+ isFirst = false;
+
+ output.WriteByte((byte)'"');
+ output.Write(item.ReadOnlySpan);
+ output.WriteByte((byte)'"');
+ output.WriteByte((byte)':');
+
+ if (!TryGet(item.ReadOnlySpan, output, out errorMessage, indent, newLine, space))
+ {
+ return false;
+ }
+ }
+ output.WriteByte((byte)'}');
+
+ errorMessage = default;
+ return true;
+ }
+
+ ///
+ /// Tries to get the JSON value for the specified path and writes it to the output stream.
+ ///
+ /// The JSON path to get the value for.
+ /// The output stream to write the value to.
+ /// The error message if the operation fails.
+ /// The string to use for indentation.
+ /// The string to use for new lines.
+ /// The string to use for spaces.
+ /// True if the operation is successful; otherwise, false.
+ public bool TryGet(ReadOnlySpan path, Stream output, out ReadOnlySpan errorMessage, string? indent = null, string? newLine = null, string? space = null)
+ {
+ try
+ {
+ if (rootNode is null)
+ {
+ errorMessage = default;
+ return true;
+ }
+
+ if (path.Length == 0)
+ {
+ // System.Text.Json doesn't support customizing indentation, new line, and space github/runtime#111899, so for now if any of these are set, we will use the default indented serializer options
+ JsonSerializer.Serialize(output, rootNode, indent is null && newLine is null && space is null ? DefaultJsonSerializerOptions : IndentedJsonSerializerOptions);
+ errorMessage = default;
+ return true;
+ }
+
+ var pathStr = Encoding.UTF8.GetString(path);
+ var result = rootNode.SelectNodes(pathStr);
+
+ output.WriteByte((byte)'[');
+ var isFirst = true;
+ foreach (var item in result)
+ {
+ if (!isFirst)
+ {
+ output.WriteByte((byte)',');
+ }
+ isFirst = false;
+
+ // System.Text.Json doesn't support customizing indentation, new line, and space github/runtime#111899, so for now if any of these are set, we will use the default indented serializer options
+ JsonSerializer.Serialize(output, item, indent is null && newLine is null && space is null ? DefaultJsonSerializerOptions : IndentedJsonSerializerOptions);
+ }
+ output.WriteByte((byte)']');
+ errorMessage = default;
+ return true;
+ }
+ catch (JsonException ex)
+ {
+ errorMessage = Encoding.UTF8.GetBytes(ex.Message);
+ return false;
+ }
+ }
+
+ ///
+ /// Sets the value at the specified JSON path.
+ ///
+ /// The JSON path.
+ /// The value to set.
+ /// The options for existence checks.
+ /// The error message if the operation fails.
+ /// The result of the set operation.
+ /// Thrown when there is an error in JSON processing.
+ public SetResult Set(ReadOnlySpan path, ReadOnlySpan value, ExistOptions existOptions, out ReadOnlySpan errorMessage)
+ {
+ try
+ {
+ var pathStr = Encoding.UTF8.GetString(path);
+
+ if (pathStr.Length == 1 && pathStr[0] == '$')
+ {
+ rootNode = JsonNode.Parse(value);
+ errorMessage = default;
+ return SetResult.Success;
+ }
+
+ if (rootNode is null)
+ {
+ errorMessage = JsonCmdStrings.RESP_NEW_OBJECT_AT_ROOT;
+ return SetResult.Error;
+ }
+
+ // Need ToArray to avoid modifying collection while iterating
+ JsonPath jsonPath = new JsonPath(pathStr);
+ var result = jsonPath.Evaluate(rootNode, rootNode, null).ToArray();
+
+ if (!result.Any())
+ {
+ if (existOptions == ExistOptions.XX)
+ {
+ errorMessage = default;
+ return SetResult.ConditionNotMet;
+ }
+
+ if (!jsonPath.IsStaticPath())
+ {
+ errorMessage = JsonCmdStrings.RESP_WRONG_STATIC_PATH;
+ return SetResult.Error;
+ }
+
+ // Find parent node using parent path
+ var parentNode = rootNode.SelectNodes(GetParentPath(pathStr, out var pathParentOffset)).FirstOrDefault();
+ if (result is null)
+ {
+ errorMessage = default;
+ return SetResult.ConditionNotMet;
+ }
+
+ var childNode = JsonNode.Parse(value);
+ var itemPropName = GetPropertyName(pathStr, pathParentOffset);
+
+ if (parentNode is JsonObject matchObject)
+ {
+ matchObject.Add(itemPropName.ToString(), childNode);
+ }
+ else if (parentNode is JsonArray matchArray && int.TryParse(itemPropName, out var index))
+ {
+ matchArray.Insert(index, childNode);
+ }
+ else
+ {
+ errorMessage = default;
+ return SetResult.ConditionNotMet;
+ }
+
+ errorMessage = default;
+ return SetResult.Success;
+ }
+
+ if (existOptions == ExistOptions.NX)
+ {
+ errorMessage = default;
+ return SetResult.ConditionNotMet;
+ }
+
+ foreach (var match in result.ToList())
+ {
+ var valNode = JsonNode.Parse(value);
+
+ if (rootNode == match)
+ {
+ rootNode = valNode;
+ break;
+ }
+
+ // Known issue: When the value to be replaced is null, replace won't work as there is no NullJsonValue, instead .net returns null
+ match?.ReplaceWith(valNode);
+ }
+
+ errorMessage = default;
+ return SetResult.Success;
+ }
+ catch (JsonException ex)
+ {
+ errorMessage = Encoding.UTF8.GetBytes(ex.Message);
+ return SetResult.Error;
+ }
+ }
+
+ private static string GetParentPath(string path, out int pathOffset)
+ {
+ var pathSpan = path.AsSpan();
+ // Removed the last character from the path to remove the trailing ']' or '.', it shouldn't affect the result even if it doesn't have
+ pathOffset = pathSpan[..^1].LastIndexOfAny('.', ']');
+
+ if (pathOffset == -1)
+ {
+ return "$";
+ }
+
+ if (pathSpan[pathOffset] == ']')
+ {
+ pathOffset++;
+ }
+
+ return path.Substring(0, pathOffset);
+ }
+
+ private static ReadOnlySpan GetPropertyName(string path, int pathOffset)
+ {
+ var pathSpan = path.AsSpan();
+ if (pathSpan[pathOffset] is '.')
+ {
+ pathOffset++;
+ }
+
+ var propertSpan = pathSpan[pathOffset..];
+ if (propertSpan[0] is '[')
+ {
+ propertSpan = propertSpan[1..^1];
+ }
+
+ if (propertSpan[0] is '"' or '\'')
+ {
+ propertSpan = propertSpan[1..^1];
+ }
+
+ return propertSpan;
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/ArrayIndexFilter.cs b/modules/GarnetJSON/JSONPath/ArrayIndexFilter.cs
new file mode 100644
index 0000000000..359e90a2aa
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/ArrayIndexFilter.cs
@@ -0,0 +1,115 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that selects a specific index from a JSON array. Eg: .[0] or .[*]
+ ///
+ internal class ArrayIndexFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the index to filter.
+ ///
+ public int? Index { get; set; }
+
+ ///
+ /// Executes the filter on the given JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ /// Thrown when the index is not valid on the current node and errorWhenNoMatch is true.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ if (Index != null)
+ {
+ if (TryGetTokenIndex(current, Index.GetValueOrDefault(), settings?.ErrorWhenNoMatch ?? false, out var jsonNode))
+ {
+ return [jsonNode];
+ }
+ }
+ else
+ {
+ if (current is JsonArray array)
+ {
+ return array;
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Index * not valid on {0}.", current?.GetType().Name));
+ }
+ }
+ }
+
+ return [];
+ }
+
+ ///
+ /// Executes the filter on the given enumerable of JSON nodes.
+ ///
+ /// The root JSON node.
+ /// The current enumerable of JSON nodes.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ var hasCount = current.TryGetNonEnumeratedCount(out int count);
+ if (hasCount && count == 0)
+ {
+ return [];
+ }
+ else if (count == 1)
+ {
+ return ExecuteFilter(root, current.First(), settings);
+ }
+ else
+ {
+ return ExecuteFilterMultiple(current, settings?.ErrorWhenNoMatch ?? false);
+ }
+ }
+
+ ///
+ /// Executes the filter on multiple JSON nodes.
+ ///
+ /// The current enumerable of JSON nodes.
+ /// Indicates whether to throw an error when no match is found.
+ /// An enumerable of filtered JSON nodes.
+ /// Thrown when the index is not valid on the current node and errorWhenNoMatch is true.
+ private IEnumerable ExecuteFilterMultiple(IEnumerable current, bool errorWhenNoMatch)
+ {
+ foreach (var item in current)
+ {
+ // Note: Not calling ExecuteFilter with yield return because that approach is slower and uses more memory. So we have duplicated code here.
+ if (Index != null)
+ {
+ if (TryGetTokenIndex(item, Index.GetValueOrDefault(), errorWhenNoMatch, out var jsonNode))
+ {
+ yield return jsonNode;
+ }
+ }
+ else
+ {
+ if (item is JsonArray array)
+ {
+ foreach (var v in array)
+ {
+ yield return v;
+ }
+ }
+ else
+ {
+ if (errorWhenNoMatch)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Index * not valid on {0}.", current?.GetType().Name));
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/ArrayMultipleIndexFilter.cs b/modules/GarnetJSON/JSONPath/ArrayMultipleIndexFilter.cs
new file mode 100644
index 0000000000..790c4be89c
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/ArrayMultipleIndexFilter.cs
@@ -0,0 +1,64 @@
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that selects multiple indexes from a JSON array. Eg: .[0,1,2]
+ ///
+ internal class ArrayMultipleIndexFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the list of indexes to filter.
+ ///
+ internal List Indexes;
+
+ ///
+ /// Initializes a new instance of the class with the specified indexes.
+ ///
+ /// The list of indexes to filter.
+ public ArrayMultipleIndexFilter(List indexes)
+ {
+ Indexes = indexes;
+ }
+
+ ///
+ /// Executes the filter on the specified JSON node and returns the filtered nodes.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ foreach (int i in Indexes)
+ {
+ if (TryGetTokenIndex(current, i, settings?.ErrorWhenNoMatch ?? false, out var jsonNode))
+ {
+ yield return jsonNode;
+ }
+ }
+ }
+
+ ///
+ /// Executes the filter on the specified enumerable of JSON nodes and returns the filtered nodes.
+ ///
+ /// The root JSON node.
+ /// The current enumerable of JSON nodes.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ foreach (var item in current)
+ {
+ // Note: Not calling ExecuteFilter with yield return because that approach is slower and uses more memory. So we have duplicated code here.
+ foreach (int i in Indexes)
+ {
+ if (TryGetTokenIndex(item, i, settings?.ErrorWhenNoMatch ?? false, out var jsonNode))
+ {
+ yield return jsonNode;
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/ArraySliceFilter.cs b/modules/GarnetJSON/JSONPath/ArraySliceFilter.cs
new file mode 100644
index 0000000000..9676f74931
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/ArraySliceFilter.cs
@@ -0,0 +1,188 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that slices an array based on the specified start, end, and step values. Eg .[1:5:2]
+ ///
+ internal class ArraySliceFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the start index of the slice.
+ ///
+ public int? Start { get; set; }
+
+ ///
+ /// Gets or sets the end index of the slice.
+ ///
+ public int? End { get; set; }
+
+ ///
+ /// Gets or sets the step value of the slice.
+ ///
+ public int? Step { get; set; }
+
+ ///
+ /// Executes the filter on the specified JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings for JSON selection.
+ /// An enumerable of JSON nodes that match the filter.
+ /// Thrown when the step value is zero or when no match is found and ErrorWhenNoMatch is true.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ if (Step == 0)
+ {
+ throw new JsonException("Step cannot be zero.");
+ }
+
+ if (current is JsonArray array)
+ {
+ int count = array.Count;
+
+ // set defaults for null arguments
+ int stepCount = Step ?? 1;
+ int startIndex = Start ?? ((stepCount > 0) ? 0 : count - 1);
+ int stopIndex = End ?? ((stepCount > 0) ? count : -1);
+
+ // start from the end of the list if start is negative
+ if (Start < 0)
+ {
+ startIndex = count + startIndex;
+ }
+
+ // end from the start of the list if stop is negative
+ if (End < 0)
+ {
+ stopIndex = count + stopIndex;
+ }
+
+ // ensure indexes keep within collection bounds
+ startIndex = Math.Max(startIndex, (stepCount > 0) ? 0 : int.MinValue);
+ startIndex = Math.Min(startIndex, (stepCount > 0) ? count : count - 1);
+ stopIndex = Math.Max(stopIndex, -1);
+ stopIndex = Math.Min(stopIndex, count);
+
+ bool positiveStep = (stepCount > 0);
+
+ if (IsValid(startIndex, stopIndex, positiveStep))
+ {
+ for (int i = startIndex; IsValid(i, stopIndex, positiveStep); i += stepCount)
+ {
+ yield return array[i];
+ }
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Array slice of {0} to {1} returned no results.",
+ Start != null ? Start.GetValueOrDefault().ToString(CultureInfo.InvariantCulture) : "*",
+ End != null ? End.GetValueOrDefault().ToString(CultureInfo.InvariantCulture) : "*"));
+ }
+ }
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Array slice is not valid on {0}.", current?.GetType().Name));
+ }
+ }
+ }
+
+ ///
+ /// Executes the filter on the specified enumerable of JSON nodes.
+ ///
+ /// The root JSON node.
+ /// The current enumerable of JSON nodes.
+ /// The settings for JSON selection.
+ /// An enumerable of JSON nodes that match the filter.
+ /// Thrown when the step value is zero or when no match is found and ErrorWhenNoMatch is true.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ if (Step == 0)
+ {
+ throw new JsonException("Step cannot be zero.");
+ }
+
+ foreach (var item in current)
+ {
+ // Note: Not calling ExecuteFilter with yield return because that approach is slower and uses more memory. So we have duplicated code here.
+ if (item is JsonArray array)
+ {
+ int count = array.Count;
+
+ // set defaults for null arguments
+ int stepCount = Step ?? 1;
+ int startIndex = Start ?? ((stepCount > 0) ? 0 : count - 1);
+ int stopIndex = End ?? ((stepCount > 0) ? count : -1);
+
+ // start from the end of the list if start is negative
+ if (Start < 0)
+ {
+ startIndex = count + startIndex;
+ }
+
+ // end from the start of the list if stop is negative
+ if (End < 0)
+ {
+ stopIndex = count + stopIndex;
+ }
+
+ // ensure indexes keep within collection bounds
+ startIndex = Math.Max(startIndex, (stepCount > 0) ? 0 : int.MinValue);
+ startIndex = Math.Min(startIndex, (stepCount > 0) ? count : count - 1);
+ stopIndex = Math.Max(stopIndex, -1);
+ stopIndex = Math.Min(stopIndex, count);
+
+ bool positiveStep = (stepCount > 0);
+
+ if (IsValid(startIndex, stopIndex, positiveStep))
+ {
+ for (int i = startIndex; IsValid(i, stopIndex, positiveStep); i += stepCount)
+ {
+ yield return array[i];
+ }
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Array slice of {0} to {1} returned no results.",
+ Start != null ? Start.GetValueOrDefault().ToString(CultureInfo.InvariantCulture) : "*",
+ End != null ? End.GetValueOrDefault().ToString(CultureInfo.InvariantCulture) : "*"));
+ }
+ }
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Array slice is not valid on {0}.", current?.GetType().Name));
+ }
+ }
+ }
+ }
+
+ ///
+ /// Determines whether the specified index is valid based on the stop index and step direction.
+ ///
+ /// The current index.
+ /// The stop index.
+ /// A value indicating whether the step is positive.
+ /// true if the index is valid; otherwise, false.
+ private bool IsValid(int index, int stopIndex, bool positiveStep)
+ {
+ if (positiveStep)
+ {
+ return (index < stopIndex);
+ }
+
+ return (index > stopIndex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/FieldFilter.cs b/modules/GarnetJSON/JSONPath/FieldFilter.cs
new file mode 100644
index 0000000000..3be808ef59
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/FieldFilter.cs
@@ -0,0 +1,133 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that selects a specific field from a JSON object. Eg: .field
+ ///
+ internal class FieldFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the name of the field to filter.
+ ///
+ internal string? Name;
+
+ ///
+ /// Initializes a new instance of the class with the specified field name.
+ ///
+ /// The name of the field to filter.
+ public FieldFilter(string? name)
+ {
+ Name = name;
+ }
+
+ ///
+ /// Executes the filter on the specified JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ /// Thrown when the specified field does not exist and is set to true.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ if (current is JsonObject obj)
+ {
+ if (Name is not null)
+ {
+ if (obj.TryGetPropertyValue(Name, out var v))
+ {
+ return [v];
+ }
+ else if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Property '{0}' does not exist on JObject.", Name));
+ }
+ }
+ else
+ {
+ return (obj as IDictionary).Values;
+ }
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Property '{0}' not valid on {1}.", Name is not null ? Name : "*", current?.GetType().Name));
+ }
+ }
+
+ return [];
+ }
+
+ ///
+ /// Executes the filter on the specified enumerable of JSON nodes.
+ ///
+ /// The root JSON node.
+ /// The current enumerable of JSON nodes.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ var hasCount = current.TryGetNonEnumeratedCount(out int count);
+
+ if (hasCount && count == 0)
+ {
+ return [];
+ }
+ else if (count == 1)
+ {
+ return ExecuteFilter(root, current.First(), settings);
+ }
+ else
+ {
+ return ExecuteFilterMultiple(current, settings?.ErrorWhenNoMatch ?? false);
+ }
+ }
+
+ ///
+ /// Executes the filter on multiple JSON nodes.
+ ///
+ /// The current enumerable of JSON nodes.
+ /// Indicates whether to throw an exception when no match is found.
+ /// An enumerable of filtered JSON nodes.
+ /// Thrown when the specified field does not exist and is set to true.
+ private IEnumerable ExecuteFilterMultiple(IEnumerable current, bool errorWhenNoMatch)
+ {
+ foreach (var item in current)
+ {
+ // Note: Not calling ExecuteFilter with yield return because that approach is slower and uses more memory. So we have duplicated code here.
+ if (item is JsonObject obj)
+ {
+ if (Name is not null)
+ {
+ if (obj.TryGetPropertyValue(Name, out var v))
+ {
+ yield return v;
+ }
+ else if (errorWhenNoMatch)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Property '{0}' does not exist on JObject.", Name));
+ }
+ }
+ else
+ {
+ foreach (var p in obj)
+ {
+ yield return p.Value;
+ }
+ }
+ }
+ else
+ {
+ if (errorWhenNoMatch)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Property '{0}' not valid on {1}.", Name is not null ? Name : "*", current?.GetType().Name));
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/FieldMultipleFilter.cs b/modules/GarnetJSON/JSONPath/FieldMultipleFilter.cs
new file mode 100644
index 0000000000..43f107db05
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/FieldMultipleFilter.cs
@@ -0,0 +1,98 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that selects multiple fields from a JSON object. Eg: .["field1", "field2"]
+ ///
+ internal class FieldMultipleFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the list of field names to filter.
+ ///
+ internal List Names;
+
+ ///
+ /// Initializes a new instance of the class with the specified field names.
+ ///
+ /// The list of field names to filter.
+ public FieldMultipleFilter(List names)
+ {
+ Names = names;
+ }
+
+ ///
+ /// Executes the filter on a single JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ /// Thrown when a specified field does not exist and is set to true.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ if (current is JsonObject obj)
+ {
+ foreach (string name in Names)
+ {
+ if (obj.TryGetPropertyValue(name, out var v))
+ {
+ yield return v;
+ }
+
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Property '{0}' does not exist on JsonObject.", name));
+ }
+ }
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Properties {0} not valid on {1}.", string.Join(", ", Names.Select(n => "'" + n + "'")), current?.GetType().Name));
+ }
+ }
+ }
+
+ ///
+ /// Executes the filter on an enumerable of JSON nodes.
+ ///
+ /// The root JSON node.
+ /// The enumerable of current JSON nodes.
+ /// The settings for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ /// Thrown when a specified field does not exist and is set to true.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ foreach (var item in current)
+ {
+ // Note: Not calling ExecuteFilter with yield return because that approach is slower and uses more memory.
+ if (item is JsonObject obj)
+ {
+ foreach (string name in Names)
+ {
+ if (obj.TryGetPropertyValue(name, out var v))
+ {
+ yield return v;
+ }
+
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Property '{0}' does not exist on JsonObject.", name));
+ }
+ }
+ }
+ else
+ {
+ if (settings?.ErrorWhenNoMatch ?? false)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Properties {0} not valid on {1}.", string.Join(", ", Names.Select(n => "'" + n + "'")), item?.GetType().Name));
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/JsonExtensions.cs b/modules/GarnetJSON/JSONPath/JsonExtensions.cs
new file mode 100644
index 0000000000..907635b58c
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/JsonExtensions.cs
@@ -0,0 +1,81 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Provides extension methods for JSON node selection using JSONPath.
+ ///
+ public static class JsonExtensions
+ {
+ private static JsonSelectSettings ErrorWhenNoMatchSettings => new JsonSelectSettings { ErrorWhenNoMatch = true };
+
+ ///
+ /// Tries to select a single JSON node based on the specified JSONPath.
+ ///
+ /// The JSON node to search within.
+ /// The JSONPath expression to evaluate.
+ /// Optional settings for JSONPath evaluation.
+ /// The resulting JSON node if found; otherwise, null.
+ /// True if a single JSON node is found; otherwise, false.
+ /// Thrown if the path returns multiple elements.
+ public static bool TrySelectNode(this JsonNode jsonNode, string path, JsonSelectSettings? settings, out JsonNode? resultJsonNode)
+ {
+ JsonPath p = new JsonPath(path);
+
+ resultJsonNode = null;
+ var count = 0;
+ foreach (var t in p.Evaluate(jsonNode, jsonNode, settings))
+ {
+ count++;
+
+ if (count != 1)
+ {
+ throw new JsonException("Path returned multiple elements.");
+ }
+
+ resultJsonNode = t;
+ }
+
+ return count == 0 ? false : true;
+ }
+
+ ///
+ /// Tries to select a single JSON node based on the specified JSONPath.
+ ///
+ /// The JSON node to search within.
+ /// The JSONPath expression to evaluate.
+ /// The resulting JSON node if found; otherwise, null.
+ /// True if a single JSON node is found; otherwise, false.
+ public static bool TrySelectNode(this JsonNode jsonNode, string path, out JsonNode? resultJsonNode)
+ {
+ return jsonNode.TrySelectNode(path, null, out resultJsonNode);
+ }
+
+ ///
+ /// Selects multiple JSON nodes based on the specified JSONPath.
+ ///
+ /// The JSON node to search within.
+ /// The JSONPath expression to evaluate.
+ /// Optional settings for JSONPath evaluation.
+ /// An enumerable collection of JSON nodes that match the JSONPath.
+ public static IEnumerable SelectNodes(this JsonNode jsonNode, string path, JsonSelectSettings? settings = null)
+ {
+ JsonPath p = new JsonPath(path);
+ return p.Evaluate(jsonNode, jsonNode, settings);
+ }
+
+ ///
+ /// Selects multiple JSON nodes based on the specified JSONPath.
+ ///
+ /// The JSON node to search within.
+ /// The JSONPath expression to evaluate.
+ /// Indicates whether to throw an error if no match is found.
+ /// An enumerable collection of JSON nodes that match the JSONPath.
+ public static IEnumerable SelectNodes(this JsonNode jsonNode, string path, bool errorWhenNoMatch)
+ {
+ JsonPath p = new JsonPath(path);
+ return p.Evaluate(jsonNode, jsonNode, errorWhenNoMatch ? ErrorWhenNoMatchSettings : null);
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/JsonPath.cs b/modules/GarnetJSON/JSONPath/JsonPath.cs
new file mode 100644
index 0000000000..e130ca00be
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/JsonPath.cs
@@ -0,0 +1,1032 @@
+#region License
+// Copyright (c) 2007 James Newton-King
+//
+// 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.Globalization;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a JSON Path expression.
+ ///
+ internal class JsonPath
+ {
+ private static readonly char[] FloatCharacters = ['.', 'E', 'e'];
+ private static readonly JsonValue TrueJsonValue = JsonValue.Create(true);
+ private static readonly JsonValue FalseJsonValue = JsonValue.Create(false);
+
+ private readonly string _expression;
+
+ ///
+ /// Gets the list of path filters.
+ ///
+ ///
+ /// A list of objects that represent the filters in the JSON Path expression.
+ ///
+ public List Filters { get; }
+
+ private int _currentIndex;
+
+ ///
+ /// Initializes a new instance of the class with the specified expression.
+ ///
+ /// The JSON Path expression.
+ /// Thrown when the expression is null.
+ public JsonPath(string expression)
+ {
+ ArgumentNullException.ThrowIfNull(expression);
+ _expression = expression;
+ Filters = new List();
+
+ ParseMain();
+ }
+
+ ///
+ /// Determines whether the current JPath is a static path.
+ /// A static path is guaranteed to return at most one result.
+ ///
+ /// true if the path is static; otherwise, false.
+ internal bool IsStaticPath()
+ {
+ return Filters.All(filter => filter switch
+ {
+ FieldFilter fieldFilter => fieldFilter.Name is not null,
+ ArrayIndexFilter arrayFilter => arrayFilter.Index.HasValue,
+ RootFilter => true,
+ _ => false
+ });
+ }
+
+ ///
+ /// Evaluates the JSON Path expression against the provided JSON nodes.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings used for JSON selection.
+ /// An enumerable of JSON nodes that match the JSON Path expression.
+ internal IEnumerable Evaluate(JsonNode root, JsonNode? t, JsonSelectSettings? settings)
+ {
+ return Evaluate(Filters, root, t, settings);
+ }
+
+ ///
+ /// Evaluates the JSON Path expression against the provided JSON nodes using the specified filters.
+ ///
+ /// The list of path filters to apply.
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings used for JSON selection.
+ /// An enumerable of JSON nodes that match the JSON Path expression.
+ internal static IEnumerable Evaluate(List filters, JsonNode root, JsonNode? t, JsonSelectSettings? settings)
+ {
+ if (filters.Count >= 1)
+ {
+ var firstFilter = filters[0];
+ var current = firstFilter.ExecuteFilter(root, t, settings);
+
+ for (int i = 1; i < filters.Count; i++)
+ {
+ current = filters[i].ExecuteFilter(root, current, settings);
+ }
+ return current;
+ }
+ else
+ {
+ return new List { t };
+ }
+ }
+
+ ///
+ /// Parses the main JSON Path expression and builds the filter list.
+ ///
+ private void ParseMain()
+ {
+ int currentPartStartIndex = _currentIndex;
+
+ EatWhitespace();
+
+ if (_expression.Length == _currentIndex)
+ {
+ return;
+ }
+
+ if (_expression[_currentIndex] == '$')
+ {
+ if (_expression.Length == 1)
+ {
+ return;
+ }
+
+ // only increment position for "$." or "$["
+ // otherwise assume property that starts with $
+ char c = _expression[_currentIndex + 1];
+ if (c == '.' || c == '[')
+ {
+ _currentIndex++;
+ currentPartStartIndex = _currentIndex;
+ }
+ }
+
+ if (!ParsePath(Filters, currentPartStartIndex, false))
+ {
+ int lastCharacterIndex = _currentIndex;
+
+ EatWhitespace();
+
+ if (_currentIndex < _expression.Length)
+ {
+ throw new JsonException("Unexpected character while parsing path: " + _expression[lastCharacterIndex]);
+ }
+ }
+ }
+
+ ///
+ /// Parses a path segment and adds appropriate filters to the filter list.
+ ///
+ /// The list to add parsed filters to.
+ /// The starting index of the current path segment.
+ /// Indicates if parsing within a query context.
+ /// True if parsing reached the end of the expression; otherwise, false.
+ private bool ParsePath(List filters, int currentPartStartIndex, bool query)
+ {
+ bool scan = false;
+ bool followingIndexer = false;
+ bool followingDot = false;
+
+ bool ended = false;
+ while (_currentIndex < _expression.Length && !ended)
+ {
+ char currentChar = _expression[_currentIndex];
+
+ switch (currentChar)
+ {
+ case '[':
+ case '(':
+ if (_currentIndex > currentPartStartIndex)
+ {
+ string? member = _expression.Substring(currentPartStartIndex, _currentIndex - currentPartStartIndex);
+ if (member == "*")
+ {
+ member = null;
+ }
+
+ filters.Add(CreatePathFilter(member, scan));
+ scan = false;
+ }
+
+ filters.Add(ParseIndexer(currentChar, scan));
+ scan = false;
+
+ _currentIndex++;
+ currentPartStartIndex = _currentIndex;
+ followingIndexer = true;
+ followingDot = false;
+ break;
+ case ']':
+ case ')':
+ ended = true;
+ break;
+ case ' ':
+ if (_currentIndex < _expression.Length)
+ {
+ ended = true;
+ }
+ break;
+ case '.':
+ if (_currentIndex > currentPartStartIndex)
+ {
+ string? member = _expression.Substring(currentPartStartIndex, _currentIndex - currentPartStartIndex);
+ if (member == "*")
+ {
+ member = null;
+ }
+
+ filters.Add(CreatePathFilter(member, scan));
+ scan = false;
+ }
+ if (_currentIndex + 1 < _expression.Length && _expression[_currentIndex + 1] == '.')
+ {
+ scan = true;
+ _currentIndex++;
+ }
+ _currentIndex++;
+ currentPartStartIndex = _currentIndex;
+ followingIndexer = false;
+ followingDot = true;
+ break;
+ default:
+ if (query && (currentChar == '=' || currentChar == '<' || currentChar == '!' || currentChar == '>' || currentChar == '|' || currentChar == '&'))
+ {
+ ended = true;
+ }
+ else
+ {
+ if (followingIndexer)
+ {
+ throw new JsonException("Unexpected character following indexer: " + currentChar);
+ }
+
+ _currentIndex++;
+ }
+ break;
+ }
+ }
+
+ bool atPathEnd = (_currentIndex == _expression.Length);
+
+ if (_currentIndex > currentPartStartIndex)
+ {
+ // TODO: Check performance of using AsSpan and TrimEnd then convert to string for the critical path
+ string? member = _expression.Substring(currentPartStartIndex, _currentIndex - currentPartStartIndex).TrimEnd();
+ if (member == "*")
+ {
+ member = null;
+ }
+ filters.Add(CreatePathFilter(member, scan));
+ }
+ else
+ {
+ // no field name following dot in path and at end of base path/query
+ if (followingDot && (atPathEnd || query))
+ {
+ throw new JsonException("Unexpected end while parsing path.");
+ }
+ }
+
+ return atPathEnd;
+ }
+
+ ///
+ /// Creates a path filter based on the member name and scan flag.
+ ///
+ /// The member name for the filter.
+ /// Indicates if this is a scan operation.
+ /// A new PathFilter instance.
+ private static PathFilter CreatePathFilter(string? member, bool scan)
+ {
+ PathFilter filter = (scan) ? (PathFilter)new ScanFilter(member) : new FieldFilter(member);
+ return filter;
+ }
+
+ ///
+ /// Parses an indexer expression starting with '[' or '('.
+ ///
+ /// The opening character of the indexer.
+ /// Indicates if this is a scan operation.
+ /// A PathFilter representing the parsed indexer.
+ private PathFilter ParseIndexer(char indexerOpenChar, bool scan)
+ {
+ _currentIndex++;
+
+ char indexerCloseChar = (indexerOpenChar == '[') ? ']' : ')';
+
+ EnsureLength("Path ended with open indexer.");
+
+ EatWhitespace();
+
+ if (_expression[_currentIndex] == '\'')
+ {
+ return ParseQuotedField(indexerCloseChar, scan);
+ }
+ else if (_expression[_currentIndex] == '?')
+ {
+ return ParseQuery(indexerCloseChar, scan);
+ }
+ else
+ {
+ return ParseArrayIndexer(indexerCloseChar);
+ }
+ }
+
+ ///
+ /// Parses an array indexer expression, supporting single index, multiple indexes, or slice notation.
+ ///
+ /// The closing character of the indexer.
+ /// A PathFilter representing the array indexer.
+ private PathFilter ParseArrayIndexer(char indexerCloseChar)
+ {
+ int start = _currentIndex;
+ int? end = null;
+ List? indexes = null;
+ int colonCount = 0;
+ int? startIndex = null;
+ int? endIndex = null;
+ int? step = null;
+
+ while (_currentIndex < _expression.Length)
+ {
+ char currentCharacter = _expression[_currentIndex];
+
+ if (currentCharacter == ' ')
+ {
+ end = _currentIndex;
+ EatWhitespace();
+ continue;
+ }
+
+ if (currentCharacter == indexerCloseChar)
+ {
+ int length = (end ?? _currentIndex) - start;
+
+ if (indexes != null)
+ {
+ if (length == 0)
+ {
+ throw new JsonException("Array index expected.");
+ }
+
+ var indexer = _expression.AsSpan(start, length);
+ int index = int.Parse(indexer, CultureInfo.InvariantCulture);
+
+ indexes.Add(index);
+ return new ArrayMultipleIndexFilter(indexes);
+ }
+ else if (colonCount > 0)
+ {
+ if (length > 0)
+ {
+ var indexer = _expression.AsSpan(start, length);
+ int index = int.Parse(indexer, CultureInfo.InvariantCulture);
+
+ if (colonCount == 1)
+ {
+ endIndex = index;
+ }
+ else
+ {
+ step = index;
+ }
+ }
+
+ return new ArraySliceFilter { Start = startIndex, End = endIndex, Step = step };
+ }
+ else
+ {
+ if (length == 0)
+ {
+ throw new JsonException("Array index expected.");
+ }
+
+ var indexer = _expression.AsSpan(start, length);
+ int index = int.Parse(indexer, CultureInfo.InvariantCulture);
+
+ return new ArrayIndexFilter { Index = index };
+ }
+ }
+ else if (currentCharacter == ',')
+ {
+ int length = (end ?? _currentIndex) - start;
+
+ if (length == 0)
+ {
+ throw new JsonException("Array index expected.");
+ }
+
+ if (indexes == null)
+ {
+ indexes = new List();
+ }
+
+ var indexer = _expression.AsSpan(start, length);
+ indexes.Add(int.Parse(indexer, CultureInfo.InvariantCulture));
+
+ _currentIndex++;
+
+ EatWhitespace();
+
+ start = _currentIndex;
+ end = null;
+ }
+ else if (currentCharacter == '*')
+ {
+ _currentIndex++;
+ EnsureLength("Path ended with open indexer.");
+ EatWhitespace();
+
+ if (_expression[_currentIndex] != indexerCloseChar)
+ {
+ throw new JsonException("Unexpected character while parsing path indexer: " + currentCharacter);
+ }
+
+ return new ArrayIndexFilter();
+ }
+ else if (currentCharacter == ':')
+ {
+ int length = (end ?? _currentIndex) - start;
+
+ if (length > 0)
+ {
+ var indexer = _expression.AsSpan(start, length);
+ int index = int.Parse(indexer, CultureInfo.InvariantCulture);
+
+ if (colonCount == 0)
+ {
+ startIndex = index;
+ }
+ else if (colonCount == 1)
+ {
+ endIndex = index;
+ }
+ else
+ {
+ step = index;
+ }
+ }
+
+ colonCount++;
+
+ _currentIndex++;
+
+ EatWhitespace();
+
+ start = _currentIndex;
+ end = null;
+ }
+ else if (!char.IsDigit(currentCharacter) && currentCharacter != '-')
+ {
+ throw new JsonException("Unexpected character while parsing path indexer: " + currentCharacter);
+ }
+ else
+ {
+ if (end != null)
+ {
+ throw new JsonException("Unexpected character while parsing path indexer: " + currentCharacter);
+ }
+
+ _currentIndex++;
+ }
+ }
+
+ throw new JsonException("Path ended with open indexer.");
+ }
+
+ ///
+ /// Advances the current index past any whitespace characters.
+ ///
+ private void EatWhitespace()
+ {
+ while (_currentIndex < _expression.Length)
+ {
+ if (_expression[_currentIndex] != ' ')
+ {
+ break;
+ }
+
+ _currentIndex++;
+ }
+ }
+
+ ///
+ /// Parses a query expression within an indexer.
+ ///
+ /// The closing character of the indexer.
+ /// Indicates if this is a scan operation.
+ /// A QueryFilter or QueryScanFilter based on the parsed expression.
+ private PathFilter ParseQuery(char indexerCloseChar, bool scan)
+ {
+ _currentIndex++;
+ EnsureLength("Path ended with open indexer.");
+
+ if (_expression[_currentIndex] != '(')
+ {
+ throw new JsonException("Unexpected character while parsing path indexer: " + _expression[_currentIndex]);
+ }
+
+ _currentIndex++;
+
+ QueryExpression expression = ParseExpression();
+
+ _currentIndex++;
+ EnsureLength("Path ended with open indexer.");
+ EatWhitespace();
+
+ if (_expression[_currentIndex] != indexerCloseChar)
+ {
+ throw new JsonException("Unexpected character while parsing path indexer: " + _expression[_currentIndex]);
+ }
+
+ if (!scan)
+ {
+ return new QueryFilter(expression);
+ }
+ else
+ {
+ return new QueryScanFilter(expression);
+ }
+ }
+
+ ///
+ /// Attempts to parse an expression starting with '$' or '@'.
+ ///
+ /// When successful, contains the list of filters for the expression.
+ /// True if successfully parsed an expression; otherwise, false.
+ private bool TryParseExpression(out List? expressionPath)
+ {
+ if (_expression[_currentIndex] == '$')
+ {
+ expressionPath = new List { RootFilter.Instance };
+ }
+ else if (_expression[_currentIndex] == '@')
+ {
+ expressionPath = new List();
+ }
+ else
+ {
+ expressionPath = null;
+ return false;
+ }
+
+ _currentIndex++;
+
+ if (ParsePath(expressionPath!, _currentIndex, true))
+ {
+ throw new JsonException("Path ended with open query.");
+ }
+
+ return true;
+ }
+
+ ///
+ /// Creates a JsonException for unexpected characters encountered during parsing.
+ ///
+ /// A new JsonException with details about the unexpected character.
+ private JsonException CreateUnexpectedCharacterException()
+ {
+ return new JsonException("Unexpected character while parsing path query: " + _expression[_currentIndex]);
+ }
+
+ ///
+ /// Parses one side of a query expression.
+ ///
+ /// An object representing either a path filter list or a value.
+ private object? ParseSide()
+ {
+ EatWhitespace();
+
+ if (TryParseExpression(out List? expressionPath))
+ {
+ EatWhitespace();
+ EnsureLength("Path ended with open query.");
+
+ return expressionPath;
+ }
+
+ if (TryParseValue(out var value))
+ {
+ EatWhitespace();
+ EnsureLength("Path ended with open query.");
+
+ return value;
+ }
+
+ throw CreateUnexpectedCharacterException();
+ }
+
+ ///
+ /// Parses a complete query expression, including boolean operations.
+ ///
+ /// A QueryExpression representing the parsed expression.
+ private QueryExpression ParseExpression()
+ {
+ QueryExpression? rootExpression = null;
+ CompositeExpression? parentExpression = null;
+
+ while (_currentIndex < _expression.Length)
+ {
+ object? left = ParseSide();
+ object? right = null;
+
+ QueryOperator op;
+ if (_expression[_currentIndex] == ')'
+ || _expression[_currentIndex] == '|'
+ || _expression[_currentIndex] == '&')
+ {
+ op = QueryOperator.Exists;
+ }
+ else
+ {
+ op = ParseOperator();
+
+ right = ParseSide();
+ }
+
+ BooleanQueryExpression booleanExpression = new BooleanQueryExpression(op, left, right);
+
+ if (_expression[_currentIndex] == ')')
+ {
+ if (parentExpression != null)
+ {
+ parentExpression.Expressions.Add(booleanExpression);
+ return rootExpression!;
+ }
+
+ return booleanExpression;
+ }
+ if (_expression[_currentIndex] == '&')
+ {
+ if (!Match("&&"))
+ {
+ throw CreateUnexpectedCharacterException();
+ }
+
+ if (parentExpression == null || parentExpression.Operator != QueryOperator.And)
+ {
+ CompositeExpression andExpression = new CompositeExpression(QueryOperator.And);
+
+ parentExpression?.Expressions.Add(andExpression);
+
+ parentExpression = andExpression;
+
+ if (rootExpression == null)
+ {
+ rootExpression = parentExpression;
+ }
+ }
+
+ parentExpression.Expressions.Add(booleanExpression);
+ }
+ if (_expression[_currentIndex] == '|')
+ {
+ if (!Match("||"))
+ {
+ throw CreateUnexpectedCharacterException();
+ }
+
+ if (parentExpression == null || parentExpression.Operator != QueryOperator.Or)
+ {
+ CompositeExpression orExpression = new CompositeExpression(QueryOperator.Or);
+
+ parentExpression?.Expressions.Add(orExpression);
+
+ parentExpression = orExpression;
+
+ if (rootExpression == null)
+ {
+ rootExpression = parentExpression;
+ }
+ }
+
+ parentExpression.Expressions.Add(booleanExpression);
+ }
+ }
+
+ throw new JsonException("Path ended with open query.");
+ }
+
+ ///
+ /// Attempts to parse a JSON value (string, number, boolean, or null).
+ ///
+ /// When successful, contains the parsed JsonValue.
+ /// True if successfully parsed a value; otherwise, false.
+ private bool TryParseValue(out JsonValue? value)
+ {
+ char currentChar = _expression[_currentIndex];
+ if (currentChar == '\'')
+ {
+ value = JsonValue.Create(ReadQuotedString());
+ return true;
+ }
+ else if (char.IsDigit(currentChar) || currentChar == '-')
+ {
+ var start = _currentIndex;
+ _currentIndex++;
+ while (_currentIndex < _expression.Length)
+ {
+ currentChar = _expression[_currentIndex];
+ if (currentChar == ' ' || currentChar == ')')
+ {
+ var numberText = _expression.AsSpan(start, _currentIndex - start);
+
+ if (numberText.IndexOfAny(FloatCharacters) != -1)
+ {
+ bool result = double.TryParse(numberText, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var d);
+ value = JsonValue.Create(d);
+ return result;
+ }
+ else
+ {
+ bool result = long.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l);
+ value = JsonValue.Create(l);
+ return result;
+ }
+ }
+ else
+ {
+ _currentIndex++;
+ }
+ }
+ }
+ else if (currentChar == 't')
+ {
+ if (Match("true"))
+ {
+ value = TrueJsonValue;
+ return true;
+ }
+ }
+ else if (currentChar == 'f')
+ {
+ if (Match("false"))
+ {
+ value = FalseJsonValue;
+ return true;
+ }
+ }
+ else if (currentChar == 'n')
+ {
+ if (Match("null"))
+ {
+ value = null;
+ return true;
+ }
+ }
+ else if (currentChar == '/')
+ {
+ value = JsonValue.Create(ReadRegexString());
+ return true;
+ }
+
+ value = null;
+ return false;
+ }
+
+ ///
+ /// Reads a quoted string value, handling escape sequences.
+ ///
+ /// The parsed string value.
+ private string ReadQuotedString()
+ {
+ StringBuilder sb = new StringBuilder();
+
+ _currentIndex++;
+ while (_currentIndex < _expression.Length)
+ {
+ char currentChar = _expression[_currentIndex];
+ if (currentChar == '\\' && _currentIndex + 1 < _expression.Length)
+ {
+ _currentIndex++;
+ currentChar = _expression[_currentIndex];
+
+ char resolvedChar;
+ switch (currentChar)
+ {
+ case 'b':
+ resolvedChar = '\b';
+ break;
+ case 't':
+ resolvedChar = '\t';
+ break;
+ case 'n':
+ resolvedChar = '\n';
+ break;
+ case 'f':
+ resolvedChar = '\f';
+ break;
+ case 'r':
+ resolvedChar = '\r';
+ break;
+ case '\\':
+ case '"':
+ case '\'':
+ case '/':
+ resolvedChar = currentChar;
+ break;
+ default:
+ throw new JsonException(@"Unknown escape character: \" + currentChar);
+ }
+
+ sb.Append(resolvedChar);
+
+ _currentIndex++;
+ }
+ else if (currentChar == '\'')
+ {
+ _currentIndex++;
+ return sb.ToString();
+ }
+ else
+ {
+ _currentIndex++;
+ sb.Append(currentChar);
+ }
+ }
+
+ throw new JsonException("Path ended with an open string.");
+ }
+
+ ///
+ /// Reads a regular expression string, including any flags.
+ ///
+ /// The complete regular expression string including delimiters and flags.
+ private string ReadRegexString()
+ {
+ int startIndex = _currentIndex;
+
+ _currentIndex++;
+ while (_currentIndex < _expression.Length)
+ {
+ char currentChar = _expression[_currentIndex];
+
+ // handle escaped / character
+ if (currentChar == '\\' && _currentIndex + 1 < _expression.Length)
+ {
+ _currentIndex += 2;
+ }
+ else if (currentChar == '/')
+ {
+ _currentIndex++;
+
+ while (_currentIndex < _expression.Length)
+ {
+ currentChar = _expression[_currentIndex];
+
+ if (char.IsLetter(currentChar))
+ {
+ _currentIndex++;
+ }
+ else
+ {
+ break;
+ }
+ }
+
+ return _expression.Substring(startIndex, _currentIndex - startIndex);
+ }
+ else
+ {
+ _currentIndex++;
+ }
+ }
+
+ throw new JsonException("Path ended with an open regex.");
+ }
+
+ ///
+ /// Attempts to match a specific string at the current position.
+ ///
+ /// The string to match.
+ /// True if the string matches at the current position; otherwise, false.
+ private bool Match(string s)
+ {
+ int currentPosition = _currentIndex;
+ for (int i = 0; i < s.Length; i++)
+ {
+ if (currentPosition < _expression.Length && _expression[currentPosition] == s[i])
+ {
+ currentPosition++;
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ _currentIndex = currentPosition;
+ return true;
+ }
+
+ ///
+ /// Parses a comparison operator in a query expression.
+ ///
+ /// The QueryOperator enum value representing the parsed operator.
+ private QueryOperator ParseOperator()
+ {
+ if (_currentIndex + 1 >= _expression.Length)
+ {
+ throw new JsonException("Path ended with open query.");
+ }
+
+ if (Match("==="))
+ {
+ return QueryOperator.StrictEquals;
+ }
+
+ if (Match("=="))
+ {
+ return QueryOperator.Equals;
+ }
+
+ if (Match("=~"))
+ {
+ return QueryOperator.RegexEquals;
+ }
+
+ if (Match("!=="))
+ {
+ return QueryOperator.StrictNotEquals;
+ }
+
+ if (Match("!=") || Match("<>"))
+ {
+ return QueryOperator.NotEquals;
+ }
+ if (Match("<="))
+ {
+ return QueryOperator.LessThanOrEquals;
+ }
+ if (Match("<"))
+ {
+ return QueryOperator.LessThan;
+ }
+ if (Match(">="))
+ {
+ return QueryOperator.GreaterThanOrEquals;
+ }
+ if (Match(">"))
+ {
+ return QueryOperator.GreaterThan;
+ }
+
+ throw new JsonException("Could not read query operator.");
+ }
+
+ ///
+ /// Parses a quoted field name in an indexer, supporting single or multiple field names.
+ ///
+ /// The closing character of the indexer.
+ /// Indicates if this is a scan operation.
+ /// A PathFilter representing the field(s) access.
+ private PathFilter ParseQuotedField(char indexerCloseChar, bool scan)
+ {
+ List? fields = null;
+
+ while (_currentIndex < _expression.Length)
+ {
+ string field = ReadQuotedString();
+
+ EatWhitespace();
+ EnsureLength("Path ended with open indexer.");
+
+ if (_expression[_currentIndex] == indexerCloseChar)
+ {
+ if (fields != null)
+ {
+ fields.Add(field);
+ return (scan)
+ ? (PathFilter)new ScanMultipleFilter(fields)
+ : (PathFilter)new FieldMultipleFilter(fields);
+ }
+ else
+ {
+ return CreatePathFilter(field, scan);
+ }
+ }
+ else if (_expression[_currentIndex] == ',')
+ {
+ _currentIndex++;
+ EatWhitespace();
+
+ if (fields == null)
+ {
+ fields = new List();
+ }
+
+ fields.Add(field);
+ }
+ else
+ {
+ throw new JsonException("Unexpected character while parsing path indexer: " + _expression[_currentIndex]);
+ }
+ }
+
+ throw new JsonException("Path ended with open indexer.");
+ }
+
+ ///
+ /// Ensures that there are remaining characters to parse in the expression.
+ ///
+ /// The error message to use if the check fails.
+ /// Thrown when there are no remaining characters to parse.
+ private void EnsureLength(string message)
+ {
+ if (_currentIndex >= _expression.Length)
+ {
+ throw new JsonException(message);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/JsonSelectSettings.cs b/modules/GarnetJSON/JSONPath/JsonSelectSettings.cs
new file mode 100644
index 0000000000..cf3331d21a
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/JsonSelectSettings.cs
@@ -0,0 +1,24 @@
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Specifies the settings used when selecting JSON.
+ ///
+ public class JsonSelectSettings
+ {
+ ///
+ /// Gets or sets a timeout that will be used when executing regular expressions.
+ ///
+ /// The timeout that will be used when executing regular expressions.
+ public TimeSpan? RegexMatchTimeout { get; set; }
+
+ ///
+ /// Gets or sets a flag that indicates whether an error should be thrown if
+ /// no tokens are found when evaluating part of the expression.
+ ///
+ ///
+ /// A flag that indicates whether an error should be thrown if
+ /// no tokens are found when evaluating part of the expression.
+ ///
+ public bool ErrorWhenNoMatch { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/PathFilter.cs b/modules/GarnetJSON/JSONPath/PathFilter.cs
new file mode 100644
index 0000000000..fd4ef06ecd
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/PathFilter.cs
@@ -0,0 +1,68 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Abstract base class representing a filter in a JSONPath query.
+ ///
+ public abstract class PathFilter
+ {
+ ///
+ /// Executes the filter on a single JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings for JSON selection.
+ /// An enumerable of JSON nodes that match the filter.
+ public abstract IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings);
+
+ ///
+ /// Executes the filter on a collection of JSON nodes.
+ ///
+ /// The root JSON node.
+ /// The collection of current JSON nodes.
+ /// The settings for JSON selection.
+ /// An enumerable of JSON nodes that match the filter.
+ public abstract IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings);
+
+ ///
+ /// Tries to get a JSON node at the specified index from a JSON array.
+ ///
+ /// The JSON node, expected to be a JSON array.
+ /// The index of the element to retrieve.
+ /// Whether to throw an error if the index is out of bounds.
+ /// The JSON node at the specified index, if found.
+ /// True if the JSON node was found at the specified index; otherwise, false.
+ /// Thrown when the index is out of bounds and is true.
+ protected static bool TryGetTokenIndex(JsonNode? t, int index, bool errorWhenNoMatch, out JsonNode? jsonNode)
+ {
+ jsonNode = default;
+ if (t is JsonArray a)
+ {
+ if (a.Count <= index)
+ {
+ if (errorWhenNoMatch)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Index {0} outside the bounds of JArray.", index));
+ }
+
+ return false;
+ }
+
+ jsonNode = a[index];
+ return true;
+ }
+ else
+ {
+ if (errorWhenNoMatch)
+ {
+ throw new JsonException(string.Format(CultureInfo.InvariantCulture, "Index {0} not valid on {1}.", index, t?.GetType().Name));
+ }
+
+ return false;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/QueryExpression.cs b/modules/GarnetJSON/JSONPath/QueryExpression.cs
new file mode 100644
index 0000000000..fd2559dea5
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/QueryExpression.cs
@@ -0,0 +1,462 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents the various query operators that can be used in JSONPath queries.
+ ///
+ internal enum QueryOperator
+ {
+ None = 0,
+ Equals = 1,
+ NotEquals = 2,
+ Exists = 3,
+ LessThan = 4,
+ LessThanOrEquals = 5,
+ GreaterThan = 6,
+ GreaterThanOrEquals = 7,
+ And = 8,
+ Or = 9,
+ RegexEquals = 10,
+ StrictEquals = 11,
+ StrictNotEquals = 12
+ }
+
+ ///
+ /// Abstract base class for query expressions used in JSONPath queries.
+ ///
+ internal abstract class QueryExpression
+ {
+ ///
+ /// Gets or sets the query operator for the expression.
+ ///
+ internal QueryOperator Operator;
+
+ ///
+ /// Initializes a new instance of the class with the specified operator.
+ ///
+ /// The query operator.
+ public QueryExpression(QueryOperator @operator)
+ {
+ Operator = @operator;
+ }
+
+ ///
+ /// Determines whether the specified JSON node matches the query expression.
+ ///
+ /// The root JSON node.
+ /// The target JSON node.
+ /// The JSON select settings.
+ /// true if the JSON node matches the query expression; otherwise, false.
+ public abstract bool IsMatch(JsonNode root, JsonNode? t, JsonSelectSettings? settings = null);
+ }
+
+ ///
+ /// Represents a composite query expression that combines multiple expressions using a logical operator.
+ ///
+ internal class CompositeExpression : QueryExpression
+ {
+ ///
+ /// Gets or sets the list of query expressions.
+ ///
+ public List Expressions { get; set; }
+
+ ///
+ /// Initializes a new instance of the class with the specified operator.
+ ///
+ /// The query operator.
+ public CompositeExpression(QueryOperator @operator) : base(@operator)
+ {
+ Expressions = new List();
+ }
+
+ ///
+ /// Determines whether the specified JSON node matches the composite query expression.
+ ///
+ /// The root JSON node.
+ /// The target JSON node.
+ /// The JSON select settings.
+ /// true if the JSON node matches the composite query expression; otherwise, false.
+ public override bool IsMatch(JsonNode root, JsonNode? t, JsonSelectSettings? settings = null)
+ {
+ switch (Operator)
+ {
+ case QueryOperator.And:
+ foreach (QueryExpression e in Expressions)
+ {
+ if (!e.IsMatch(root, t, settings))
+ {
+ return false;
+ }
+ }
+ return true;
+ case QueryOperator.Or:
+ foreach (QueryExpression e in Expressions)
+ {
+ if (e.IsMatch(root, t, settings))
+ {
+ return true;
+ }
+ }
+ return false;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+ }
+
+ ///
+ /// Represents a boolean query expression that compares two values using a specified operator.
+ ///
+ internal class BooleanQueryExpression : QueryExpression
+ {
+ ///
+ /// Gets the left operand of the boolean expression.
+ ///
+ public readonly object? Left;
+
+ ///
+ /// Gets the right operand of the boolean expression.
+ ///
+ public readonly object? Right;
+
+ ///
+ /// Initializes a new instance of the class with the specified operator and operands.
+ ///
+ /// The query operator.
+ /// The left operand.
+ /// The right operand.
+ public BooleanQueryExpression(QueryOperator @operator, object? left, object? right) : base(@operator)
+ {
+ Left = left;
+ Right = right;
+ }
+
+ ///
+ /// Determines whether the specified JSON node matches the boolean query expression.
+ ///
+ /// The root JSON node.
+ /// The target JSON node.
+ /// The JSON select settings.
+ /// true if the JSON node matches the boolean query expression; otherwise, false.
+ public override bool IsMatch(JsonNode root, JsonNode? t, JsonSelectSettings? settings = null)
+ {
+ if (Operator == QueryOperator.Exists)
+ {
+ return Left is List left ? JsonPath.Evaluate(left, root, t, settings).Any() : true;
+ }
+
+ if (Left is List leftPath)
+ {
+ foreach (var leftResult in JsonPath.Evaluate(leftPath, root, t, settings))
+ {
+ if (EvaluateMatch(root, t, settings, leftResult))
+ {
+ return true;
+ }
+ }
+ }
+ else if (Left is JsonNode left)
+ {
+ return EvaluateMatch(root, t, settings, left);
+ }
+ else if (Left is null)
+ {
+ return EvaluateMatch(root, t, settings, null);
+ }
+
+ return false;
+
+ bool EvaluateMatch(JsonNode root, JsonNode? t, JsonSelectSettings? settings, JsonNode? leftResult)
+ {
+ if (Right is List right)
+ {
+ foreach (var rightResult in JsonPath.Evaluate(right, root, t, settings))
+ {
+ if (MatchTokens(leftResult, rightResult, settings))
+ {
+ return true;
+ }
+ }
+ }
+ else if (Right is JsonNode rightNode)
+ {
+ return MatchTokens(leftResult, rightNode, settings);
+ }
+ else if (Right is null)
+ {
+ return MatchTokens(leftResult, null, settings);
+ }
+
+ return false;
+ }
+ }
+
+ private bool MatchTokens(JsonNode? leftResult, JsonNode? rightResult, JsonSelectSettings? settings)
+ {
+ if (leftResult is JsonValue or null && rightResult is JsonValue or null)
+ {
+ var left = leftResult as JsonValue;
+ var right = rightResult as JsonValue;
+ switch (Operator)
+ {
+ case QueryOperator.RegexEquals:
+ return RegexEquals(left, right, settings);
+ case QueryOperator.Equals:
+ return EqualsWithStringCoercion(left, right);
+ case QueryOperator.StrictEquals:
+ return EqualsWithStrictMatch(left, right);
+ case QueryOperator.NotEquals:
+ return !EqualsWithStringCoercion(left, right);
+ case QueryOperator.StrictNotEquals:
+ return !EqualsWithStrictMatch(left, right);
+ case QueryOperator.GreaterThan:
+ return CompareTo(left, right) > 0;
+ case QueryOperator.GreaterThanOrEquals:
+ return CompareTo(left, right) >= 0;
+ case QueryOperator.LessThan:
+ return CompareTo(left, right) < 0;
+ case QueryOperator.LessThanOrEquals:
+ return CompareTo(left, right) <= 0;
+ case QueryOperator.Exists:
+ return true;
+ }
+ }
+ else
+ {
+ switch (Operator)
+ {
+ case QueryOperator.Exists:
+ case QueryOperator.NotEquals:
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ internal static int CompareTo(JsonValue? leftValue, JsonValue? rightValue)
+ {
+ if (leftValue is null)
+ {
+ return rightValue is null ? 0 : -1;
+ }
+
+ if (rightValue is null)
+ {
+ return 1;
+ }
+
+ if (leftValue.GetValueKind() == rightValue.GetValueKind())
+ {
+ if (leftValue is null)
+ {
+ return 0;
+ }
+
+ switch (leftValue.GetValueKind())
+ {
+ case JsonValueKind.False:
+ case JsonValueKind.True:
+ case JsonValueKind.Null:
+ case JsonValueKind.Undefined:
+ return 0;
+ case JsonValueKind.String:
+ return leftValue.GetValue()!.CompareTo(rightValue!.GetValue());
+ case JsonValueKind.Number:
+ if (leftValue.TryGetValue(out var left))
+ {
+ return rightValue.TryGetValue(out var right) ? left.CompareTo(right) : left.CompareTo((long)rightValue.GetValue());
+ }
+ else
+ {
+ return rightValue.TryGetValue(out var right) ? ((long)leftValue.GetValue()).CompareTo(right) : leftValue.GetValue().CompareTo(rightValue.GetValue());
+ }
+ default:
+ throw new InvalidOperationException($"Can compare only value types, but the current type is: {leftValue.GetValueKind()}");
+ }
+ }
+
+ if (IsBoolean(leftValue) && IsBoolean(rightValue))
+ {
+ return leftValue.GetValue().CompareTo(rightValue.GetValue());
+ }
+
+ if (TryGetAsDouble(leftValue, out double leftNum) && TryGetAsDouble(rightValue, out double rightNum))
+ {
+ return leftNum.CompareTo(rightNum);
+ }
+
+ if (leftValue is null || rightValue is null)
+ {
+ return 0;
+ }
+
+ return leftValue.GetValue().CompareTo(rightValue.GetValue());
+ }
+
+ private static bool RegexEquals(JsonValue? input, JsonValue? pattern, JsonSelectSettings? settings)
+ {
+ if (input is null || pattern is null || input.GetValueKind() != JsonValueKind.String || pattern.GetValueKind() != JsonValueKind.String)
+ {
+ return false;
+ }
+
+ string regexText = pattern.GetValue();
+ int patternOptionDelimiterIndex = regexText.LastIndexOf('/');
+
+ string patternText = regexText.Substring(1, patternOptionDelimiterIndex - 1);
+ string optionsText = regexText.Substring(patternOptionDelimiterIndex + 1);
+
+ TimeSpan timeout = settings?.RegexMatchTimeout ?? Regex.InfiniteMatchTimeout;
+ return Regex.IsMatch(input.GetValue(), patternText, GetRegexOptions(optionsText), timeout);
+
+ RegexOptions GetRegexOptions(string optionsText)
+ {
+ RegexOptions options = RegexOptions.None;
+
+ for (int i = 0; i < optionsText.Length; i++)
+ {
+ switch (optionsText[i])
+ {
+ case 'i':
+ options |= RegexOptions.IgnoreCase;
+ break;
+ case 'm':
+ options |= RegexOptions.Multiline;
+ break;
+ case 's':
+ options |= RegexOptions.Singleline;
+ break;
+ case 'x':
+ options |= RegexOptions.ExplicitCapture;
+ break;
+ }
+ }
+
+ return options;
+ }
+ }
+
+ internal static bool EqualsWithStringCoercion(JsonValue? value, JsonValue? queryValue)
+ {
+ if (value is null && value is null)
+ {
+ return true;
+ }
+
+ if (value is null || queryValue is null)
+ {
+ return false;
+ }
+
+ if (TryGetAsDouble(value, out double leftNum) && TryGetAsDouble(queryValue, out double rightNum))
+ {
+ return leftNum == rightNum;
+ }
+
+ if (IsBoolean(value) && IsBoolean(queryValue))
+ {
+ return value.GetValue() == queryValue.GetValue();
+ }
+
+ if (queryValue.GetValueKind() != JsonValueKind.String)
+ {
+ return false;
+ }
+
+ var queryValueText = queryValue.GetValue();
+
+ switch (value.GetValueKind())
+ {
+ case JsonValueKind.String:
+ return string.Equals(value.GetValue(), queryValueText, StringComparison.Ordinal);
+ case JsonValueKind.True:
+ case JsonValueKind.False:
+ return bool.TryParse(queryValueText, out var queryBool) && queryBool == value.GetValue();
+ default:
+ return false;
+ }
+ }
+
+ internal static bool EqualsWithStrictMatch(JsonValue? value, JsonValue? queryValue)
+ {
+ if (value is null)
+ {
+ return queryValue is null;
+ }
+
+ if (queryValue is null)
+ {
+ return false;
+ }
+
+ if (IsBoolean(value) && IsBoolean(queryValue))
+ {
+ return value.GetValue() == queryValue.GetValue();
+ }
+
+ if (value.GetValueKind() != queryValue.GetValueKind())
+ {
+ return false;
+ }
+
+ if (value.GetValueKind() == JsonValueKind.Number)
+ {
+ if (value.TryGetValue(out var valueNum))
+ {
+ return queryValue.TryGetValue(out var queryNum) ? valueNum == queryNum : valueNum == queryValue.GetValue();
+ }
+ else
+ {
+ return queryValue.TryGetValue(out var queryNum) ? value.GetValue() == queryNum : value.GetValue() == queryValue.GetValue();
+ }
+ }
+
+ if (value.GetValueKind() == JsonValueKind.String)
+ {
+ return string.Equals(value.GetValue(), queryValue.GetValue(), StringComparison.Ordinal);
+ }
+
+ if (value.GetValueKind() == JsonValueKind.Null && queryValue.GetValueKind() == JsonValueKind.Null)
+ {
+ return true;
+ }
+
+ if (value.GetValueKind() == JsonValueKind.Undefined && queryValue.GetValueKind() == JsonValueKind.Undefined)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool IsBoolean([NotNullWhen(true)] JsonNode? v) => v is not null && (v.GetValueKind() == JsonValueKind.False || v.GetValueKind() == JsonValueKind.True);
+
+ private static bool TryGetAsDouble(JsonValue? value, out double num)
+ {
+ if (value is null)
+ {
+ num = default;
+ return false;
+ }
+
+ if (value.GetValueKind() == JsonValueKind.Number)
+ {
+ num = value.TryGetValue(out var valueNum) ? valueNum : value.GetValue();
+ return true;
+ }
+
+ if (value.GetValueKind() == JsonValueKind.String && double.TryParse(value.GetValue(), out num))
+ {
+ return true;
+ }
+
+ num = default;
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/QueryFilter.cs b/modules/GarnetJSON/JSONPath/QueryFilter.cs
new file mode 100644
index 0000000000..9b967bec97
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/QueryFilter.cs
@@ -0,0 +1,90 @@
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that applies a query expression to JSON nodes. Eg: .field[?(@.name == 'value')]
+ ///
+ internal class QueryFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the query expression used to filter JSON nodes.
+ ///
+ internal QueryExpression Expression;
+
+ ///
+ /// Initializes a new instance of the class with the specified query expression.
+ ///
+ /// The query expression to use for filtering.
+ public QueryFilter(QueryExpression expression)
+ {
+ Expression = expression;
+ }
+
+ ///
+ /// Executes the filter on a single JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON node to filter.
+ /// The settings to use for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ if (current is JsonArray arr)
+ {
+ foreach (var v in arr)
+ {
+ if (Expression.IsMatch(root, v, settings))
+ {
+ yield return v;
+ }
+ }
+ }
+ else if (current is JsonObject obj)
+ {
+ foreach (var v in (obj as IDictionary).Values)
+ {
+ if (Expression.IsMatch(root, v, settings))
+ {
+ yield return v;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Executes the filter on a collection of JSON nodes.
+ ///
+ /// The root JSON node.
+ /// The collection of current JSON nodes to filter.
+ /// The settings to use for JSON selection.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ foreach (var item in current)
+ {
+ // Note: Not calling ExecuteFilter with yield return because that approach is slower and uses more memory.
+ if (item is JsonArray arr)
+ {
+ foreach (var v in arr)
+ {
+ if (Expression.IsMatch(root, v, settings))
+ {
+ yield return v;
+ }
+ }
+ }
+ else if (item is JsonObject obj)
+ {
+ foreach (var v in (obj as IDictionary).Values)
+ {
+ if (Expression.IsMatch(root, v, settings))
+ {
+ yield return v;
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/QueryScanFilter.cs b/modules/GarnetJSON/JSONPath/QueryScanFilter.cs
new file mode 100644
index 0000000000..80ce5d07b2
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/QueryScanFilter.cs
@@ -0,0 +1,95 @@
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that scans and evaluates JSON nodes based on a query expression. Eg: ..[?(@.name == 'value')]
+ ///
+ internal class QueryScanFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the query expression used to evaluate JSON nodes.
+ ///
+ internal QueryExpression Expression;
+
+ ///
+ /// Initializes a new instance of the class with the specified query expression.
+ ///
+ /// The query expression to use for filtering JSON nodes.
+ public QueryScanFilter(QueryExpression expression)
+ {
+ Expression = expression;
+ }
+
+ ///
+ /// Executes the filter on the specified JSON node and returns the matching nodes.
+ ///
+ /// The root JSON node.
+ /// The current JSON node to evaluate.
+ /// The settings to use for JSON selection.
+ /// An enumerable collection of matching JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ if (Expression.IsMatch(root, current, settings))
+ {
+ yield return current;
+ }
+
+ IEnumerator? enumerator = null;
+ if (current is JsonArray arr)
+ {
+ enumerator = arr.GetEnumerator();
+ }
+ else if (current is JsonObject obj)
+ {
+ enumerator = (obj as IDictionary).Values.GetEnumerator();
+ }
+
+ if (enumerator is not null)
+ {
+ var stack = new Stack>();
+ while (true)
+ {
+ if (enumerator.MoveNext())
+ {
+ var jsonNode = enumerator.Current;
+ if (Expression.IsMatch(root, jsonNode, settings))
+ {
+ yield return jsonNode;
+ }
+ stack.Push(enumerator);
+
+ if (jsonNode is JsonArray innerArr)
+ {
+ enumerator = innerArr.GetEnumerator();
+ }
+ else if (jsonNode is JsonObject innerOobj)
+ {
+ enumerator = (innerOobj as IDictionary).Values.GetEnumerator();
+ }
+ }
+ else if (stack.Count > 0)
+ {
+ enumerator = stack.Pop();
+ }
+ else
+ {
+ yield break;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Executes the filter on the specified collection of JSON nodes and returns the matching nodes.
+ ///
+ /// The root JSON node.
+ /// The collection of current JSON nodes to evaluate.
+ /// The settings to use for JSON selection.
+ /// An enumerable collection of matching JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ return current.SelectMany(x => ExecuteFilter(root, x, settings));
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/RootFilter.cs b/modules/GarnetJSON/JSONPath/RootFilter.cs
new file mode 100644
index 0000000000..1d1514a1da
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/RootFilter.cs
@@ -0,0 +1,46 @@
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that returns the root JSON node. Eg: $
+ ///
+ internal class RootFilter : PathFilter
+ {
+ ///
+ /// Singleton instance of the RootFilter.
+ ///
+ public static readonly RootFilter Instance = new RootFilter();
+
+ ///
+ /// Private constructor to prevent instantiation.
+ ///
+ private RootFilter()
+ {
+ }
+
+ ///
+ /// Executes the filter and returns the root JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON node (ignored).
+ /// The settings for JSON selection (ignored).
+ /// An enumerable containing the root JSON node.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ return [root];
+ }
+
+ ///
+ /// Executes the filter and returns the root JSON node.
+ ///
+ /// The root JSON node.
+ /// The current JSON nodes (ignored).
+ /// The settings for JSON selection (ignored).
+ /// An enumerable containing the root JSON node.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ return [root];
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/ScanFilter.cs b/modules/GarnetJSON/JSONPath/ScanFilter.cs
new file mode 100644
index 0000000000..aa3a1f30d0
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/ScanFilter.cs
@@ -0,0 +1,112 @@
+using System.Collections;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath
+{
+ ///
+ /// Represents a filter that scans through JSON nodes to find nodes matching a specified name. Eg: ..['name']
+ ///
+ internal class ScanFilter : PathFilter
+ {
+ ///
+ /// Gets or sets the name of the JSON node to match.
+ ///
+ internal string? Name;
+
+ ///
+ /// Initializes a new instance of the class with the specified name.
+ ///
+ /// The name of the JSON node to match. If null, all nodes are matched.
+ public ScanFilter(string? name)
+ {
+ Name = name;
+ }
+
+ ///
+ /// Executes the filter on the specified JSON node and returns the matching nodes.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The settings for JSON selection.
+ /// An enumerable of matching JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ if (Name is null)
+ {
+ yield return current;
+ }
+
+ // Inspired by https://stackoverflow.com/a/30441479/7331395
+ IEnumerator? enumerator = null;
+ if (current is JsonArray arr)
+ {
+ enumerator = arr.GetEnumerator();
+ }
+ else if (current is JsonObject obj)
+ {
+ enumerator = obj.GetEnumerator();
+ }
+
+ if (enumerator is not null)
+ {
+ var stack = new Stack();
+ while (true)
+ {
+ if (enumerator.MoveNext())
+ {
+ JsonNode? jsonNode = default;
+ if (enumerator is IEnumerator arrayEnumerator)
+ {
+ var element = arrayEnumerator.Current;
+ jsonNode = element;
+ if (Name is null)
+ {
+ yield return element;
+ }
+ stack.Push(enumerator);
+ }
+ else if (enumerator is IEnumerator> objectEnumerator)
+ {
+ var element = objectEnumerator.Current;
+ jsonNode = element.Value;
+ if (Name is null || element.Key == Name)
+ {
+ yield return element.Value;
+ }
+ stack.Push(enumerator);
+ }
+
+ if (jsonNode is JsonArray innerArr)
+ {
+ enumerator = innerArr.GetEnumerator();
+ }
+ else if (jsonNode is JsonObject innerOobj)
+ {
+ enumerator = innerOobj.GetEnumerator();
+ }
+ }
+ else if (stack.Count > 0)
+ {
+ enumerator = stack.Pop();
+ }
+ else
+ {
+ yield break;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Executes the filter on the specified enumerable of JSON nodes and returns the matching nodes.
+ ///
+ /// The root JSON node.
+ /// The enumerable of current JSON nodes.
+ /// The settings for JSON selection.
+ /// An enumerable of matching JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ return current.SelectMany(x => ExecuteFilter(root, x, settings));
+ }
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JSONPath/ScanMultipleFilter.cs b/modules/GarnetJSON/JSONPath/ScanMultipleFilter.cs
new file mode 100644
index 0000000000..06b8779d51
--- /dev/null
+++ b/modules/GarnetJSON/JSONPath/ScanMultipleFilter.cs
@@ -0,0 +1,98 @@
+using System.Collections;
+using System.Text.Json.Nodes;
+
+namespace GarnetJSON.JSONPath;
+
+///
+/// Represents a filter that scans through JSON nodes to find nodes matching with specified names. Eg: ..['name1', 'name2']
+///
+internal class ScanMultipleFilter : PathFilter
+{
+ private List _names;
+
+ ///
+ /// Initializes a new instance of the class with the specified names.
+ ///
+ /// The list of names to filter by.
+ public ScanMultipleFilter(List names)
+ {
+ _names = names;
+ }
+
+ ///
+ /// Executes the filter on the specified JSON node and returns the filtered nodes.
+ ///
+ /// The root JSON node.
+ /// The current JSON node.
+ /// The JSON select settings.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, JsonNode? current, JsonSelectSettings? settings)
+ {
+ IEnumerator? enumerator = null;
+ if (current is JsonArray arr)
+ {
+ enumerator = arr.GetEnumerator();
+ }
+ else if (current is JsonObject obj)
+ {
+ enumerator = obj.GetEnumerator();
+ }
+
+ if (enumerator is not null)
+ {
+ var stack = new Stack();
+ while (true)
+ {
+ if (enumerator.MoveNext())
+ {
+ JsonNode? jsonNode = default;
+ if (enumerator is IEnumerator arrayEnumerator)
+ {
+ var element = arrayEnumerator.Current;
+ jsonNode = element;
+ stack.Push(enumerator);
+ }
+ else if (enumerator is IEnumerator> objectEnumerator)
+ {
+ var element = objectEnumerator.Current;
+ jsonNode = element.Value;
+ if (_names.Contains(element.Key))
+ {
+ yield return jsonNode;
+ }
+ stack.Push(enumerator);
+ }
+
+ if (jsonNode is JsonArray innerArr)
+ {
+ enumerator = innerArr.GetEnumerator();
+ }
+ else if (jsonNode is JsonObject innerOobj)
+ {
+ enumerator = innerOobj.GetEnumerator();
+ }
+ }
+ else if (stack.Count > 0)
+ {
+ enumerator = stack.Pop();
+ }
+ else
+ {
+ yield break;
+ }
+ }
+ }
+ }
+
+ ///
+ /// Executes the filter on the specified enumerable of JSON nodes and returns the filtered nodes.
+ ///
+ /// The root JSON node.
+ /// The enumerable of current JSON nodes.
+ /// The JSON select settings.
+ /// An enumerable of filtered JSON nodes.
+ public override IEnumerable ExecuteFilter(JsonNode root, IEnumerable current, JsonSelectSettings? settings)
+ {
+ return current.SelectMany(x => ExecuteFilter(root, x, settings));
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JsonCmdStrings.cs b/modules/GarnetJSON/JsonCmdStrings.cs
new file mode 100644
index 0000000000..a0795557fb
--- /dev/null
+++ b/modules/GarnetJSON/JsonCmdStrings.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+namespace GarnetJSON
+{
+ ///
+ /// Json Command strings for RESP protocol
+ ///
+ public static class JsonCmdStrings
+ {
+ public static ReadOnlySpan INDENT => "INDENT"u8;
+ public static ReadOnlySpan NEWLINE => "NEWLINE"u8;
+ public static ReadOnlySpan SPACE => "SPACE"u8;
+
+ public static ReadOnlySpan RESP_NEW_OBJECT_AT_ROOT => "ERR new objects must be created at the root"u8;
+ public static ReadOnlySpan RESP_WRONG_STATIC_PATH => "Err wrong static path"u8;
+ }
+}
\ No newline at end of file
diff --git a/modules/GarnetJSON/JsonCommands.cs b/modules/GarnetJSON/JsonCommands.cs
new file mode 100644
index 0000000000..a17b3ff114
--- /dev/null
+++ b/modules/GarnetJSON/JsonCommands.cs
@@ -0,0 +1,189 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+using System.Buffers;
+using System.Diagnostics;
+using System.Text;
+using Garnet.common;
+using Garnet.server;
+using Microsoft.Extensions.Logging;
+using Tsavorite.core;
+
+namespace GarnetJSON
+{
+ ///
+ /// Represents a custom function to set JSON values in the Garnet object store.
+ ///
+ public class JsonSET : CustomObjectFunctions
+ {
+ private ILogger? logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger instance to use for logging.
+ public JsonSET(ILogger? logger = null) => this.logger = logger;
+
+ ///
+ /// Determines whether an initial update is needed.
+ ///
+ /// The key of the object.
+ /// The input data.
+ /// The output data.
+ /// Always returns true.
+ public override bool NeedInitialUpdate(ReadOnlyMemory key, ref ObjectInput input, ref (IMemoryOwner, int) output) => true;
+
+ ///
+ /// Updates the JSON object with the specified key and input.
+ ///
+ /// The key of the object.
+ /// The input data.
+ /// The JSON object to update.
+ /// The output data.
+ /// Additional information for the update.
+ /// True if the update is successful, otherwise false.
+ public override bool Updater(ReadOnlyMemory key, ref ObjectInput input, IGarnetObject jsonObject, ref (IMemoryOwner, int) output, ref RMWInfo rmwInfo)
+ {
+ Debug.Assert(jsonObject is GarnetJsonObject);
+
+ var parseState = input.parseState;
+ if (parseState.Count is not (2 or 3))
+ {
+ return output.AbortWithWrongNumberOfArguments("json.set");
+ }
+
+ int offset = 0;
+ var path = CustomCommandUtils.GetNextArg(ref input, ref offset);
+ var value = CustomCommandUtils.GetNextArg(ref input, ref offset);
+ var existOptions = ExistOptions.None;
+
+ if (parseState.Count is 4)
+ {
+ var existOptionStr = CustomCommandUtils.GetNextArg(ref input, ref offset);
+ if (existOptionStr.EqualsUpperCaseSpanIgnoringCase(CmdStrings.NX))
+ {
+ existOptions = ExistOptions.NX;
+ }
+ else if (existOptionStr.EqualsUpperCaseSpanIgnoringCase(CmdStrings.XX))
+ {
+ existOptions = ExistOptions.XX;
+ }
+ else
+ {
+ return output.AbortWithErrorMessage(CmdStrings.RESP_SYNTAX_ERROR);
+ }
+ }
+ var result = ((GarnetJsonObject)jsonObject).Set(path, value, existOptions, out var errorMessage);
+
+ switch (result)
+ {
+ case SetResult.Success:
+ WriteDirect(ref output, CmdStrings.RESP_OK);
+ break;
+ case SetResult.ConditionNotMet:
+ WriteNullBulkString(ref output);
+ break;
+ default:
+ output.AbortWithErrorMessage(errorMessage);
+ break;
+ }
+
+ return true;
+ }
+ }
+
+ ///
+ /// Represents a custom function to get JSON values from the Garnet object store.
+ ///
+ public class JsonGET : CustomObjectFunctions
+ {
+ private ILogger? logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger instance to use for logging.
+ public JsonGET(ILogger? logger = null) => this.logger = logger;
+
+ ///
+ /// Reads the JSON object with the specified key and input.
+ ///
+ /// The key of the object.
+ /// The input data.
+ /// The JSON object to read.
+ /// The output data.
+ /// Additional information for the read operation.
+ /// True if the read is successful, otherwise false.
+ public override bool Reader(ReadOnlyMemory key, ref ObjectInput input, IGarnetObject value, ref (IMemoryOwner, int) output, ref ReadInfo readInfo)
+ {
+ Debug.Assert(value is GarnetJsonObject);
+
+ var parseState = input.parseState;
+
+ using var outputStream = new MemoryStream();
+ var isSuccess = false;
+ ReadOnlySpan errorMessage = default;
+ if (parseState.Count == 0)
+ {
+ ReadOnlySpan path = default;
+ isSuccess = ((GarnetJsonObject)value).TryGet(path, outputStream, out errorMessage);
+ }
+ else
+ {
+ ReadOnlySpan paths = default;
+ var offset = 0;
+ string? indent = null;
+ string? newLine = null;
+ string? space = null;
+ while (true)
+ {
+ var option = CustomCommandUtils.GetNextArg(ref input, ref offset);
+ if (option.EqualsUpperCaseSpanIgnoringCase(JsonCmdStrings.INDENT) && offset < parseState.Count)
+ {
+ indent = Encoding.UTF8.GetString(CustomCommandUtils.GetNextArg(ref input, ref offset));
+ continue;
+ }
+ else if (option.EqualsUpperCaseSpanIgnoringCase(JsonCmdStrings.NEWLINE) && offset < parseState.Count)
+ {
+ newLine = Encoding.UTF8.GetString(CustomCommandUtils.GetNextArg(ref input, ref offset));
+ continue;
+ }
+ else if (option.EqualsUpperCaseSpanIgnoringCase(JsonCmdStrings.SPACE) && offset < parseState.Count)
+ {
+ space = Encoding.UTF8.GetString(CustomCommandUtils.GetNextArg(ref input, ref offset));
+ continue;
+ }
+
+ if (offset > parseState.Count)
+ {
+ return output.AbortWithWrongNumberOfArguments("json.get");
+ }
+ else
+ {
+ // If the code reached here then it means the current offset is a path, not an option
+ paths = parseState.Parameters.Slice(--offset);
+ break;
+ }
+ }
+
+ isSuccess = ((GarnetJsonObject)value).TryGet(paths, outputStream, out errorMessage, indent, newLine, space);
+ }
+
+ if (!isSuccess)
+ {
+ output.AbortWithErrorMessage(errorMessage);
+ return true;
+ }
+
+ if (outputStream.Length == 0)
+ {
+ CustomCommandUtils.WriteNullBulkString(ref output);
+ }
+ else
+ {
+ CustomCommandUtils.WriteBulkString(ref output, outputStream.GetBuffer().AsSpan(0, (int)outputStream.Length));
+ }
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/playground/GarnetJSON/Module.cs b/modules/GarnetJSON/Module.cs
similarity index 95%
rename from playground/GarnetJSON/Module.cs
rename to modules/GarnetJSON/Module.cs
index fc791a04ed..66e9a5558a 100644
--- a/playground/GarnetJSON/Module.cs
+++ b/modules/GarnetJSON/Module.cs
@@ -25,7 +25,7 @@ public override void OnLoad(ModuleLoadContext context, string[] args)
return;
}
- var jsonFactory = new JsonObjectFactory();
+ var jsonFactory = new GarnetJsonObjectFactory();
status = context.RegisterType(jsonFactory);
if (status == ModuleActionStatus.Success)
{
diff --git a/modules/GarnetJSON/RespJsonEnums.cs b/modules/GarnetJSON/RespJsonEnums.cs
new file mode 100644
index 0000000000..407b5ad38b
--- /dev/null
+++ b/modules/GarnetJSON/RespJsonEnums.cs
@@ -0,0 +1,26 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+
+namespace GarnetJSON
+{
+ ///
+ /// Represents the result of a JSON.SET operation.
+ ///
+ public enum SetResult : byte
+ {
+ ///
+ /// The operation was successful.
+ ///
+ Success,
+
+ ///
+ /// The condition for the operation was not met.
+ ///
+ ConditionNotMet,
+
+ ///
+ /// An error occurred during the operation.
+ ///
+ Error
+ }
+}
\ No newline at end of file
diff --git a/playground/GarnetJSON/JsonCommands.cs b/playground/GarnetJSON/JsonCommands.cs
deleted file mode 100644
index 7691771af2..0000000000
--- a/playground/GarnetJSON/JsonCommands.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT license.
-
-using System.Buffers;
-using System.Diagnostics;
-using System.Text;
-using Garnet.server;
-using Microsoft.Extensions.Logging;
-using Tsavorite.core;
-
-namespace GarnetJSON
-{
- public class JsonSET : CustomObjectFunctions
- {
- private ILogger? logger;
-
- public JsonSET(ILogger? logger = null) => this.logger = logger;
-
- public override bool NeedInitialUpdate(ReadOnlyMemory key, ref ObjectInput input, ref (IMemoryOwner, int) output) => true;
-
- public override bool Updater(ReadOnlyMemory key, ref ObjectInput input, IGarnetObject jsonObject, ref (IMemoryOwner, int) output, ref RMWInfo rmwInfo)
- {
- Debug.Assert(jsonObject is JsonObject);
-
- int offset = 0;
- var path = CustomCommandUtils.GetNextArg(ref input, ref offset);
- var value = CustomCommandUtils.GetNextArg(ref input, ref offset);
-
- if (!((JsonObject)jsonObject).TrySet(Encoding.UTF8.GetString(path), Encoding.UTF8.GetString(value), logger))
- {
- WriteError(ref output, "ERR Invalid input");
- }
-
- return true;
- }
- }
-
- public class JsonGET : CustomObjectFunctions
- {
- private ILogger? logger;
-
- public JsonGET(ILogger? logger = null) => this.logger = logger;
-
- public override bool Reader(ReadOnlyMemory key, ref ObjectInput input, IGarnetObject value, ref (IMemoryOwner, int) output, ref ReadInfo readInfo)
- {
- Debug.Assert(value is JsonObject);
-
- var offset = 0;
- var path = CustomCommandUtils.GetNextArg(ref input, ref offset);
- var strPath = path.IsEmpty ? "$" : Encoding.UTF8.GetString(path);
-
- if (((JsonObject)value).TryGet(strPath, out var result, logger))
- CustomCommandUtils.WriteBulkString(ref output, Encoding.UTF8.GetBytes(result));
- else
- WriteNullBulkString(ref output);
- return true;
- }
- }
-}
\ No newline at end of file
diff --git a/playground/GarnetJSON/JsonObject.cs b/playground/GarnetJSON/JsonObject.cs
deleted file mode 100644
index f96d1bd617..0000000000
--- a/playground/GarnetJSON/JsonObject.cs
+++ /dev/null
@@ -1,272 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT license.
-
-using System.Diagnostics;
-using System.Text.Json;
-using System.Text.Json.Nodes;
-using System.Text.RegularExpressions;
-using Garnet.server;
-using Json.Path;
-using Microsoft.Extensions.Logging;
-
-namespace GarnetJSON
-{
- ///
- /// Represents a factory for creating instances of .
- ///
- public class JsonObjectFactory : CustomObjectFactory
- {
- ///
- /// Creates a new instance of with the specified type.
- ///
- /// The type of the object.
- /// A new instance of .
- public override CustomObjectBase Create(byte type)
- => new JsonObject(type);
-
- ///
- /// Deserializes a from the specified binary reader.
- ///
- /// The type of the object.
- /// The binary reader to deserialize from.
- /// A deserialized instance of .
- public override CustomObjectBase Deserialize(byte type, BinaryReader reader)
- => new JsonObject(type, reader);
- }
-
- ///
- /// Represents a JSON object that supports SET and GET operations using JSON path.
- ///
- public class JsonObject : CustomObjectBase
- {
- private const string JsonPathPattern = @"(\.[^.\[]+)|(\['[^']+'\])|(\[\d+\])";
-
- // Root node
- private JsonNode? jNode;
-
- ///
- /// Initializes a new instance of the class with the specified type.
- ///
- /// The type of the object.
- public JsonObject(byte type)
- : base(type, 0, MemoryUtils.DictionaryOverhead)
- {
- }
-
- ///
- /// Initializes a new instance of the class by deserializing from the specified binary reader.
- ///
- /// The type of the object.
- /// The binary reader to deserialize from.
- public JsonObject(byte type, BinaryReader reader)
- : base(type, reader)
- {
- Debug.Assert(reader != null);
-
- var jsonString = reader.ReadString();
- jNode = JsonNode.Parse(jsonString);
- }
-
- ///
- /// Initializes a new instance of the class by cloning another instance.
- ///
- /// The instance to clone.
- public JsonObject(JsonObject obj)
- : base(obj)
- {
- jNode = obj.jNode;
- }
-
- ///
- /// Creates a new instance of that is a clone of the current instance.
- ///
- /// A new instance of that is a clone of the current instance.
- public override CustomObjectBase CloneObject() => new JsonObject(this);
-
- ///
- /// Serializes the to the specified binary writer.
- ///
- /// The binary writer to serialize to.
- public override void SerializeObject(BinaryWriter writer)
- {
- if (jNode == null) return;
-
- writer.Write(jNode.ToJsonString());
- }
-
- ///
- /// Disposes the instance.
- ///
- public override void Dispose() { }
-
- ///
- public override unsafe void Scan(long start, out List items, out long cursor, int count = 10,
- byte* pattern = default, int patternLength = 0, bool isNoValue = false) =>
- throw new NotImplementedException();
-
- ///
- /// Tries to get the value at the specified JSON path.
- ///
- /// The JSON path.
- /// The JSON string value at the specified path, or null if the value is not found.
- /// The logger to log any errors.
- /// true if the value was successfully retrieved; otherwise, false.
- /// Thrown when is null.
- public bool TryGet(string path, out string jsonString, ILogger? logger = null)
- {
- if (path == null)
- throw new ArgumentNullException(nameof(path));
-
- jsonString = string.Empty;
-
- try
- {
- // Find all items matching JSON path
- var jPath = JsonPath.Parse(path);
- var result = jPath.Evaluate(jNode);
-
- // Return matches in JSON array format
- jsonString = $"[{string.Join(",", result.Matches.Select(m => m.Value!.ToJsonString()))}]";
-
- return true;
- }
- catch (PathParseException ex)
- {
- logger?.LogError(ex, "Failed to parse JSON path");
- return false;
- }
- catch (JsonException ex)
- {
- logger?.LogError(ex, "Failed to get JSON value");
- return false;
- }
- }
-
- ///
- /// Tries to set the value at the specified JSON path.
- ///
- /// The JSON path.
- /// The value to set.
- /// The logger to log any errors.
- /// true if the value was successfully set; otherwise, false.
- /// Thrown when or is null.
- public bool TrySet(string path, string value, ILogger? logger = null)
- {
- if (path == null)
- throw new ArgumentNullException(nameof(path));
-
- if (value == null)
- throw new ArgumentNullException(nameof(value));
-
- try
- {
- Set(path, value);
- return true;
- }
- catch (PathParseException ex)
- {
- logger?.LogError(ex, "Failed to parse JSON path");
- return false;
- }
- catch (JsonException ex)
- {
- logger?.LogError(ex, "Failed to set JSON value");
- return false;
- }
- }
-
- private void Set(string path, string value)
- {
- // Find all items matching JSON path
- var jPath = JsonPath.Parse(path);
- var result = jPath.Evaluate(jNode);
-
- // No matched items
- if (result.Matches.Count == 0)
- {
- // Find parent node path
- var parentPath = JsonPath.Parse(GetParentPathExt(path));
- result = parentPath.Evaluate(jNode);
- if (result.Matches.Count == 0)
- throw new JsonException("Unable to find parent node(s) for JSON path.");
-
- // Get parent node from path & parse child node from input value
- var parentNode = result.Matches[0].Value;
- var childNode = JsonNode.Parse(value);
-
- // Check if parent node is a JsonObject
- if (parentNode is System.Text.Json.Nodes.JsonObject matchObject)
- {
- // Get key name from JSON path
- var propName = GetPropertyName(path);
-
- // Add key & child node to the parent node
- matchObject.Add(propName, childNode);
- }
- // Check if parent node is a JsonArray
- else if (parentNode is JsonArray matchArray)
- {
- // Get child index in parent array
- var index = GetArrayIndex(path);
-
- // Add child node to parent array
- matchArray.Insert(index, childNode);
- }
- }
- // Matches found
- else
- {
- foreach (var match in result.Matches)
- {
- // Parse node from input value
- var valNode = JsonNode.Parse(value);
-
- // If matched node is root
- if (match.Value == null && match.Location?.ToString() == "$")
- {
- // Set root node to parsed value node
- jNode = valNode;
- continue;
- }
-
- // Replace matched value with input value
- if (match.Value is JsonValue matchValue)
- {
- matchValue.ReplaceWith(valNode);
- }
- }
- }
- }
-
- private static string GetParentPathExt(string jsonPath)
- {
- var matches = Regex.Matches(jsonPath, JsonPathPattern);
-
- if (matches.Count == 0) return "$";
-
- return jsonPath.Substring(0, matches[^1].Index);
- }
-
- private static string GetPropertyName(string path)
- {
- var lastDotIndex = path.LastIndexOf('.');
- return lastDotIndex >= 0 ? path.Substring(lastDotIndex + 1) : path;
- }
-
- private static int GetArrayIndex(string path)
- {
- var startIndex = path.LastIndexOf('[');
- var endIndex = path.LastIndexOf(']');
- if (startIndex >= 0 && endIndex >= 0 && endIndex > startIndex)
- {
- var indexString = path.Substring(startIndex + 1, endIndex - startIndex - 1);
- if (int.TryParse(indexString, out var index))
- {
- return index;
- }
- }
-
- throw new ArgumentException("Invalid array index in path");
- }
- }
-}
\ No newline at end of file
diff --git a/test/Garnet.test/Garnet.test.csproj b/test/Garnet.test/Garnet.test.csproj
index 27749a33f5..b9d2550ee2 100644
--- a/test/Garnet.test/Garnet.test.csproj
+++ b/test/Garnet.test/Garnet.test.csproj
@@ -56,7 +56,7 @@
-
+
diff --git a/test/Garnet.test/GarnetJSON/JSONPath/JsonAssert.cs b/test/Garnet.test/GarnetJSON/JSONPath/JsonAssert.cs
new file mode 100644
index 0000000000..7df9ba2e11
--- /dev/null
+++ b/test/Garnet.test/GarnetJSON/JSONPath/JsonAssert.cs
@@ -0,0 +1,89 @@
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+
+namespace Garnet.test.JSONPath
+{
+ public static class JsonAssert
+ {
+ public static void AreEqual(JsonElement? left, JsonElement? right)
+ {
+ ClassicAssert.That(left, Is.EqualTo(right).Using(JsonElementEqualityComparer.Instance));
+ }
+
+ public static void AreEqual(JsonDocument left, JsonElement? right)
+ {
+ ClassicAssert.That(left.RootElement, Is.EqualTo(right).Using(JsonElementEqualityComparer.Instance));
+ }
+
+ public static void AreEqual(JsonNode left, JsonNode right)
+ {
+ ClassicAssert.That(left, Is.EqualTo(right).Using(JsonNodeEqualityComparer.Instance));
+ }
+ }
+
+ public static class JsonTestExtensions
+ {
+ public static bool DeepEquals(this JsonElement left, JsonElement? right)
+ {
+ if (right is null)
+ {
+ return false;
+ }
+ return JsonNode.DeepEquals(JsonNode.Parse(JsonSerializer.Serialize(left)), JsonNode.Parse(JsonSerializer.Serialize(right.Value)));
+ }
+ }
+
+ public class JsonElementEqualityComparer : IEqualityComparer
+ {
+ public static JsonElementEqualityComparer Instance { get; } = new JsonElementEqualityComparer();
+
+ public bool Equals(JsonElement? x, JsonElement? y)
+ {
+ if (x is null && y is null)
+ {
+ return true;
+ }
+
+ if (x is null || y is null)
+ {
+ return false;
+ }
+
+ return x.Value.DeepEquals(y);
+ }
+
+ public int GetHashCode([DisallowNull] JsonElement? obj)
+ {
+ return obj is null ? 0 : obj.GetHashCode();
+ }
+ }
+
+ public class JsonNodeEqualityComparer : IEqualityComparer
+ {
+ public static JsonNodeEqualityComparer Instance { get; } = new JsonNodeEqualityComparer();
+
+ public bool Equals(JsonNode x, JsonNode y)
+ {
+ if (x is null && y is null)
+ {
+ return true;
+ }
+
+ if (x is null || y is null)
+ {
+ return false;
+ }
+
+ return JsonNode.DeepEquals(x, y);
+ }
+
+ public int GetHashCode([DisallowNull] JsonNode obj)
+ {
+ return obj is null ? 0 : obj.GetHashCode();
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/Garnet.test/GarnetJSON/JSONPath/JsonPathExecuteTests.cs b/test/Garnet.test/GarnetJSON/JSONPath/JsonPathExecuteTests.cs
new file mode 100644
index 0000000000..b202c50420
--- /dev/null
+++ b/test/Garnet.test/GarnetJSON/JSONPath/JsonPathExecuteTests.cs
@@ -0,0 +1,1503 @@
+#region License
+// Copyright (c) 2007 James Newton-King
+//
+// 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;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using GarnetJSON.JSONPath;
+using NUnit.Framework;
+using NUnit.Framework.Legacy;
+
+namespace Garnet.test.JSONPath
+{
+ [TestFixture]
+ public class JsonPathExecuteTests
+ {
+ [Test]
+ public void GreaterThanIssue1518()
+ {
+ string statusJson = @"{""usingmem"": ""214376""}";//214,376
+ var jObj = JsonNode.Parse(statusJson);
+
+ var success = jObj.TrySelectNode("$..[?(@.usingmem>10)]", out var result);//found,10
+ ClassicAssert.IsTrue(success);
+ JsonAssert.AreEqual(jObj, result);
+
+ success = jObj.TrySelectNode("$..[?(@.usingmem>27000)]", out result);//null, 27,000
+ ClassicAssert.IsTrue(success);
+ JsonAssert.AreEqual(jObj, result);
+
+ success = jObj.TrySelectNode("$..[?(@.usingmem>21437)]", out result);//found, 21,437
+ ClassicAssert.IsTrue(success);
+ JsonAssert.AreEqual(jObj, result);
+
+ success = jObj.TrySelectNode("$..[?(@.usingmem>21438)]", out result);//null,21,438
+ ClassicAssert.IsTrue(success);
+ JsonAssert.AreEqual(jObj, result);
+ }
+
+ [Test]
+ public void BacktrackingRegex_SingleMatch_TimeoutRespected()
+ {
+ const string RegexBacktrackingPattern = "(?(.*?))[|].*(?(.*?))[|].*(?(.*?))[|].*(?[1-3])[|].*(?(.*?))[|].*[|].*[|].*(?(.*?))[|].*[|].*(?(.*?))[|].*(?(.*))";
+
+ var jObj = JsonNode.Parse(@"[{""b"": ""15/04/2020 8:18:03 PM|1|System.String[]|3|Libero eligendi magnam ut inventore.. Quaerat et sit voluptatibus repellendus blanditiis aliquam ut.. Quidem qui ut sint in ex et tempore.|||.\\iste.cpp||46018|-1"" }]");
+
+ ClassicAssert.Throws((() =>
+ {
+ jObj.SelectNodes(
+ $"[?(@.b =~ /{RegexBacktrackingPattern}/)]",
+ new JsonSelectSettings
+ {
+ RegexMatchTimeout = TimeSpan.FromSeconds(0.01)
+ }).ToArray();
+ }));
+ }
+
+ [Test]
+ public void GreaterThanWithIntegerParameterAndStringValue()
+ {
+ string json = @"{
+ ""persons"": [
+ {
+ ""name"" : ""John"",
+ ""age"": ""26""
+ },
+ {
+ ""name"" : ""Jane"",
+ ""age"": ""2""
+ }
+ ]
+}";
+
+ var models = JsonNode.Parse(json);
+
+ var results = models.SelectNodes("$.persons[?(@.age > 3)]").ToList();
+
+ ClassicAssert.AreEqual(1, results.Count);
+ }
+
+ [Test]
+ public void GreaterThanWithStringParameterAndIntegerValue()
+ {
+ string json = @"{
+ ""persons"": [
+ {
+ ""name"" : ""John"",
+ ""age"": 26
+ },
+ {
+ ""name"" : ""Jane"",
+ ""age"": 2
+ }
+ ]
+ }";
+
+ var models = JsonNode.Parse(json);
+
+ var results = models.SelectNodes("$.persons[?(@.age > '3')]").ToList();
+
+ ClassicAssert.AreEqual(1, results.Count);
+ }
+
+ [Test]
+ public void RecursiveWildcard()
+ {
+ string json = @"{
+ ""a"": [
+ {
+ ""id"": 1
+ }
+ ],
+ ""b"": [
+ {
+ ""id"": 2
+ },
+ {
+ ""id"": 3,
+ ""c"": {
+ ""id"": 4
+ }
+ }
+ ],
+ ""d"": [
+ {
+ ""id"": 5
+ }
+ ]
+}";
+
+ var models = JsonNode.Parse(json);
+
+ var results = models.SelectNodes("$.b..*.id").ToList();
+
+ ClassicAssert.AreEqual(3, results.Count);
+ ClassicAssert.AreEqual(2, results[0].GetValue());
+ ClassicAssert.AreEqual(3, results[1].GetValue());
+ ClassicAssert.AreEqual(4, results[2].GetValue());
+ }
+
+ [Test]
+ public void ScanFilter()
+ {
+ string json = @"{
+ ""elements"": [
+ {
+ ""id"": ""A"",
+ ""children"": [
+ {
+ ""id"": ""AA"",
+ ""children"": [
+ {
+ ""id"": ""AAA""
+ },
+ {
+ ""id"": ""AAB""
+ }
+ ]
+ },
+ {
+ ""id"": ""AB""
+ }
+ ]
+ },
+ {
+ ""id"": ""B"",
+ ""children"": []
+ }
+ ]
+}";
+
+ var models = JsonNode.Parse(json);
+
+ var results = models.SelectNodes("$.elements..[?(@.id=='AAA')]").ToList();
+
+ ClassicAssert.AreEqual(1, results.Count);
+ JsonAssert.AreEqual(models["elements"][0]["children"][0]["children"][0], results[0]);
+ }
+
+ [Test]
+ public void FilterTrue()
+ {
+ string json = @"{
+ ""elements"": [
+ {
+ ""id"": ""A"",
+ ""children"": [
+ {
+ ""id"": ""AA"",
+ ""children"": [
+ {
+ ""id"": ""AAA""
+ },
+ {
+ ""id"": ""AAB""
+ }
+ ]
+ },
+ {
+ ""id"": ""AB""
+ }
+ ]
+ },
+ {
+ ""id"": ""B"",
+ ""children"": []
+ }
+ ]
+}";
+
+ var models = JsonNode.Parse(json);
+
+ var results = models.SelectNodes("$.elements[?(true)]").ToList();
+
+ ClassicAssert.AreEqual(2, results.Count);
+ JsonAssert.AreEqual(results[0], models["elements"][0]);
+ JsonAssert.AreEqual(results[1], models["elements"][1]);
+ }
+
+ [Test]
+ public void ScanFilterTrue()
+ {
+ string json = @"{
+ ""elements"": [
+ {
+ ""id"": ""A"",
+ ""children"": [
+ {
+ ""id"": ""AA"",
+ ""children"": [
+ {
+ ""id"": ""AAA""
+ },
+ {
+ ""id"": ""AAB""
+ }
+ ]
+ },
+ {
+ ""id"": ""AB""
+ }
+ ]
+ },
+ {
+ ""id"": ""B"",
+ ""children"": []
+ }
+ ]
+}";
+
+ var models = JsonNode.Parse(json);
+
+ var results = models.SelectNodes("$.elements..[?(true)]").ToList();
+
+ // TODO: I think this should be 15, because results from online evaluators doesn't include the root/self element. Need to verify if changing the returning of self will affect other tests.
+ ClassicAssert.AreEqual(16, results.Count);
+ }
+
+ [Test]
+ public void ScanQuoted()
+ {
+ string json = @"{
+ ""Node1"": {
+ ""Child1"": {
+ ""Name"": ""IsMe"",
+ ""TargetNode"": {
+ ""Prop1"": ""Val1"",
+ ""Prop2"": ""Val2""
+ }
+ },
+ ""My.Child.Node"": {
+ ""TargetNode"": {
+ ""Prop1"": ""Val1"",
+ ""Prop2"": ""Val2""
+ }
+ }
+ },
+ ""Node2"": {
+ ""TargetNode"": {
+ ""Prop1"": ""Val1"",
+ ""Prop2"": ""Val2""
+ }
+ }
+}";
+
+ var models = JsonNode.Parse(json);
+
+ int result = models.SelectNodes("$..['My.Child.Node']").Count();
+ ClassicAssert.AreEqual(1, result);
+
+ result = models.SelectNodes("..['My.Child.Node']").Count();
+ ClassicAssert.AreEqual(1, result);
+ }
+
+ [Test]
+ public void ScanMultipleQuoted()
+ {
+ string json = @"{
+ ""Node1"": {
+ ""Child1"": {
+ ""Name"": ""IsMe"",
+ ""TargetNode"": {
+ ""Prop1"": ""Val1"",
+ ""Prop2"": ""Val2""
+ }
+ },
+ ""My.Child.Node"": {
+ ""TargetNode"": {
+ ""Prop1"": ""Val3"",
+ ""Prop2"": ""Val4""
+ }
+ }
+ },
+ ""Node2"": {
+ ""TargetNode"": {
+ ""Prop1"": ""Val5"",
+ ""Prop2"": ""Val6""
+ }
+ }
+}";
+
+ var models = JsonNode.Parse(json);
+
+ var results = models.SelectNodes("$..['My.Child.Node','Prop1','Prop2']").ToList();
+ ClassicAssert.AreEqual("Val1", results[0].GetValue());
+ ClassicAssert.AreEqual("Val2", results[1].GetValue());
+ ClassicAssert.AreEqual(JsonValueKind.Object, results[2].GetValueKind());
+ ClassicAssert.AreEqual("Val3", results[3].GetValue());
+ ClassicAssert.AreEqual("Val4", results[4].GetValue());
+ ClassicAssert.AreEqual("Val5", results[5].GetValue());
+ ClassicAssert.AreEqual("Val6", results[6].GetValue());
+ }
+
+ [Test]
+ public void ParseWithEmptyArrayContent()
+ {
+ var json = @"{
+ ""controls"": [
+ {
+ ""messages"": {
+ ""addSuggestion"": {
+ ""en-US"": ""Add""
+ }
+ }
+ },
+ {
+ ""header"": {
+ ""controls"": []
+ },
+ ""controls"": [
+ {
+ ""controls"": [
+ {
+ ""defaultCaption"": {
+ ""en-US"": ""Sort by""
+ },
+ ""sortOptions"": [
+ {
+ ""label"": {
+ ""en-US"": ""Name""
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}";
+ var jToken = JsonNode.Parse(json);
+ var tokens = jToken.SelectNodes("$..en-US").ToList();
+
+ ClassicAssert.AreEqual(3, tokens.Count);
+ ClassicAssert.AreEqual("Add", tokens[0].ToString());
+ ClassicAssert.AreEqual("Sort by", tokens[1].ToString());
+ ClassicAssert.AreEqual("Name", tokens[2].ToString());
+ }
+
+ [Test]
+ public void SelectTokenAfterEmptyContainer()
+ {
+ string json = @"{
+ ""cont"": [],
+ ""test"": ""no one will find me""
+}";
+
+ var o = JsonNode.Parse(json);
+
+ var results = o.SelectNodes("$..test").ToList();
+
+ ClassicAssert.AreEqual(1, results.Count);
+ ClassicAssert.AreEqual("no one will find me", results[0].ToString());
+ }
+
+ [Test]
+ public void EvaluatePropertyWithRequired()
+ {
+ string json = "{\"bookId\":\"1000\"}";
+ var o = JsonNode.Parse(json);
+
+ var bookId = o.TrySelectNode("bookId", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result);
+
+ ClassicAssert.IsTrue(bookId);
+ ClassicAssert.AreEqual("1000", result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateEmptyPropertyIndexer()
+ {
+ var o = JsonNode.Parse(@"{"""": 1}");
+
+ var t = o.TrySelectNode("['']", out var result);
+ ClassicAssert.IsTrue(t);
+ ClassicAssert.AreEqual(1, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateEmptyString()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var success = o.TrySelectNode("", out var result);
+ ClassicAssert.IsTrue(success);
+ JsonAssert.AreEqual(o, result);
+
+ success = o.TrySelectNode("['']", out result);
+ ClassicAssert.IsFalse(success);
+ ClassicAssert.IsNull(result);
+ }
+
+ [Test]
+ public void EvaluateEmptyStringWithMatchingEmptyProperty()
+ {
+ var o = JsonNode.Parse(@"{"" "": 1}");
+
+ var success = o.TrySelectNode("[' ']", out var result);
+ ClassicAssert.IsTrue(success);
+ ClassicAssert.AreEqual(1, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateWhitespaceString()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var success = o.TrySelectNode(" ", out var result);
+ ClassicAssert.IsTrue(success);
+ JsonAssert.AreEqual(o, result);
+ }
+
+ [Test]
+ public void EvaluateDollarString()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var success = o.TrySelectNode("$", out var result);
+ ClassicAssert.IsTrue(success);
+ JsonAssert.AreEqual(o, result);
+ }
+
+ [Test]
+ public void EvaluateDollarTypeString()
+ {
+ var o = JsonNode.Parse(@"{""$values"": [1, 2, 3]}");
+
+ var t = o.TrySelectNode("$values[1]", out var result);
+ ClassicAssert.IsTrue(t);
+ ClassicAssert.AreEqual(2, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateSingleProperty()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var t = o.TrySelectNode("Blah", out var result);
+ ClassicAssert.IsTrue(t);
+ ClassicAssert.AreEqual(JsonValueKind.Number, result.GetValueKind());
+ ClassicAssert.AreEqual(1, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateWildcardProperty()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1, ""Blah2"": 2}");
+
+ var t = o.SelectNodes("$.*").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(2, t.Count);
+ ClassicAssert.AreEqual(1, t[0].GetValue());
+ ClassicAssert.AreEqual(2, t[1].GetValue());
+ }
+
+ [Test]
+ public void QuoteName()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var t = o.TrySelectNode("['Blah']", out var result);
+ ClassicAssert.IsTrue(t);
+ ClassicAssert.AreEqual(JsonValueKind.Number, result.GetValueKind());
+ ClassicAssert.AreEqual(1, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateMissingProperty()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var success = o.TrySelectNode("Missing[1]", out var result);
+ ClassicAssert.IsFalse(success);
+ ClassicAssert.IsNull(result);
+ }
+
+ [Test]
+ public void EvaluateIndexerOnObject()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var t = o.TrySelectNode("[1]", out var result);
+ ClassicAssert.IsFalse(t);
+ }
+
+ [Test]
+ public void EvaluateIndexerOnObjectWithError()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ ClassicAssert.Throws(() => { o.TrySelectNode("[1]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, @"Index 1 not valid on JObject.");
+ }
+
+ [Test]
+ public void EvaluateWildcardIndexOnObjectWithError()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ ClassicAssert.Throws(() => { o.TrySelectNode("[*]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, @"Index * not valid on JObject.");
+ }
+
+ [Test]
+ public void EvaluateSliceOnObjectWithError()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ ClassicAssert.Throws(() => { o.TrySelectNode("[:]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, @"Array slice is not valid on JObject.");
+ }
+
+ [Test]
+ public void EvaluatePropertyOnArray()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ var t = a.TrySelectNode("BlahBlah", out var result);
+ ClassicAssert.IsFalse(t);
+ }
+
+ [Test]
+ public void EvaluateMultipleResultsError()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("[0, 1]", out var result); }, @"Path returned multiple tokens.");
+ }
+
+ [Test]
+ public void EvaluatePropertyOnArrayWithError()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("BlahBlah", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, @"Property 'BlahBlah' not valid on JArray.");
+ }
+
+ [Test]
+ public void EvaluateNoResultsWithMultipleArrayIndexes()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("[9,10]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, @"Index 9 outside the bounds of JArray.");
+ }
+
+ [Test]
+ public void EvaluateMissingPropertyWithError()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ ClassicAssert.Throws(() => { o.TrySelectNode("Missing", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Property 'Missing' does not exist on JObject.");
+ }
+
+ [Test]
+ public void EvaluatePropertyWithoutError()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ var v = o.TrySelectNode("Blah", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result);
+ ClassicAssert.IsTrue(v);
+ ClassicAssert.AreEqual(1, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateMissingPropertyIndexWithError()
+ {
+ var o = JsonNode.Parse(@"{""Blah"": 1}");
+
+ ClassicAssert.Throws(() => { o.TrySelectNode("['Missing','Missing2']", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Property 'Missing' does not exist on JObject.");
+ }
+
+ [Test]
+ public void EvaluateMultiPropertyIndexOnArrayWithError()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("['Missing','Missing2']", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Properties 'Missing', 'Missing2' not valid on JArray.");
+ }
+
+ [Test]
+ public void EvaluateArraySliceWithError()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("[99:]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Array slice of 99 to * returned no results.");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("[1:-19]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Array slice of 1 to -19 returned no results.");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("[:-19]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Array slice of * to -19 returned no results.");
+
+ a = JsonNode.Parse(@"[]");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("[:]", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Array slice of * to * returned no results.");
+ }
+
+ [Test]
+ public void EvaluateOutOfBoundsIndxer()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ var t = a.TrySelectNode("[1000].Ha", out var result);
+ ClassicAssert.IsFalse(t);
+ }
+
+ [Test]
+ public void EvaluateArrayOutOfBoundsIndxerWithError()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5]");
+
+ ClassicAssert.Throws(() => { a.TrySelectNode("[1000].Ha", new JsonSelectSettings { ErrorWhenNoMatch = true }, out var result); }, "Index 1000 outside the bounds of JArray.");
+ }
+
+ [Test]
+ public void EvaluateArray()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4]");
+
+ var t = a.TrySelectNode("[1]", out var result);
+ ClassicAssert.IsTrue(t);
+ ClassicAssert.AreEqual(JsonValueKind.Number, result.GetValueKind());
+ ClassicAssert.AreEqual(2, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateArraySlice()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4, 5, 6, 7, 8, 9]");
+
+ var t = a.SelectNodes("[-3:]").ToList();
+ ClassicAssert.AreEqual(3, t.Count);
+ ClassicAssert.AreEqual(7, t[0].GetValue());
+ ClassicAssert.AreEqual(8, t[1].GetValue());
+ ClassicAssert.AreEqual(9, t[2].GetValue());
+
+ t = a.SelectNodes("[-1:-2:-1]").ToList();
+ ClassicAssert.AreEqual(1, t.Count);
+ ClassicAssert.AreEqual(9, t[0].GetValue());
+
+ t = a.SelectNodes("[-2:-1]").ToList();
+ ClassicAssert.AreEqual(1, t.Count);
+ ClassicAssert.AreEqual(8, t[0].GetValue());
+
+ t = a.SelectNodes("[1:1]").ToList();
+ ClassicAssert.AreEqual(0, t.Count);
+
+ t = a.SelectNodes("[1:2]").ToList();
+ ClassicAssert.AreEqual(1, t.Count);
+ ClassicAssert.AreEqual(2, t[0].GetValue());
+
+ t = a.SelectNodes("[::-1]").ToList();
+ ClassicAssert.AreEqual(9, t.Count);
+ ClassicAssert.AreEqual(9, t[0].GetValue());
+ ClassicAssert.AreEqual(8, t[1].GetValue());
+ ClassicAssert.AreEqual(7, t[2].GetValue());
+ ClassicAssert.AreEqual(6, t[3].GetValue());
+ ClassicAssert.AreEqual(5, t[4].GetValue());
+ ClassicAssert.AreEqual(4, t[5].GetValue());
+ ClassicAssert.AreEqual(3, t[6].GetValue());
+ ClassicAssert.AreEqual(2, t[7].GetValue());
+ ClassicAssert.AreEqual(1, t[8].GetValue());
+
+ t = a.SelectNodes("[::-2]").ToList();
+ ClassicAssert.AreEqual(5, t.Count);
+ ClassicAssert.AreEqual(9, t[0].GetValue());
+ ClassicAssert.AreEqual(7, t[1].GetValue());
+ ClassicAssert.AreEqual(5, t[2].GetValue());
+ ClassicAssert.AreEqual(3, t[3].GetValue());
+ ClassicAssert.AreEqual(1, t[4].GetValue());
+ }
+
+ [Test]
+ public void EvaluateWildcardArray()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4]");
+
+ var t = a.SelectNodes("[*]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(4, t.Count);
+ ClassicAssert.AreEqual(1, t[0].GetValue());
+ ClassicAssert.AreEqual(2, t[1].GetValue());
+ ClassicAssert.AreEqual(3, t[2].GetValue());
+ ClassicAssert.AreEqual(4, t[3].GetValue());
+ }
+
+ [Test]
+ public void EvaluateArrayMultipleIndexes()
+ {
+ var a = JsonNode.Parse(@"[1, 2, 3, 4]");
+
+ var t = a.SelectNodes("[1,2,0]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(3, t.Count());
+ ClassicAssert.AreEqual(2, t.ElementAt(0).GetValue());
+ ClassicAssert.AreEqual(3, t.ElementAt(1).GetValue());
+ ClassicAssert.AreEqual(1, t.ElementAt(2).GetValue());
+ }
+
+ [Test]
+ public void EvaluateScan()
+ {
+ var o1 = JsonNode.Parse(@"{""Name"": 1}");
+ var o2 = JsonNode.Parse(@"{""Name"": 2}");
+ var a = JsonNode.Parse(@"[" + o1.ToJsonString() + "," + o2.ToJsonString() + "]");
+
+ var t = a.SelectNodes("$..Name").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(2, t.Count);
+ ClassicAssert.AreEqual(1, t[0].GetValue());
+ ClassicAssert.AreEqual(2, t[1].GetValue());
+ }
+
+ [Test]
+ public void EvaluateWildcardScan()
+ {
+ string json = @"[
+ { ""Name"": 1 },
+ { ""Name"": 2 }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ IList t = a.SelectNodes("$..*").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(5, t.Count);
+ JsonAssert.AreEqual(a, t[0]);
+ JsonAssert.AreEqual(a[0], t[1]);
+ JsonAssert.AreEqual(a[1], t[3]);
+ }
+
+ [Test]
+ public void EvaluateScanNestResults()
+ {
+ string json = @"[
+ { ""Name"": 1 },
+ { ""Name"": 2 },
+ { ""Name"": { ""Name"": [3] } }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ IList t = a.SelectNodes("$..Name").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(4, t.Count);
+ ClassicAssert.AreEqual(1, t[0].GetValue());
+ ClassicAssert.AreEqual(2, t[1].GetValue());
+ JsonAssert.AreEqual(a[2]["Name"], t[2]);
+ JsonAssert.AreEqual(a[2]["Name"]["Name"], t[3]);
+ }
+
+ [Test]
+ public void EvaluateWildcardScanNestResults()
+ {
+ string json = @"[
+ { ""Name"": 1 },
+ { ""Name"": 2 },
+ { ""Name"": { ""Name"": [3] } }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ IList t = a.SelectNodes("$..*").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(9, t.Count);
+
+ JsonAssert.AreEqual(a, t[0]);
+ JsonAssert.AreEqual(a[0], t[1]);
+ JsonAssert.AreEqual(a[1], t[3]);
+ JsonAssert.AreEqual(a[2], t[5]);
+ }
+
+ [Test]
+ public void EvaluateSinglePropertyReturningArray()
+ {
+ var json = @"{""Blah"": [1, 2, 3]}";
+ var o = JsonNode.Parse(json);
+
+ var t = o.TrySelectNode("Blah", out var result);
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(JsonValueKind.Array, result.GetValueKind());
+
+ t = o.TrySelectNode("Blah[2]", out result);
+ ClassicAssert.AreEqual(JsonValueKind.Number, result.GetValueKind());
+ ClassicAssert.AreEqual(3, result.GetValue());
+ }
+
+ [Test]
+ public void EvaluateLastSingleCharacterProperty()
+ {
+ var json = @"{""People"":[{""N"":""Jeff""}]}";
+ var o2 = JsonNode.Parse(json);
+
+ var a2 = o2.TrySelectNode("People[0].N", out var result);
+
+ ClassicAssert.AreEqual("Jeff", result.GetValue());
+ }
+
+ [Test]
+ public void ExistsQuery()
+ {
+ var json = @"[{""hi"": ""ho""}, {""hi2"": ""ha""}]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( @.hi ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(1, t.Count);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": ""ho""}"), t[0]);
+ }
+
+ [Test]
+ public void EqualsQuery()
+ {
+ var json = @"[{""hi"": ""ho""}, {""hi"": ""ha""}]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( @.['hi'] == 'ha' ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(1, t.Count);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": ""ha""}"), t[0]);
+ }
+
+ [Test]
+ public void NotEqualsQuery()
+ {
+ var json = @"[[{""hi"": ""ho""}], [{""hi"": ""ha""}]]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( @..hi <> 'ha' ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(1, t.Count);
+ JsonAssert.AreEqual(JsonNode.Parse(@"[{""hi"": ""ho""}]"), t[0]);
+ }
+
+ [Test]
+ public void NoPathQuery()
+ {
+ var json = @"[1, 2, 3]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( @ > 1 ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(2, t.Count);
+ ClassicAssert.AreEqual(2, t[0].GetValue());
+ ClassicAssert.AreEqual(3, t[1].GetValue());
+ }
+
+ [Test]
+ public void MultipleQueries()
+ {
+ var json = @"[1, 2, 3, 4, 5, 6, 7, 8, 9]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[?(@ <> 1)][?(@ <> 4)][?(@ < 7)]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(0, t.Count);
+ }
+
+ [Test]
+ public void GreaterQuery()
+ {
+ var json = @"[{""hi"": 1}, {""hi"": 2}, {""hi"": 3}]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( @.hi > 1 ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(2, t.Count);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 2}"), t[0]);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 3}"), t[1]);
+ }
+
+ [Test]
+ public void LesserQuery_ValueFirst()
+ {
+ var json = @"[{""hi"": 1}, {""hi"": 2}, {""hi"": 3}]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( 1 < @.hi ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(2, t.Count);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 2}"), t[0]);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 3}"), t[1]);
+ }
+
+ [Test]
+ public void GreaterQueryBigInteger()
+ {
+ var json = @"[{""hi"": 1}, {""hi"": 2}, {""hi"": 3}]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( @.hi > 1 ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(2, t.Count);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 2}"), t[0]);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 3}"), t[1]);
+ }
+
+ [Test]
+ public void GreaterOrEqualQuery()
+ {
+ var json = @"[{""hi"": 1}, {""hi"": 2}, {""hi"": 2.0}, {""hi"": 3}]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[ ?( @.hi >= 1 ) ]").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(4, t.Count);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 1}"), t[0]);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 2}"), t[1]);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 2.0}"), t[2]);
+ JsonAssert.AreEqual(JsonNode.Parse(@"{""hi"": 3}"), t[3]);
+ }
+
+ [Test]
+ public void NestedQuery()
+ {
+ var json = @"[
+ { ""name"": ""Bad Boys"", ""cast"": [{ ""name"": ""Will Smith"" }] },
+ { ""name"": ""Independence Day"", ""cast"": [{ ""name"": ""Will Smith"" }] },
+ { ""name"": ""The Rock"", ""cast"": [{ ""name"": ""Nick Cage"" }] }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ var t = a.SelectNodes("[?(@.cast[?(@.name=='Will Smith')])].name").ToList();
+ ClassicAssert.IsNotNull(t);
+ ClassicAssert.AreEqual(2, t.Count);
+ ClassicAssert.AreEqual("Bad Boys", t[0].GetValue());
+ ClassicAssert.AreEqual("Independence Day", t[1].GetValue());
+ }
+
+ [Test]
+ public void PathWithConstructor()
+ {
+ var json = @"[
+ { ""Property1"": [1, [[[]]]] },
+ { ""Property2"": [null, [1]] }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ var v = a.TrySelectNode("[1].Property2[1][0]", out var result);
+ ClassicAssert.AreEqual(1, result.GetValue());
+ }
+
+ [Test]
+ public void MultiplePaths()
+ {
+ var json = @"[
+ { ""price"": 199, ""max_price"": 200 },
+ { ""price"": 200, ""max_price"": 200 },
+ { ""price"": 201, ""max_price"": 200 }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ var results = a.SelectNodes("[?(@.price > @.max_price)]").ToList();
+ ClassicAssert.AreEqual(1, results.Count);
+ JsonAssert.AreEqual(a[2], results[0]);
+ }
+
+ [Test]
+ public void Exists_True()
+ {
+ var json = @"[
+ { ""price"": 199, ""max_price"": 200 },
+ { ""price"": 200, ""max_price"": 200 },
+ { ""price"": 201, ""max_price"": 200 }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ var results = a.SelectNodes("[?(true)]").ToList();
+ ClassicAssert.AreEqual(3, results.Count);
+ JsonAssert.AreEqual(a[0], results[0]);
+ JsonAssert.AreEqual(a[1], results[1]);
+ JsonAssert.AreEqual(a[2], results[2]);
+ }
+
+ [Test]
+ public void Exists_Null()
+ {
+ var json = @"[
+ { ""price"": 199, ""max_price"": 200 },
+ { ""price"": 200, ""max_price"": 200 },
+ { ""price"": 201, ""max_price"": 200 }
+ ]";
+ var a = JsonNode.Parse(json);
+
+ var results = a.SelectNodes("[?(true)]").ToList();
+ ClassicAssert.AreEqual(3, results.Count);
+ JsonAssert.AreEqual(a[0], results[0]);
+ JsonAssert.AreEqual(a[1], results[1]);
+ JsonAssert.AreEqual(a[2], results[2]);
+ }
+
+ [Test]
+ public void WildcardWithProperty()
+ {
+ var json = @"{
+ ""station"": 92000041000001,
+ ""containers"": [
+ {
+ ""id"": 1,
+ ""text"": ""Sort system"",
+ ""containers"": [
+ { ""id"": ""2"", ""text"": ""Yard 11"" },
+ { ""id"": ""92000020100006"", ""text"": ""Sort yard 12"" },
+ { ""id"": ""92000020100005"", ""text"": ""Yard 13"" }
+ ]
+ },
+ { ""id"": ""92000020100011"", ""text"": ""TSP-1"" },
+ { ""id"":""92000020100007"", ""text"": ""Passenger 15"" }
+ ]
+ }";
+ var o = JsonNode.Parse(json);
+
+ var tokens = o.SelectNodes("$..*[?(@.text)]").ToList();
+ int i = 0;
+ ClassicAssert.AreEqual("Sort system", tokens[i++]["text"].GetValue());
+ ClassicAssert.AreEqual("TSP-1", tokens[i++]["text"].GetValue());
+ ClassicAssert.AreEqual("Passenger 15", tokens[i++]["text"].GetValue());
+ ClassicAssert.AreEqual("Yard 11", tokens[i++]["text"].GetValue());
+ ClassicAssert.AreEqual("Sort yard 12", tokens[i++]["text"].GetValue());
+ ClassicAssert.AreEqual("Yard 13", tokens[i++]["text"].GetValue());
+ ClassicAssert.AreEqual(6, tokens.Count);
+ }
+
+ [Test]
+ public void QueryAgainstNonStringValues()
+ {
+ var json = @"{
+ ""prop"": [
+ { ""childProp"": ""ff2dc672-6e15-4aa2-afb0-18f4f69596ad"" },
+ { ""childProp"": ""ff2dc672-6e15-4aa2-afb0-18f4f69596ad"" },
+ { ""childProp"": ""http://localhost"" },
+ { ""childProp"": ""http://localhost"" },
+ { ""childProp"": ""2000-12-05T05:07:59Z"" },
+ { ""childProp"": ""2000-12-05T05:07:59Z"" },
+ { ""childProp"": ""2000-12-05T05:07:59-10:00"" },
+ { ""childProp"": ""2000-12-05T05:07:59-10:00"" },
+ { ""childProp"": ""SGVsbG8gd29ybGQ="" },
+ { ""childProp"": ""SGVsbG8gd29ybGQ="" },
+ { ""childProp"": ""365.23:59:59"" },
+ { ""childProp"": ""365.23:59:59"" }
+ ]
+ }";
+ var o = JsonNode.Parse(json);
+
+ var t = o.SelectNodes("$.prop[?(@.childProp =='ff2dc672-6e15-4aa2-afb0-18f4f69596ad')]").ToList();
+ ClassicAssert.AreEqual(2, t.Count);
+
+ t = o.SelectNodes("$.prop[?(@.childProp =='http://localhost')]").ToList();
+ ClassicAssert.AreEqual(2, t.Count);
+
+ t = o.SelectNodes("$.prop[?(@.childProp =='2000-12-05T05:07:59Z')]").ToList();
+ ClassicAssert.AreEqual(2, t.Count);
+
+ t = o.SelectNodes("$.prop[?(@.childProp =='2000-12-05T05:07:59-10:00')]").ToList();
+ ClassicAssert.AreEqual(2, t.Count);
+
+ t = o.SelectNodes("$.prop[?(@.childProp =='SGVsbG8gd29ybGQ=')]").ToList();
+ ClassicAssert.AreEqual(2, t.Count);
+
+ t = o.SelectNodes("$.prop[?(@.childProp =='365.23:59:59')]").ToList();
+ ClassicAssert.AreEqual(2, t.Count);
+ }
+
+ [Test]
+ public void Example()
+ {
+ var json = @"{
+ ""Stores"": [
+ ""Lambton Quay"",
+ ""Willis Street""
+ ],
+ ""Manufacturers"": [
+ {
+ ""Name"": ""Acme Co"",
+ ""Products"": [
+ {
+ ""Name"": ""Anvil"",
+ ""Price"": 50
+ }
+ ]
+ },
+ {
+ ""Name"": ""Contoso"",
+ ""Products"": [
+ {
+ ""Name"": ""Elbow Grease"",
+ ""Price"": 99.95
+ },
+ {
+ ""Name"": ""Headlight Fluid"",
+ ""Price"": 4
+ }
+ ]
+ }
+ ]
+ }";
+
+ var o = JsonNode.Parse(json);
+
+ o.TrySelectNode("Manufacturers[0].Name", out var nameNode);
+ string name = nameNode.GetValue();
+ // Acme Co
+
+ o.TrySelectNode("Manufacturers[0].Products[0].Price", out var priceNode);
+ decimal productPrice = priceNode.GetValue();
+ // 50
+
+ o.TrySelectNode("Manufacturers[1].Products[0].Name", out var productNameNode);
+ string productName = productNameNode.GetValue();
+ // Elbow Grease
+
+ ClassicAssert.AreEqual("Acme Co", name);
+ ClassicAssert.AreEqual(50m, productPrice);
+ ClassicAssert.AreEqual("Elbow Grease", productName);
+
+ o.TrySelectNode("Stores", out var storesNode);
+ IList storeNames = ((JsonArray)storesNode).Select(s => s.GetValue()).ToList();
+ // Lambton Quay
+ // Willis Street
+
+ var manufacturers = (JsonArray)o["Manufacturers"];
+ IList firstProductNames = manufacturers.Select(m =>
+ {
+ m.AsObject().TrySelectNode("Products[1].Name", out var node);
+ return node?.GetValue();
+ }).ToList();
+ // null
+ // Headlight Fluid
+
+ decimal totalPrice = manufacturers.Sum(m =>
+ {
+ m.AsObject().TrySelectNode("Products[0].Price", out var node);
+ return node.GetValue();
+ });
+ // 149.95
+
+ ClassicAssert.AreEqual(2, storeNames.Count);
+ ClassicAssert.AreEqual("Lambton Quay", storeNames[0]);
+ ClassicAssert.AreEqual("Willis Street", storeNames[1]);
+ ClassicAssert.AreEqual(2, firstProductNames.Count);
+ ClassicAssert.AreEqual(null, firstProductNames[0]);
+ ClassicAssert.AreEqual("Headlight Fluid", firstProductNames[1]);
+ ClassicAssert.AreEqual(149.95m, totalPrice);
+ }
+
+ [Test]
+ public void NotEqualsAndNonPrimativeValues()
+ {
+ string json = @"[
+ {
+ ""name"": ""string"",
+ ""value"": ""aString""
+ },
+ {
+ ""name"": ""number"",
+ ""value"": 123
+ },
+ {
+ ""name"": ""array"",
+ ""value"": [
+ 1,
+ 2,
+ 3,
+ 4
+ ]
+ },
+ {
+ ""name"": ""object"",
+ ""value"": {
+ ""1"": 1
+ }
+ }
+ ]";
+
+ var a = JsonNode.Parse(json);
+
+ List result = a.SelectNodes("$.[?(@.value!=1)]").ToList();
+ ClassicAssert.AreEqual(4, result.Count);
+
+ result = a.SelectNodes("$.[?(@.value!='2000-12-05T05:07:59-10:00')]").ToList();
+ ClassicAssert.AreEqual(4, result.Count);
+
+ result = a.SelectNodes("$.[?(@.value!=null)]").ToList();
+ ClassicAssert.AreEqual(4, result.Count);
+
+ result = a.SelectNodes("$.[?(@.value!=123)]").ToList();
+ ClassicAssert.AreEqual(3, result.Count);
+
+ result = a.SelectNodes("$.[?(@.value)]").ToList();
+ ClassicAssert.AreEqual(4, result.Count);
+ }
+
+ [Test]
+ public void RootInFilter()
+ {
+ string json = @"[
+ {
+ ""store"" : {
+ ""book"" : [
+ {
+ ""category"" : ""reference"",
+ ""author"" : ""Nigel Rees"",
+ ""title"" : ""Sayings of the Century"",
+ ""price"" : 8.95
+ },
+ {
+ ""category"" : ""fiction"",
+ ""author"" : ""Evelyn Waugh"",
+ ""title"" : ""Sword of Honour"",
+ ""price"" : 12.99
+ },
+ {
+ ""category"" : ""fiction"",
+ ""author"" : ""Herman Melville"",
+ ""title"" : ""Moby Dick"",
+ ""isbn"" : ""0-553-21311-3"",
+ ""price"" : 8.99
+ },
+ {
+ ""category"" : ""fiction"",
+ ""author"" : ""J. R. R. Tolkien"",
+ ""title"" : ""The Lord of the Rings"",
+ ""isbn"" : ""0-395-19395-8"",
+ ""price"" : 22.99
+ }
+ ],
+ ""bicycle"" : {
+ ""color"" : ""red"",
+ ""price"" : 19.95
+ }
+ },
+ ""expensive"" : 10
+ }
+ ]";
+
+ var a = JsonNode.Parse(json);
+
+ List result = a.SelectNodes("$.[?($.[0].store.bicycle.price < 20)]").ToList();
+ ClassicAssert.AreEqual(1, result.Count);
+
+ result = a.SelectNodes("$.[?($.[0].store.bicycle.price < 10)]").ToList();
+ ClassicAssert.AreEqual(0, result.Count);
+ }
+
+ [Test]
+ public void RootInFilterWithRootObject()
+ {
+ string json = @"{
+ ""store"" : {
+ ""book"" : [
+ {
+ ""category"" : ""reference"",
+ ""author"" : ""Nigel Rees"",
+ ""title"" : ""Sayings of the Century"",
+ ""price"" : 8.95
+ },
+ {
+ ""category"" : ""fiction"",
+ ""author"" : ""Evelyn Waugh"",
+ ""title"" : ""Sword of Honour"",
+ ""price"" : 12.99
+ },
+ {
+ ""category"" : ""fiction"",
+ ""author"" : ""Herman Melville"",
+ ""title"" : ""Moby Dick"",
+ ""isbn"" : ""0-553-21311-3"",
+ ""price"" : 8.99
+ },
+ {
+ ""category"" : ""fiction"",
+ ""author"" : ""J. R. R. Tolkien"",
+ ""title"" : ""The Lord of the Rings"",
+ ""isbn"" : ""0-395-19395-8"",
+ ""price"" : 22.99
+ }
+ ],
+ ""bicycle"" : [
+ {
+ ""color"" : ""red"",
+ ""price"" : 19.95
+ }
+ ]
+ },
+ ""expensive"" : 10
+ }";
+
+ var a = JsonNode.Parse(json);
+
+ List result = a.SelectNodes("$..book[?(@.price <= $['expensive'])]").ToList();
+ ClassicAssert.AreEqual(2, result.Count);
+
+ result = a.SelectNodes("$.store..[?(@.price > $.expensive)]").ToList();
+ ClassicAssert.AreEqual(3, result.Count);
+ }
+
+ [Test]
+ public void RootInFilterWithInitializers()
+ {
+ var json = @"{
+ ""referenceDate"": ""0001-01-01T00:00:00Z"",
+ ""dateObjectsArray"": [
+ { ""date"": ""0001-01-01T00:00:00Z"" },
+ { ""date"": ""9999-12-31T23:59:59.9999999Z"" },
+ { ""date"": ""2023-10-10T00:00:00Z"" },
+ { ""date"": ""0001-01-01T00:00:00Z"" }
+ ]
+ }";
+
+ var rootObject = JsonNode.Parse(json);
+
+ List result = rootObject.SelectNodes("$.dateObjectsArray[?(@.date == $.referenceDate)]").ToList();
+ ClassicAssert.AreEqual(2, result.Count);
+ }
+
+ [Test]
+ public void IdentityOperator()
+ {
+ var json = @"{
+ ""Values"": [{
+
+ ""Coercible"": 1,
+ ""Name"": ""Number""
+
+ }, {
+ ""Coercible"": ""1"",
+ ""Name"": ""String""
+ }]
+ }";
+
+ var o = JsonNode.Parse(json);
+
+ // just to verify expected behavior hasn't changed
+ IEnumerable sanity1 = o.SelectNodes("Values[?(@.Coercible == '1')].Name").Select(x => x.GetValue()).ToList();
+ IEnumerable sanity2 = o.SelectNodes("Values[?(@.Coercible != '1')].Name").Select(x => x.GetValue()).ToList();
+ // new behavior
+ IEnumerable mustBeNumber1 = o.SelectNodes("Values[?(@.Coercible === 1)].Name").Select(x => x.GetValue()).ToList();
+ IEnumerable mustBeString1 = o.SelectNodes("Values[?(@.Coercible !== 1)].Name").Select(x => x.GetValue()).ToList();
+ IEnumerable mustBeString2 = o.SelectNodes("Values[?(@.Coercible === '1')].Name").Select(x => x.GetValue()).ToList();
+ IEnumerable mustBeNumber2 = o.SelectNodes("Values[?(@.Coercible !== '1')].Name").Select(x => x.GetValue()).ToList();
+
+ // FAILS-- JsonPath returns { "String" }
+ //CollectionAssert.AreEquivalent(new[] { "Number", "String" }, sanity1);
+ // FAILS-- JsonPath returns { "Number" }
+ //Assert.IsTrue(!sanity2.Any());
+ ClassicAssert.AreEqual("Number", mustBeNumber1.Single());
+ ClassicAssert.AreEqual("String", mustBeString1.Single());
+ ClassicAssert.AreEqual("Number", mustBeNumber2.Single());
+ ClassicAssert.AreEqual("String", mustBeString2.Single());
+ }
+
+ [Test]
+ public void QueryWithEscapedPath()
+ {
+ var json = @"{
+ ""Property"": [
+ {
+ ""@Name"": ""x"",
+ ""@Value"": ""y"",
+ ""@Type"": ""FindMe""
+ }
+ ]
+ }";
+
+ var t = JsonNode.Parse(json);
+
+ var tokens = t.SelectNodes("$..[?(@.['@Type'] == 'FindMe')]").ToList();
+ ClassicAssert.AreEqual(1, tokens.Count);
+ }
+
+ [Test]
+ public void Equals_FloatWithInt()
+ {
+ var json = @"{
+ ""Values"": [
+ {
+ ""Property"": 1
+ }
+ ]
+ }";
+
+ var t = JsonNode.Parse(json);
+
+ ClassicAssert.IsNotNull(t.SelectNodes(@"Values[?(@.Property == 1.0)]"));
+ }
+
+ [TestCaseSource(nameof(StrictMatchWithInverseTestData))]
+ public void EqualsStrict(string value1, string value2, bool matchStrict)
+ {
+ value1 = value1.StartsWith('\'') ? $"\"{value1.Substring(1, value1.Length - 2)}\"" : value1;
+
+ string completeJson = @"{
+ ""Values"": [
+ {
+ ""Property"": " + value1 + @"
+ }
+ ]
+ }";
+ string completeEqualsStrictPath = "$.Values[?(@.Property === " + value2 + ")]";
+ string completeNotEqualsStrictPath = "$.Values[?(@.Property !== " + value2 + ")]";
+
+ var t = JsonNode.Parse(completeJson);
+
+ bool hasEqualsStrict = t.SelectNodes(completeEqualsStrictPath).Any();
+ ClassicAssert.AreEqual(
+ matchStrict,
+ hasEqualsStrict,
+ $"Expected {value1} and {value2} to match: {matchStrict}"
+ + Environment.NewLine + completeJson + Environment.NewLine + completeEqualsStrictPath);
+
+ bool hasNotEqualsStrict = t.SelectNodes(completeNotEqualsStrictPath).Any();
+ ClassicAssert.AreNotEqual(
+ matchStrict,
+ hasNotEqualsStrict,
+ $"Expected {value1} and {value2} to match: {!matchStrict}"
+ + Environment.NewLine + completeJson + Environment.NewLine + completeEqualsStrictPath);
+ }
+
+ public static IEnumerable