cajuncoding / GraphQL.RepoDB

A set of extensions for working with HotChocolate GraphQL and Database access with micro-orms such as RepoDb (or Dapper). This extension pack provides access to key elements such as Selections/Projections, Sort arguments, & Paging arguments in a significantly simplified facade so this logic can be leveraged in the Serivces/Repositories that encapsulate all data access (without dependency on IQueryable and execution outside of the devs control).
MIT License
41 stars 5 forks source link

Support for .UseOffsetPaging<>() #2

Closed kondelik closed 3 years ago

kondelik commented 3 years ago

Hi,

when using OffsetPaging there is a wrong (its too shallow) AllSelectionFields. It always contains "items" and "__typename" only.

This is pretty much showstopper for us, because we are unable to use CursorPaging. edit: well, it isnt, we can always fork you or ctrl +c ctrl + v ;)

Is there any plan to support this?

(its actually really simple, i can send you pull request, but it is pretty much

else if (lookup.Contains("items")) // "items" is constant and it should be in SelectionNodeName
{
  var itemsSelectionField = lookup["items"].FirstOrDefault();
  var childSelections = GatherChildSelections(_resolverContext, itemsSelectionField);
  selectionResults.AddRange(childSelections);
} 
//Handle Non-paging cases; current Node is an Entity...
else 
{
...

in IResolverContextSelectionExtensions right after if (lookup.Contains(SelectionNodeName.Nodes) || lookup.Contains(SelectionNodeName.Edges)) )

The thing is, i have not idea if this break some backward compatibility or something (items is really generic name 🤦 ) Also, i have no idea if this break your RepoDb implementation (we dont use RepoDb)

kondelik commented 3 years ago

Oh, i just noticed your GraphQLParamsContext.AllSelectionNames is virtual... Thats great! 👍 I can just override it. Pity that IResolverContextSelectionExtensions.Find/Gather are private...

kondelik commented 3 years ago

Duh, i just realized i dont need .UseOffsetPaging() but just .AddOffsetPagingArguments() which behave differently and everything works just fine (there isnt any items subtype)

So, for some random googler who may find this useful, my solution ends up like this:

# nullable enable

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using HotChocolate;
using HotChocolate.Execution.Processing;
using HotChocolate.Language;
using HotChocolate.PreProcessingExtensions;
using HotChocolate.PreProcessingExtensions.Selections;
using HotChocolate.Resolvers;
using HotChocolate.Types;
using ExSelection = HotChocolate.PreProcessingExtensions.PreProcessingSelection;

namespace StitchedQuery
{
  // heavily inspired by https://github.com/cajuncoding/GraphQL.RepoDb

  public class PatchedGraphQLParams : GraphQLParamsContext
  {
    private const string ItemsSelectionDescription = "items";

    public PatchedGraphQLParams([NotNull] IResolverContext resolverContext)
    : base(resolverContext)
    {
      _resolverContext = resolverContext ?? throw new ArgumentNullException(nameof(resolverContext));
    }

    public override IReadOnlyList<IPreProcessingSelection> AllSelectionFields
    {
      get
      {
        return _selectionFields ??= GetPreProcessingSelections();
      }
    }

    private List<IPreProcessingSelection> GetPreProcessingSelections()
    {
      var selectionResults = new List<IPreProcessingSelection>();

      var selections = GatherChildSelections(_resolverContext!);
      if (selections.Any())
      {
        //BBernard
        //Determine if the Selection is for a Connection, and dive deeper to get the real
        //  selections from the node {} field.
        var lookup = selections.ToLookup(s => s.SelectionName.ToString().ToLower());

        //Handle paging cases; current Node is a Connection so we have to look for selections inside
        //  ->edges->nodes, or inside the ->nodes (shortcut per Relay spec); both of which may exist(?)
        if (lookup.Contains(IResolverContextSelectionExtensions.SelectionNodeName.Nodes) 
            || lookup.Contains(IResolverContextSelectionExtensions.SelectionNodeName.Edges))
        {
          //NOTE: nodes and edges are not mutually exclusive per Relay spec so
          //          we gather from all if they are defined...
          if (lookup.Contains(IResolverContextSelectionExtensions.SelectionNodeName.Nodes))
          {
            var nodesSelectionField = lookup[IResolverContextSelectionExtensions.SelectionNodeName.Nodes].FirstOrDefault();
            var childSelections = GatherChildSelections(_resolverContext, nodesSelectionField);
            selectionResults.AddRange(childSelections);
          }

          if (lookup.Contains(IResolverContextSelectionExtensions.SelectionNodeName.Edges))
          {
            var edgesSelectionField = lookup[IResolverContextSelectionExtensions.SelectionNodeName.Edges].FirstOrDefault();
            var nodeSelectionField = FindChildSelectionByName(_resolverContext, IResolverContextSelectionExtensions.SelectionNodeName.EdgeNode, edgesSelectionField);
            var childSelections = GatherChildSelections(_resolverContext, nodeSelectionField);
            selectionResults.AddRange(childSelections);
          }
        } else if (lookup.Contains(ItemsSelectionDescription))
        {
          var itemsSelectionField = lookup[ItemsSelectionDescription].FirstOrDefault();

          var childSelections = GatherChildSelections(_resolverContext, itemsSelectionField);
          selectionResults.AddRange(childSelections);
        }
        //Handle Non-paging cases; current Node is an Entity...
        else
        {
          selectionResults.AddRange(selections);
        }

      }

      return selectionResults;
    }

    /// <summary>
    /// Find the selection that matches the speified name.
    /// For more info. on Node parsing logic see here:
    /// https://github.com/ChilliCream/hotchocolate/blob/a1f2438b74b19e965b560ca464a9a4a896dab79a/src/Core/Core.Tests/Execution/ResolverContextTests.cs#L83-L89
    /// </summary>
    /// <param name="context"></param>
    /// <param name="baseSelection"></param>
    /// <param name="selectionFieldName"></param>
    /// <returns></returns>
    private static ExSelection FindChildSelectionByName(IResolverContext? context, string selectionFieldName, ExSelection? baseSelection)
    {
      if (context == null)
        return null!;

      var childSelections = GatherChildSelections(context!, baseSelection);
      var resultSelection = childSelections?.FirstOrDefault(
        s => s.SelectionName.ToString().Equals(selectionFieldName, StringComparison.OrdinalIgnoreCase)
      )!;

      return resultSelection!;
    }

    /// <summary>
    /// Gather all child selections of the specified Selection
    /// For more info. on Node parsing logic see here:
    /// https://github.com/ChilliCream/hotchocolate/blob/a1f2438b74b19e965b560ca464a9a4a896dab79a/src/Core/Core.Tests/Execution/ResolverContextTests.cs#L83-L89
    /// </summary>
    /// <param name="context"></param>
    /// <param name="baseSelection"></param>
    /// <returns></returns>
    private static List<ExSelection> GatherChildSelections(IResolverContext? context, ExSelection? baseSelection = null)
    {
      if (context == null)
        return null!;

      var gathered = new List<ExSelection>();

      //Initialize the optional base field selection if specified...
      var baseFieldSelection = baseSelection?.GraphQLFieldSelection;

      //Dynamically support re-basing to the specified baseSelection or fallback to current Context.Field
      var field = baseFieldSelection?.Field ?? context.Field;

      //Initialize the optional SelectionSet to rebase processing as the root for GetSelections()
      //  if specified (but is optional & null safe)...
      SelectionSetNode? baseSelectionSetNode = baseFieldSelection is ISelection baseISelection
          ? baseISelection.SelectionSet
          : null!;

      //Get all possible ObjectType(s); InterfaceTypes & UnitionTypes will have more than one...
      var objectTypes = GetObjectTypesSafely(field.Type, context.Schema);

      //Map all object types into PreProcessingSelection (adapter classes)...
      foreach (var objectType in objectTypes)
      {
        //Now we can process the ObjectType with the correct context (selectionSet may be null resulting
        //  in default behavior for current field.
        var childSelections = context.GetSelections(objectType, baseSelectionSetNode);
        var preprocessSelections = childSelections.Select(s => new ExSelection(objectType, s));
        gathered.AddRange(preprocessSelections);
      }

      return gathered;
    }

    /// <summary>
    /// ObjectType resolver function to get the current object type enhanced with support
    /// for InterfaceTypes & UnionTypes; initially modeled after from HotChocolate source:
    /// HotChocolate.Data -> SelectionVisitor`1.cs
    /// </summary>
    /// <param name="type"></param>
    /// <param name="objectType"></param>
    /// <param name="schema"></param>
    /// <returns></returns>
    private static List<ObjectType> GetObjectTypesSafely(IType type, ISchema schema)
    {
      var results = new List<ObjectType>();
      switch (type)
      {
        case NonNullType nonNullType:
          results.AddRange(GetObjectTypesSafely(nonNullType.NamedType(), schema));
          break;
        case ObjectType objType:
          results.Add(objType);
          break;
        case ListType listType:
          results.AddRange(GetObjectTypesSafely(listType.InnerType(), schema));
          break;
        case InterfaceType interfaceType:
          var possibleInterfaceTypes = schema.GetPossibleTypes(interfaceType);
          var objectTypesForInterface = possibleInterfaceTypes.SelectMany(t => GetObjectTypesSafely(t, schema));
          results.AddRange(objectTypesForInterface);
          break;
        //TODO: TEST UnionTypes!
        case UnionType unionType:
          var possibleUnionTypes = schema.GetPossibleTypes(unionType);
          var objectTypesForUnion = possibleUnionTypes.SelectMany(t => GetObjectTypesSafely(t, schema));
          results.AddRange(objectTypesForUnion);
          break;
      }

      return results;
    }
  }
}

But then i realized .UseOffsetPaging() is paging my already paged data, so i throw it out and used .AddOffsetPagingArguments().

cajuncoding commented 3 years ago

@kondelik I’m very glad your finding this project helpful! If you like it then give it a Star (shameless plug) :-)

