Repository: alirezanet/Gridify
Pull request #261: Expose helpful low-level methods to parse and analyze filterings and orderings
Author: login: cmenzi
Created: 2025-03-19T11:06:36Z | Merged: 2025-03-20T18:00:17Z

# Description

This PR introduces 4 new methods that can be used to get details about filterings and orderings.

Fixes #260

@alirezanet  I'm not sure if it's too much, but I this way the Api feels more symmetric.

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)

## Checklist

- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] I have made corresponding changes to the documentation
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] New and existing unit tests pass locally with my changes


Linked issue #260: Expose useful private helper methods in the Syntax Namespace

### Discussed in https://github.com/alirezanet/Gridify/discussions/259

<div type='discussions-op-text'>

<sup>Originally posted by **cmenzi** March 18, 2025</sup>
Hello

We are doing some integration testing against Sql Server and EF core, where we also measure index performance of some the columns.

In order to prepare the tests in a generic way for all our LIST endpoints which support filtering and ordering, we like to use the following methods:
- `IEnumerable<ParsedOrdering> GridifyExtensions::ParseOrderings(this string orderings)`
  -   https://github.com/alirezanet/Gridify/blob/master/src/Gridify/GridifyExtensions.cs#L350
- `IEnumerable<ISyntaxNode> GridifyExtensions::Descendants(this ISyntaxNode root)`
  - https://github.com/alirezanet/Gridify/blob/master/src/Gridify/GridifyExtensions.cs#L200

Both, parameters and return values are already public. Would it therefore be issue, to also make this 2 methods public?

Or maybe introduce a `IEnumerable<FieldExpressionSyntax> ParseFilterings(this string filter)`. Also here `FieldExpressionSyntax` is already public.

I can call it via reflection, but it does not feel rights ;-)



</div>

Diff:
diff --git a/docs/.vitepress/configs/sidebar.ts b/docs/.vitepress/configs/sidebar.ts
index f6d7505c..e86d0c43 100644
--- a/docs/.vitepress/configs/sidebar.ts
+++ b/docs/.vitepress/configs/sidebar.ts
@@ -40,6 +40,7 @@ export const sidebar: DefaultTheme.Sidebar = {
             { text: 'Dependency injection', link: '/guide/dependency-injection' },
             { text: 'Compilation', link: '/guide/compile' },
             { text: 'AutoMapper', link: '/guide/autoMapper' },
+            { text: 'Syntax', link: '/guide/syntax.md' },
          ]
       },
 
