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

How to customize SQL query according to GraphQL request using HotChocolate and Dapper?

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

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

变为:

SELECT title, price FROM products WHERE id = 3;

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

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

好问题!

我和你在同一条船上……需要在没有 EF 或 IQueryable 等的情况下实现我们自己的数据检索执行,同时还要获得预测的价值,尤其是在我们可以使用的地方使用微型 ORM 或存储库模式' t 并且不想 return IQueryable 使用 HC 的延迟执行等…

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

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

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

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

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

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

为了 posterity,我将在此处 post 编写代码,以向您确切展示您可以实现什么来作为扩展方法来处理这些情况……但它直接取自我的开源项目 I'我们分享了(是的,我是作者)通过简化的 facade 为 HC IResolverContext 使这变得更容易;它提供了更容易访问除选择之外的其他重要内容,这是下面所有代码的重点。

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

第 1 步:获取选择

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

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 Table 字段名称不同。因此,如果使用 Dapper 注释等,您可能必须实施一些反射处理才能获取实际的字段名称。

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

有很多方法可以做到这一点,但一个好的方法可能是引入一个很棒的 SQL 构建器包,比如 SqlKata 我绝对推荐这样做 correctly/safely 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