问题描述
我使用 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 回答您的问题,您必须执行以下关键步骤:
- 获取客户端在 GraphQL 查询中请求的选择的完整列表。
- 从查询中获取过滤器参数(幸运的是这很简单)
- 如果您想在 SQL 服务器级别应用排序,请获取排序参数(您没有问过这个问题,但我相信您很快就会问到)。
- 将这些 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 查询中设置的那些字段。