Unfortunately, as is noted in the Readme, Offset paging is not fully implemented...I started working on it but it’s not a use case for my current project and other priorities has prevented me from finishing it.

But to confirm, I do plan on finishing it just as I currently do for Cursor paging. There are multiple parts to fully implementing including Selections safely and intelligently getting selections (requires traversing the items node), as well as interface and custom paging to prevent redundant processing of the pre-processed results. And of course testing it :-)

As you noted the default paging handler that gets wired up when you add UseOffsetPaging() will result in unnecessarily post processing the results again, which for ease of use requires a custom paging handler that actually short circuits this default HC behavior.

But thanks for sharing your workaround for now!

And yes I’ve intentionally made as much as possible virtual and extensible, but I’ll be sure to look for anything else that is private but may be helpful to make protected virtual, thanks for the feedback.

cajuncoding commented 3 years ago

@kondelik FYI I finally had some time to re-visit OffsetPaging support (as it was not fully supported) and have now added full support with matching functionality to CursorPaging in the pre-processing extensions, selections, and paging handlers, etc....

It is now available to implement with you own paging (e.g. Linq) within the Resolver, and provided an example resolver in the Star Wars-AzureFunctions project starWarsCharactersOffsetPaginated resolver, as well as several unit tests.

Note: The RepoDB Sql Server implementation for Offset Paging is still pending...