如何使用 HotChocolate 和 Dapper 根据 GraphQL 请求自定义 SQL 查询?

问题描述

我使用 HotChocolate 作为我的 GraphQL 服务器和 Dapper 来访问我项目中的数据库。目前,随着每个 graphql 查询请求实体的“某些”字段,从数据库查询整行,这会浪费资源,尤其是在查询数据列表时。我想根据graphql查询中请求的字段自定义SQL查询。 所以,这个

{
  product(id: 3) {
    title,price
  }
}

变成:

SELECT title,price FROM products WHERE id = 3;

HotChocolate 中有一个称为投影的功能,我认为它与我的问题有关。但不幸的是,文档不完整,只展示了一些使用实体框架进行投影的示例。有没有办法使用 Dapper 实现这个功能?怎么样?

解决方法

好问题!

我和你在同一条船上……需要在没有 EF 或 IQueryable 等的情况下实现我们自己的数据检索执行,同时还要获得投影的价值,尤其是使用微 ORM 或存储库模式,而我们不能和不想使用HC的延迟执行等返回IQueryable...

这当然是可能的,不幸的是,它并不像看起来那么简单或简单……它可能会变得复杂,主要是由于 GraphQL 规范定义的广泛功能。

在简单查询中,您应该使用 IResolverContext.GetSelctions() API 并检索选择名称列表;因为这是获取所需数据的受支持 API,并且有许多必须使用它的低级原因。

但是你很快就会发现它本身是一个低级实用程序,并且可能不再足够,因为一旦你使用导致使用的接口类型或联合类型,它就会变得更加复杂GraphQL 查询片段。

然后,当您添加偏移分页或游标分页时,查询结构会再次发生变化,因此您需要处理它(或两者都处理,因为这是两种不同的结构)...

现在要专门针对 Dapper 回答您的问题,您必须执行以下关键步骤:

  1. 获取客户端在 GraphQL 查询中请求的选择的完整列表。
  2. 从查询中获取过滤器参数(幸运的是这很简单)
  3. 如果您想在 SQL 服务器级别应用排序,请获取排序参数(您没有问过这个问题,但我相信您很快就会问到)。
  4. 将这些 GraphQL 元素转换为格式良好的 SQL 查询并使用 Dapper 执行它。

为了后代,我将在这里发布代码,以向您展示您可以实现什么来处理这些情况作为扩展方法……但它直接取自我分享的开源项目(是的,我是作者)使用简化的 facade 为 HC 的 IResolverContext 简化此操作;它可以让您更轻松地访问除选择之外的其他重要内容,这是以下所有代码的重点。

我也实现了一个 full micro-ORM wrapper using this facade for RepoDB,但类似的可以用 Dapper 来完成(尽管需要做更多的工作,因为 RepoDB 提供了用于动态处理模型和从字符串选择名称查询)。

第 1 步:获取选择