diff --git a/docs/pages/guide/syntax.md b/docs/pages/guide/syntax.md
new file mode 100644
index 00000000..7bc10100
--- /dev/null
+++ b/docs/pages/guide/syntax.md
@@ -0,0 +1,71 @@
+# Parsing and Syntax Tree
+
+Gridify internally uses a syntax tree to generate the expression tree that is used to filter or order objects based on their members. If you need more control or insights over the `SyntaxTree`, you can use the following extension methods inside the `Gridify.Syntax` namespace.
+
+## SyntaxTree
+
+The `SyntaxTree` always starts at the root of the tree and can be accessed by the `Root` property.
+
+``` csharp
+var root = SyntaxTree.Parse(filterings).Root; // ISyntaxNode
+```
+
+### Descendants
+
+To get all syntax nodes (self and descendants) of the syntax tree, you can use the `Descendants` method.
+
+``` csharp
+var filterings = "name = Jack, arrayProp[8] > 10, dictProp[name] = John";
+
+var syntaxNodes = SyntaxTree.Parse(filterings).Root.Descendants();
+
+var expressions = syntaxNodes.OfType<ExpressionSyntax>();
+var valueExpressions = syntaxNodes.OfType<ValueExpressionSyntax>();
+var fieldExpressions = syntaxNodes.OfType<FieldExpressionSyntax>();
+var binaryExpressions = syntaxNodes.OfType<BinaryExpressionSyntax>();
+var parenthesizedExpressions = syntaxNodes.OfType<ParenthesizedExpressionSyntax>();
+```
+
+### DistinctFieldExpressions
+
+To get all distinct field expression syntax nodes (self and descendants) of the syntax tree, you can use the `DistinctFieldExpressions` method.
+
+``` csharp
+var filterings = "name = Jack, arrayProp[8] > 10, dictProp[name] = John";
+
+var fieldExpressions = SyntaxTree.Parse(filterings).Root.DistinctFieldExpressions();
+```
+
+## Direct Parsing
+
+If you would like to parse a filtering or ordering string directly, you can use the following methods: `ParseFilterings` and `ParseOrderings`.
+
+### ParseFilterings
+
+``` csharp
+var filterings = SyntaxTree.ParseFilterings("name = Jack, arrayProp[8] > 10, dictProp[name] = John");
+
+foreach (var filtering in filterings)
+{
+    Console.WriteLine(filtering.MemberName);
+}
+
+// name
+// arrayProp
+// dictProp
+```
+
+### ParseOrderings
+
+``` csharp
+var orderings = SyntaxTree.ParseOrderings("Id desc, FirstName asc, LastName");
+
+foreach (var ordering in orderings)
+{
+    Console.WriteLine(ordering.MemberName);
+}
+
+// Id
+// FirstName
+// LastName
+```
diff --git a/src/Gridify/Builder/BaseSortingQueryBuilder.cs b/src/Gridify/Builder/BaseSortingQueryBuilder.cs
index aa60671a..9dc05567 100644
--- a/src/Gridify/Builder/BaseSortingQueryBuilder.cs
+++ b/src/Gridify/Builder/BaseSortingQueryBuilder.cs
@@ -16,7 +16,7 @@ public abstract class BaseSortingQueryBuilder<TSortingQuery, T>(IGridifyMapper<T
    public TSortingQuery ProcessOrdering(TSortingQuery query, string orderings, bool startWithThenBy)
    {
       var isFirst = !startWithThenBy;
-      var orders = orderings.ParseOrderings().ToList();
+      var orders = SyntaxTree.ParseOrderings(orderings).ToList();
       Mapper ??= BuildMapper(orders);
 
       foreach (var order in orders)
diff --git a/src/Gridify/GridifyExtensions.cs b/src/Gridify/GridifyExtensions.cs
index 71065b6a..3dd4c951 100644
--- a/src/Gridify/GridifyExtensions.cs
+++ b/src/Gridify/GridifyExtensions.cs
@@ -50,7 +50,7 @@ public static IEnumerable<Expression<Func<T, object>>> GetOrderingExpressions<T>
          throw new GridifyQueryException("OrderBy is not defined or not found");
 
 
-      var members = ParseOrderings(gridifyOrdering.OrderBy!).Select(q => q.MemberName).ToList();
+      var members = SyntaxTree.ParseOrderings(gridifyOrdering.OrderBy!).Select(q => q.MemberName).ToList();
       if (mapper is null)
          foreach (var member in members)
          {
@@ -178,14 +178,11 @@ internal static IGridifyMapper<T> FixMapper<T>(this IGridifyMapper<T>? mapper, S
 
       mapper = new GridifyMapper<T>();
 
-      var root = syntaxTree.Root;
-      var fields = root.Descendants()
-          .OfType<FieldExpressionSyntax>()
-          .Distinct(new FieldExpressionComparer());
+      var fieldExpressions = syntaxTree.Root.DistinctFieldExpressions();
 
       try
       {
-         foreach (var field in fields) mapper.AddMap(field.FieldToken.Text);
+         foreach (var fieldExpression in fieldExpressions) mapper.AddMap(fieldExpression.FieldToken.Text);
       }
       catch (Exception)
       {
@@ -196,18 +193,6 @@ internal static IGridifyMapper<T> FixMapper<T>(this IGridifyMapper<T>? mapper, S
       return mapper;
    }
 
-
-   private static IEnumerable<ISyntaxNode> Descendants(this ISyntaxNode root)
-   {
-      var nodes = new Stack<ISyntaxNode>(new[] { root });
-      while (nodes.Count != 0)
-      {
-         var node = nodes.Pop();
-         yield return node;
-         foreach (var n in node.GetChildren()) nodes.Push(n);
-      }
-   }
-
    /// <summary>
    /// Adds Filtering, Ordering And Paging to the query
    /// </summary>
@@ -305,9 +290,7 @@ public static bool IsValid<T>(this IGridifyFiltering filtering, IGridifyMapper<T
          if (syntaxTree.Diagnostics.Any())
             return false;
 
-         var fieldExpressions = syntaxTree.Root.Descendants()
-            .OfType<FieldExpressionSyntax>()
-            .Distinct(new FieldExpressionComparer());
+         var fieldExpressions = syntaxTree.Root.DistinctFieldExpressions();
 
          mapper ??= new GridifyMapper<T>(true);
 
@@ -327,7 +310,7 @@ public static bool IsValid<T>(this IGridifyOrdering ordering, IGridifyMapper<T>?
       if (string.IsNullOrWhiteSpace(ordering.OrderBy)) return true;
       try
       {
-         var orders = ParseOrderings(ordering.OrderBy!).ToList();
+         var orders = SyntaxTree.ParseOrderings(ordering.OrderBy!);
          mapper ??= new GridifyMapper<T>(true);
          if (orders.Any(order => !mapper.HasMap(order.MemberName)))
             return false;
@@ -345,47 +328,6 @@ internal static string ReplaceAll(this string seed, IEnumerable<char> chars, cha
       return chars.Aggregate(seed, (str, cItem) => str.Replace(cItem, replacementCharacter));
    }
 
-   private static readonly char[] OrderingSeparator = [' ', '\t'];
-
-   internal static IEnumerable<ParsedOrdering> ParseOrderings(this string orderings)
-   {
-      var nullableChars = new[] { '?', '!' };
-      foreach (var field in orderings.Split(','))
-      {
-         var orderingExp = field.Trim();
-         if (orderingExp.Contains(' '))
-         {
-            var spliced = orderingExp.Split(OrderingSeparator, StringSplitOptions.RemoveEmptyEntries);
-            var isAsc = spliced.Last() switch
-            {
-               "desc" => false,
-               "asc" => true,
-               _ => throw new GridifyOrderingException("Invalid keyword. expected 'desc' or 'asc'")
-            };
-            var member = spliced.First();
-            yield return new ParsedOrdering
-            {
-               MemberName = member.ReplaceAll(nullableChars, ' ').TrimEnd(),
-               IsAscending = isAsc,
-               OrderingType = member.EndsWith("?") ? OrderingType.NullCheck
-                  : member.EndsWith("!") ? OrderingType.NotNullCheck
-                  : OrderingType.Normal
-            };
-         }
-         else
-         {
-            yield return new ParsedOrdering
-            {
-               MemberName = orderingExp.ReplaceAll(nullableChars, ' ').TrimEnd(),
-               IsAscending = true,
-               OrderingType = orderingExp.EndsWith("?") ? OrderingType.NullCheck
-                  : orderingExp.EndsWith("!") ? OrderingType.NotNullCheck
-                  : OrderingType.Normal
-            };
-         }
-      }
-   }
-
    /// <summary>
    /// adds Ordering to the query
    /// </summary>
diff --git a/src/Gridify/Syntax/ParsedFiltering.cs b/src/Gridify/Syntax/ParsedFiltering.cs
new file mode 100644
index 00000000..33880346
--- /dev/null
+++ b/src/Gridify/Syntax/ParsedFiltering.cs
@@ -0,0 +1,16 @@
+namespace Gridify.Syntax;
+
+using System;
+
+public sealed class ParsedFiltering
+{
+   public ParsedFiltering(string memberName, string? indexer)
+   {
+      MemberName = memberName;
+      Indexer = indexer;
+   }
+
+   public string MemberName { get; }
+
+   public string? Indexer { get; }
+}
diff --git a/src/Gridify/Syntax/SyntaxNodeExtensions.cs b/src/Gridify/Syntax/SyntaxNodeExtensions.cs
new file mode 100644
index 00000000..dc04a907
--- /dev/null
+++ b/src/Gridify/Syntax/SyntaxNodeExtensions.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+    public static class SyntaxNodeExtensions
+    {
+      private static readonly FieldExpressionComparer FieldExpressionComparer = new();
+
+      /// <summary>
+      /// Retrieves all distinct field expressions from the specified syntax node.
+      /// </summary>
+      /// <param name="syntaxNode">The syntax node from which to retrieve descendant field expressions.</param>
+      /// <returns>An collection of <see cref="FieldExpressionSyntax"/> instances representing the descendant field expressions.</returns>
+      public static IEnumerable<FieldExpressionSyntax> DistinctFieldExpressions(this ISyntaxNode syntaxNode)
+      {
+         if (syntaxNode is null) throw new ArgumentNullException(nameof(syntaxNode));
+
+         var fieldExpressions = syntaxNode.Descendants()
+            .OfType<FieldExpressionSyntax>()
+            .Distinct(FieldExpressionComparer);
+
+         return fieldExpressions;
+      }
+
+      /// <summary>
+      /// Retrieves all descendants from the specified syntax node.
+      /// </summary>
+      /// <param name="syntaxNode">The syntax node from which to retrieve descendant syntax nodes.</param>
+      /// <returns>An collection of <see cref="ISyntaxNode"/> instances representing the descendant syntax nodes.</returns>
+      public static IEnumerable<ISyntaxNode> Descendants(this ISyntaxNode syntaxNode)
+      {
+         if (syntaxNode is null) throw new ArgumentNullException(nameof(syntaxNode));
+
+         var nodes = new Stack<ISyntaxNode>([syntaxNode]);
+         while (nodes.Count != 0)
+         {
+            var node = nodes.Pop();
+            yield return node;
+            foreach (var n in node.GetChildren()) nodes.Push(n);
+         }
+      }
+   }
+}
diff --git a/src/Gridify/Syntax/SyntaxTree.cs b/src/Gridify/Syntax/SyntaxTree.cs
index 1f9d89bd..21d626c1 100644
--- a/src/Gridify/Syntax/SyntaxTree.cs
+++ b/src/Gridify/Syntax/SyntaxTree.cs
@@ -1,3 +1,4 @@
+using System;
 using System.Collections.Generic;
 using System.Linq;
 
@@ -5,6 +6,8 @@ namespace Gridify.Syntax;
 
 public sealed class SyntaxTree(IEnumerable<string> diagnostics, ExpressionSyntax root)
 {
+   private static readonly char[] OrderingSeparator = [' ', '\t'];
+
    public IEnumerable<string> Diagnostics { get; } = diagnostics;
    public ExpressionSyntax Root { get; } = root;
 
@@ -15,4 +18,75 @@ public static SyntaxTree Parse(string text, IEnumerable<IGridifyOperator>? custo
       var parser = new Parser(text, customOperators);
       return parser.Parse();
    }
+
+   /// <summary>
+   /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+   /// </summary>
+   /// <param name="filterings">A string of filterings.</param>
+   /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+   /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+   public static IEnumerable<ParsedFiltering> ParseFilterings(string filterings)
+   {
+      if (string.IsNullOrWhiteSpace(filterings))
+         yield break;
+
+      var syntaxTree = Parse(filterings, GridifyGlobalConfiguration.CustomOperators.Operators);
+
+      if (syntaxTree.Diagnostics.Any())
+         throw new GridifyFilteringException(string.Join("\n", syntaxTree.Diagnostics.Reverse()));
+
+
+      foreach (var fieldExpression in syntaxTree.Root.DistinctFieldExpressions().OrderBy(x => x.FieldToken.Position))
+      {
+         yield return new ParsedFiltering(fieldExpression.FieldToken.Text, fieldExpression.Indexer);
+      }
+   }
+
+   /// <summary>
+   /// Parses a string of orderings into a list of <see cref="ParsedOrdering"/> objects.
+   /// </summary>
+   /// <param name="orderings">A comma-separated string of orderings. Each ordering can optionally specify the sort direction (asc or desc).</param>
+   /// <exception cref="GridifyOrderingException">Thrown when an invalid keyword is encountered in the orderings string.</exception>
+   /// <returns>An <see cref="IEnumerable{ParsedOrdering}"/> representing the parsed orderings.</returns>
+   public static IEnumerable<ParsedOrdering> ParseOrderings(string orderings)
+   {
+      if (string.IsNullOrWhiteSpace(orderings))
+         yield break;
+
+      var nullableChars = new[] { '?', '!' };
+      foreach (var field in orderings.Split(','))
+      {
+         var orderingExp = field.Trim();
+         if (orderingExp.Contains(' '))
+         {
+            var spliced = orderingExp.Split(OrderingSeparator, StringSplitOptions.RemoveEmptyEntries);
+            var isAsc = spliced.Last() switch
+            {
+               "desc" => false,
+               "asc" => true,
+               _ => throw new GridifyOrderingException("Invalid keyword. expected 'desc' or 'asc'")
+            };
+            var member = spliced.First();
+            yield return new ParsedOrdering
+            {
+               MemberName = member.ReplaceAll(nullableChars, ' ').TrimEnd(),
+               IsAscending = isAsc,
+               OrderingType = member.EndsWith("?") ? OrderingType.NullCheck
+                  : member.EndsWith("!") ? OrderingType.NotNullCheck
+                  : OrderingType.Normal
+            };
+         }
+         else
+         {
+            yield return new ParsedOrdering
+            {
+               MemberName = orderingExp.ReplaceAll(nullableChars, ' ').TrimEnd(),
+               IsAscending = true,
+               OrderingType = orderingExp.EndsWith("?") ? OrderingType.NullCheck
+                  : orderingExp.EndsWith("!") ? OrderingType.NotNullCheck
+                  : OrderingType.Normal
+            };
+         }
+      }
+   }
 }
diff --git a/test/Gridify.Tests/SyntaxNodeExtensionsShould.cs b/test/Gridify.Tests/SyntaxNodeExtensionsShould.cs
new file mode 100644
index 00000000..783694e7
--- /dev/null
+++ b/test/Gridify.Tests/SyntaxNodeExtensionsShould.cs
@@ -0,0 +1,53 @@
+#nullable enable
+using Gridify.Syntax;
+
+using System;
+using System.Linq;
+
+using Xunit;
+
+namespace Gridify.Tests;
+
+public class SyntaxNodeExtensionsShould
+{
+   [Fact]
+   public void Descendants_ShouldReturnSyntaxNodes()
+   {
+      var filterings = "name = Jack, arrayProp[8] > 10, dictProp[name] = John";
+
+      var syntaxNodes = SyntaxTree.Parse(filterings).Root.Descendants().ToList();
+
+      Assert.Equal(22, syntaxNodes.Count);
+   }
+
+   [Fact]
+   public void Descendants_WhenNull_ShouldThrow()
+   {
+      ISyntaxNode syntaxNode = null!;
+
+      var act = () => syntaxNode.Descendants().ToList();
+
+      Assert.Throws<ArgumentNullException>(act);
+   }
+
+   [Fact]
+   public void DistinctFieldExpressions_ShouldReturnFieldExpressions()
+   {
+      var filterings = "name = Jack, arrayProp[8] > 10, dictProp[name] = John";
+
+      var syntaxNodes = SyntaxTree.Parse(filterings).Root.DistinctFieldExpressions().ToList();
+
+      Assert.Equal(3, syntaxNodes.Count);
+   }
+
+   [Fact]
+   public void DistinctFieldExpressions_WhenNull_ShouldThrow()
+   {
+      ISyntaxNode syntaxNode = null!;
+
+      var act = () => syntaxNode.DistinctFieldExpressions().ToList();
+
+      Assert.Throws<ArgumentNullException>(act);
+
+   }
+}
diff --git a/test/Gridify.Tests/SyntaxTreeShould.cs b/test/Gridify.Tests/SyntaxTreeShould.cs
new file mode 100644
index 00000000..7077104a
--- /dev/null
+++ b/test/Gridify.Tests/SyntaxTreeShould.cs
@@ -0,0 +1,72 @@
+#nullable enable
+using Gridify.Syntax;
+
+using System.Linq;
+
+using Xunit;
+
+namespace Gridify.Tests;
+
+public class SyntaxTreeShould
+{
+   [Fact]
+   public void ParseFilterings_MembersAndIndexerIsSet()
+   {
+      var filterings = "name = Jack, arrayProp[8] > 10, dictProp[name] = John";
+
+      var actual = SyntaxTree.ParseFilterings(filterings).ToList();
+
+      Assert.Equal(3, actual.Count);
+
+      Assert.Equal("name", actual[0].MemberName);
+      Assert.Null(actual[0].Indexer);
+
+      Assert.Equal("arrayProp", actual[1].MemberName);
+      Assert.Equal("8", actual[1].Indexer);
+
+      Assert.Equal("dictProp", actual[2].MemberName);
+      Assert.Equal("name", actual[2].Indexer);
+   }
+
+   [Theory]
+   [InlineData(null)]
+   [InlineData("")]
+   public void ParseFilterings_ShouldReturnEmtpy(string? filterings)
+   {
+      var actual = SyntaxTree.ParseFilterings(filterings!).ToList();
+
+      Assert.Empty(actual);
+   }
+
+   [Fact]
+   public void ParseOrderings_MembersAndOrderingIsSet()
+   {
+      var orderings = "name, firstName desc, lastName asc";
+
+      var actual = SyntaxTree.ParseOrderings(orderings).ToList();
+
+      Assert.Equal(3, actual.Count);
+
+      Assert.Equal("name", actual[0].MemberName);
+      Assert.Equal(OrderingType.Normal, actual[0].OrderingType);
+      Assert.True(actual[0].IsAscending);
+
+      Assert.Equal("firstName", actual[1].MemberName);
+      Assert.Equal(OrderingType.Normal, actual[1].OrderingType);
+      Assert.False(actual[1].IsAscending);
+
+      Assert.Equal("lastName", actual[2].MemberName);
+      Assert.Equal(OrderingType.Normal, actual[2].OrderingType);
+      Assert.True(actual[2].IsAscending);
+   }
+
+   [Theory]
+   [InlineData(null)]
+   [InlineData("")]
+   public void ParseOrderings_ShouldReturnEmtpy(string? orderings)
+   {
+      var actual = SyntaxTree.ParseOrderings(orderings!).ToList();
+
+      Assert.Empty(actual);
+   }
+}


Reviews:
user: login: alirezanet
body: Overall looks good 👌, there is one small issue that I would like to know your feedback 
state: CHANGES_REQUESTED
author_association: OWNER
submitted_at: 2025-03-19T16:26:07Z
user: login: cmenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T16:56:33Z
user: login: cmenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T17:04:06Z
user: login: alirezanet
state: COMMENTED
author_association: OWNER
submitted_at: 2025-03-19T17:07:23Z
user: login: cmenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T18:41:20Z
user: login: cmenzi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T06:06:09Z
user: login: alirezanet
state: APPROVED
author_association: OWNER
submitted_at: 2025-03-20T12:15:21Z

Review comments:
diff_hunk: @@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+   public static class StringExtensions
+   {
+      private static readonly char[] OrderingSeparator = [' ', '\t'];
+
+      /// <summary>
+      /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+      /// </summary>
+      /// <param name="filterings">A string of filterings.</param>
+      /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+      public static IEnumerable<ParsedFiltering> ParseFilterings(this string filterings)
path: src/Gridify/Syntax/StringExtensions.cs
user: login: alirezanet
body: Having an extension method directly on the `string` type feels too general—it doesn't look good when these appear on every string instance throughout the application. I'd recommend converting these extensions into regular static methods within a helper class.
(I'm open to suggestions on the class name; perhaps something like `ParserHelper`.)
created_at: 2025-03-19T16:23:09Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+   public static class StringExtensions
+   {
+      private static readonly char[] OrderingSeparator = [' ', '\t'];
+
+      /// <summary>
+      /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+      /// </summary>
+      /// <param name="filterings">A string of filterings.</param>
+      /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+      public static IEnumerable<ParsedFiltering> ParseFilterings(this string filterings)
+      {
+         if (string.IsNullOrWhiteSpace(filterings))
+            yield break;
+
+         var parser = new Parser(filterings, GridifyGlobalConfiguration.CustomOperators.Operators);
+         var syntaxTree = parser.Parse();
+
+         if (syntaxTree.Diagnostics.Any())
+            throw new GridifyFilteringException(string.Join("\n", syntaxTree.Diagnostics.Reverse()));
+
+
+         foreach (var fieldExpression in syntaxTree.Root.DistinctFieldExpressions().OrderBy(x => x.FieldToken.Position))
+         {
+            yield return new ParsedFiltering
+            {
+               MemberName = fieldExpression.FieldToken.Text,
+               Indexer = fieldExpression.Indexer,
+            };
+         }
+      }
+
+      /// <summary>
+      /// Parses a string of orderings into a list of <see cref="ParsedOrdering"/> objects.
+      /// </summary>
+      /// <param name="orderings">A comma-separated string of orderings. Each ordering can optionally specify the sort direction (asc or desc).</param>
+      /// <exception cref="GridifyOrderingException">Thrown when an invalid keyword is encountered in the orderings string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedOrdering}"/> representing the parsed orderings.</returns>
+      public static IEnumerable<ParsedOrdering> ParseOrderings(this string orderings)
path: src/Gridify/Syntax/StringExtensions.cs
user: login: alirezanet
body: same as ParseFilterings
created_at: 2025-03-19T16:23:23Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+   public static class StringExtensions
+   {
+      private static readonly char[] OrderingSeparator = [' ', '\t'];
+
+      /// <summary>
+      /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+      /// </summary>
+      /// <param name="filterings">A string of filterings.</param>
+      /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+      public static IEnumerable<ParsedFiltering> ParseFilterings(this string filterings)
path: src/Gridify/Syntax/StringExtensions.cs
user: login: cmenzi
body: Yeah, I also had the same feelings. Having extensions methods on objects and strings are usually smelly, except in testing frameworks.

I see this 3 options

a) Introduce `ParserHelper`

``` csharp
var filterings = ParserHelper.ParseFilterings("name = abc");
var orderings = ParserHelper.ParseOrderings("name desc");
```

I could live with that, but it also a bit "smelly" to create XHelper classes. :-) 

b) Put the static methods into the current `Parser` struct 

``` csharp
var filterings = Parser.ParseFilterings("name = abc");
var orderings = Parser.ParseOrderings("name desc");
```

This would actually be a good fit for filterings part and it would keep the cohesion. For orderings it feels wrong, as they are not part of the SyntaxTree and expressed as ExpressionSyntax. Maybe you have plans to do that in the future? 

For example to support the ordering with indexed properties `arr[1].name desc` or `dict["name"].value asc `.

c) Creating a class per tasks

``` csharp
var filterings = FilteringParser.Parse("name = abc");
var orderings = OrderingParser.Parse("name desc");
```

This would be more task oriented. 
created_at: 2025-03-19T16:56:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+   public static class StringExtensions
+   {
+      private static readonly char[] OrderingSeparator = [' ', '\t'];
+
+      /// <summary>
+      /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+      /// </summary>
+      /// <param name="filterings">A string of filterings.</param>
+      /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+      public static IEnumerable<ParsedFiltering> ParseFilterings(this string filterings)
path: src/Gridify/Syntax/StringExtensions.cs
user: login: cmenzi
body: Ahh option d)

d) Creating "collection" classes `Filterings` and `Orderings`

``` csharp
public class Filterings : ReadOnlyCollection<ParserFilterings>
{
    private Filterings(IEnumerable<ParsedFilterings> filterings)
       : base(filterings)
    { 
    } 

    public static Filterings Parse(string filterings)
    {
       // Current Logic
       return new Filterings(...)
    }
}
```
created_at: 2025-03-19T17:04:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+   public static class StringExtensions
+   {
+      private static readonly char[] OrderingSeparator = [' ', '\t'];
+
+      /// <summary>
+      /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+      /// </summary>
+      /// <param name="filterings">A string of filterings.</param>
+      /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+      public static IEnumerable<ParsedFiltering> ParseFilterings(this string filterings)
path: src/Gridify/Syntax/StringExtensions.cs
user: login: alirezanet
body: Yeah, totally agree. Option B looks cleaner, but I'm not fully sure about choosing between `Parser` or `SyntaxTree`. I'm leaning towards SyntaxTree since it handles the core expression parsing logic and feels aligned with these methods. What do you think?
created_at: 2025-03-19T17:07:23Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+   public static class StringExtensions
+   {
+      private static readonly char[] OrderingSeparator = [' ', '\t'];
+
+      /// <summary>
+      /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+      /// </summary>
+      /// <param name="filterings">A string of filterings.</param>
+      /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+      public static IEnumerable<ParsedFiltering> ParseFilterings(this string filterings)
path: src/Gridify/Syntax/StringExtensions.cs
user: login: cmenzi
body: Ok. SyntaxTree is also good idea. I will put it there and then we can discuss how it feels. 👌 
created_at: 2025-03-19T18:41:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gridify.Syntax
+{
+   public static class StringExtensions
+   {
+      private static readonly char[] OrderingSeparator = [' ', '\t'];
+
+      /// <summary>
+      /// Parses a string of filterings into a list of <see cref="ParsedFiltering"/> objects.
+      /// </summary>
+      /// <param name="filterings">A string of filterings.</param>
+      /// <exception cref="GridifyFilteringException">Thrown when an invalid keyword is encountered in the filtering string.</exception>
+      /// <returns>An <see cref="IEnumerable{ParsedFiltering}"/> representing the parsed filtering.</returns>
+      public static IEnumerable<ParsedFiltering> ParseFilterings(this string filterings)
path: src/Gridify/Syntax/StringExtensions.cs
user: login: cmenzi
body: Done. 

Things, I would improve:

- Make the class `ParsedOrdering` immutable. Is there a reason why there are public setters? 
  - At least in this code base, the setters are never used
  - But it would be a breaking change, as they were public
- `SyntaxTree::Parse`
  - L17: I would use `customOperators ??= GridifyGlobalConfiguration.CustomOperators.Operators`
  - You anyway pass this everywhere and if no operators are registered, it would result in the same

created_at: 2025-03-20T06:06:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: what-the-diff[bot]
created_at: 2025-03-19T11:07:07Z
body: ## PR Summary

* **Updated Documentation**
    - We've enriched our documentation by adding new sections in few files. "Low-Level Methods" for `extensions.md`, "Parsing Filterings Extensions" for `filtering.md` and "Parsing Orderings Extensions" for `ordering.md`. These new sections include detailed examples and demonstrate the use of various methods.

* **Improvement in Existing Code**
    - There has been a notable improvement in `GridifyExtensions.cs` where we've made our method more efficient by using `ParseOrderings()` inside it rather than using `ParseOrderings` as a standalone function.
    - We have also updated the `Descendants()` method and it will now utilize `DistinctFieldExpressions()` for accessing unique fields in the syntax tree.

* **New Functionalities Introduced**
    - We've launched new classes and functionalities. A new class `ParsedFiltering` has been created with its parsing logic incorporated within `StringExtensions.cs` via a new method `ParseFilterings`.
    - A new extension method `DistinctFieldExpressions()` for syntax processing has also been introduced inside `SyntaxNodeExtensions.cs`.

* **Test Cases Implementation**
    - To ensure the robustness and correctness of our new functionalities, we've implemented new unit tests in `StringExtensionsShould.cs` and `SyntaxNodeExtensionsShould.cs`. These tests check for functionality of `ParseFilterings` and `ParseOrderings`, and the correctness of `Descendants()` and `DistinctFieldExpressions()` methods, including null checks for additional robustness.


author_association: NONE
user: login: alirezanet
created_at: 2025-03-19T11:37:50Z
body: Hi @cmenzi,
I like these changes! 💐 soon I'll review the code.
Regarding the documentation, currently, we don't have a dedicated page for the Syntax namespace. Since it's not something everyone uses, it might be better to create a separate section/menu for the Syntax namespace and place this information there.
author_association: OWNER