这里的代码用于获取您的选择并处理上面针对联合类型、接口类型、分页等突出显示的用例类型(取自 Github 上共享的 GraphQL.PreProcessingExtensions 库。

public static class IResolverContextSelectionExtensions
{
    /// <summary>
    /// Similar to CollectFields in v10,this uses GetSelections but safely validates that the current context
    ///     has selections before returning them,it will safely return null if unable to do so.
    /// This is a variation of the helper method provided by HotChocolate team here: 
    ///     https://github.com/ChilliCream/hotchocolate/issues/1527#issuecomment-596175928
    /// </summary>
    /// <param name="context"></param>
    /// <returns></returns>
    public static IReadOnlyList<PreProcessingSelection> GetPreProcessingSelections(this IResolverContext? context)
    {
        if (context == null)
            return null!;

        var selectionResults = new List<PreProcessingSelection>();

        var selections = GatherChildSelections(context!);
        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(SelectionNodeName.Nodes) || lookup.Contains(SelectionNodeName.Edges) || lookup.Contains(SelectionNodeName.Items))
            {
                //Cursor & Offset Paging are mutually exclusive so this small optimization prevents unnecessary processing...
                var searchOffsetPagingEnabled = true;

                //CURSOR PAGING SUPPORT - results are in either a 'Nodes' or 'Edges' Node!
                //NOTE: nodes and edges are not mutually exclusive per Relay spec so
                //          we gather from all if they are defined...
                if (lookup.Contains(SelectionNodeName.Nodes))
                {
                    var nodesSelectionField = lookup[SelectionNodeName.Nodes].FirstOrDefault();
                    var childSelections = GatherChildSelections(context,nodesSelectionField);
                    selectionResults.AddRange(childSelections);

                    searchOffsetPagingEnabled = false;
                }

                if (lookup.Contains(SelectionNodeName.Edges))
                {
                    var edgesSelectionField = lookup[SelectionNodeName.Edges].FirstOrDefault();
                    //If Edges are specified then Selections are actually inside a nested 'Node' (singular,not plural) that we need to traverse...
                    var nodesSelectionField = FindChildSelectionByName(context,SelectionNodeName.EdgeNode,edgesSelectionField);
                    var childSelections = GatherChildSelections(context,nodesSelectionField);
                    selectionResults.AddRange(childSelections);
                    
                    searchOffsetPagingEnabled = false;
                }

                //OFFSET PAGING SUPPORT - results are in an 'Items' Node!
                if (searchOffsetPagingEnabled && lookup.Contains(SelectionNodeName.Items))
                {
                    var nodesSelectionField = lookup[SelectionNodeName.Items].FirstOrDefault();
                    var childSelections = GatherChildSelections(context,nodesSelectionField);
                    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 specified 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 PreProcessingSelection FindChildSelectionByName(IResolverContext? context,string selectionFieldName,PreProcessingSelection? baseSelection)
    {
        if (context == null)
            return null!;

        var childSelections = GatherChildSelections(context!,baseSelection);
        var resultSelection = childSelections?.FirstOrDefault(
            s => s.SelectionName.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<PreProcessingSelection> GatherChildSelections(IResolverContext? context,PreProcessingSelection? baseSelection = null)
    {
        if (context == null)
            return null!;

        var gathered = new List<PreProcessingSelection>();

        //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 & UnionTypes 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 PreProcessingSelection(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;
            case UnionType unionType:
                var possibleUnionTypes = schema.GetPossibleTypes(unionType);
                var objectTypesForUnion = possibleUnionTypes.SelectMany(t => GetObjectTypesSafely(t,schema));
                results.AddRange(objectTypesForUnion);
                break;
        }

        return results;
    }
}

第 2 步:获取过滤参数

这对于简单的参数和对象来说要简单得多,因为 HC 在为我们提供访问权限方面做得非常好:

    [GraphQLName("products")]
    public async Task<IEnumerable<Products> GetProductsByIdAsync(
        IResolverContext context,[Service] ProductsService productsService,CancellationToken cancellationToken,int id
    )
    {
        //Per the Annotation based Resolver signature here HC will inject the 'id' argument for us!
        //Otherwise this is just normal Resolver stuff...
        var productId = id;

        //Also you could get the argument from the IResolverContext...
        var productId = context.Argument<int>("id");. . . 
    }

第 3 步:获取排序参数

注意:在这里获取参数很容易,但是,排序参数同时具有名称和排序方向,并且在使用 micro-orm 时需要将它们映射到模型名称。所以这不是微不足道的,但很有可能:

public static class IResolverContextSortingExtensions
{
    /// <summary>
    /// Safely process the GraphQL context to retrieve the Order argument;
    /// matches the default name used by HotChocolate Sorting middleware (order: {{field1}: ASC,{field2}: DESC).
    /// Will return null if the order arguments/info is not available.
    /// </summary>
    /// <returns></returns>
    public static List<ISortOrderField>? GetSortingArgsSafely(this IResolverContext context,string sortOrderArgName = null!)
    {
        var results = new List<ISortOrderField>();

        //Unfortunately the Try/Catch is required to make this safe for easier coding when the argument is not specified,//  because the ResolverContext doesn't expose a method to check if an argument exists...
        try
        {
            var sortArgName = sortOrderArgName ?? SortConventionDefinition.DefaultArgumentName;

            //Get Sort Argument Fields and current Values...
            //NOTE: In order to correctly be able to Map names from GraphQL Schema to property/member names
            //      we need to get both the Fields (Schema) and the current order values...
            //NOTE: Not all Queries have Fields (e.g. no Selections,just a literal result),so .Field may
            //      throw internal NullReferenceException,hence we have the wrapper Try/Catch.
            IInputField sortArgField = context.Field.Arguments[sortArgName];
            ObjectValueNode sortArgValue = context.ArgumentLiteral<ObjectValueNode>(sortArgName);

            //Validate that we have some sort args specified and that the Type is correct (ListType of SortInputType values)...
            //NOTE: The Following processing logic was adapted from 'QueryableSortProvider' implementation in HotChocolate.Data core.
            //FIX: The types changed in v11.0.1/v11.0.2 the Sort Field types need to be checked with IsNull() method,and
            //      then against NonNullType.NamedType() is ISortInputType instead.
            if (!sortArgValue.IsNull()
                && sortArgField.Type is ListType lt
                && lt.ElementType is NonNullType nn 
                && nn.NamedType() is ISortInputType sortInputType)
            {
                //Create a Lookup for the Fields...
                var sortFieldLookup = sortInputType.Fields.OfType<SortField>().ToLookup(f => f.Name.ToString().ToLower());

                //Now only process the values provided,but initialize with the corresponding Field (metadata) for each value...
                var sortOrderFields = sortArgValue.Fields.Select(
                    f => new SortOrderField(
                        sortFieldLookup[f.Name.ToString().ToLower()].FirstOrDefault(),f.Value.ToString()
                    )
                );

                results.AddRange(sortOrderFields);
            }

            return results;
        }
        catch
        {
            //Always safely return at least an Empty List to help minimize Null Reference issues.
            return results;
        }
    }
}

第 4 步:将所有这些转换为有效的 SQL 语句...

现在您可以轻松使用选择名称列表、排序参数,甚至过滤器参数值。但是,这些选择名称可能与您的 C# 模型名称或 SQL 表字段名称不同。因此,如果使用了 Dapper 注释等,您可能需要实施一些反射处理来获取实际的字段名称。

但是一旦您将选择名称映射到实际的 SQL 表字段名称;这是特定于实现的,可能需要另一个问题来回答...然后您可以将它们转换为有效的 SQL。

有很多方法可以做到这一点,但一种好​​方法可能是引入一个很棒的 SQL 构建器包,例如 SqlKata,我绝对推荐它通过缓解 SQL 注入等正确/安全地执行此操作。重要,这些库让这一切变得更容易。

然后您可以使用构建的 SQL 并通过 Dapper 执行它。 . .您不必使用 SqlKata 执行引擎。

然而,这是我将项目转移到 RepoDB众多原因之一,因为这种处理更容易并且只使用一个包——但我不用担心使用 SqlKata。

所有这些都可以在 Nuget package 中轻松获得,让生活更轻松...

使用微 ORM 处理解析器内部数据的扩展和简化外观: https://github.com/cajuncoding/GraphQL.RepoDB/tree/main/GraphQL.PreProcessingExtensions

以及使用 RepoDB 的完整实现: https://github.com/cajuncoding/GraphQL.RepoDB

,

好吧,我管理了它(老实说,我使用它是因为聚合字段功能),但是通过另一个包 NReco.GraphQL - 它完全采用您在 GraphQL 查询中设置的那些字段。