将SQL查询限制为Graphene-SQLAlchemy中定义的字段/列

时间:2018-05-24 06:51:17

标签: python sql sqlalchemy graphql graphene-python

  

此问题已作为https://github.com/graphql-python/graphene-sqlalchemy/issues/134下的GH问题发布,但我认为我也会将其发布在此处以吸引SO人群。

     

可以在https://github.com/somada141/demo-graphql-sqlalchemy-falcon下找到完整的工作演示。

考虑以下SQLAlchemy ORM类:

class Author(Base, OrmBaseMixin):
    __tablename__ = "authors"

    author_id = sqlalchemy.Column(
        sqlalchemy.types.Integer(),
        primary_key=True,
    )

    name_first = sqlalchemy.Column(
        sqlalchemy.types.Unicode(length=80),
        nullable=False,
    )

    name_last = sqlalchemy.Column(
        sqlalchemy.types.Unicode(length=80),
        nullable=False,
    )

简单地用SQLAlchemyObjectType包裹:

class TypeAuthor(SQLAlchemyObjectType):
    class Meta:
        model = Author

并透露:

author = graphene.Field(
    TypeAuthor,
    author_id=graphene.Argument(type=graphene.Int, required=False),
    name_first=graphene.Argument(type=graphene.String, required=False),
    name_last=graphene.Argument(type=graphene.String, required=False),
)

@staticmethod
def resolve_author(
    args,
    info,
    author_id: Union[int, None] = None,
    name_first: Union[str, None] = None,
    name_last: Union[str, None] = None,
):
    query = TypeAuthor.get_query(info=info)

    if author_id:
        query = query.filter(Author.author_id == author_id)

    if name_first:
        query = query.filter(Author.name_first == name_first)

    if name_last:
        query = query.filter(Author.name_last == name_last)

    author = query.first()

    return author

GraphQL查询,例如:

query GetAuthor{
  author(authorId: 1) {
    nameFirst
  }
}

将导致发出以下原始SQL(取自SQLA引擎的echo日志):

SELECT authors.author_id AS authors_author_id, authors.name_first AS authors_name_first, authors.name_last AS authors_name_last
FROM authors
WHERE authors.author_id = ?
 LIMIT ? OFFSET ?
2018-05-24 16:23:03,669 INFO sqlalchemy.engine.base.Engine (1, 1, 0)

正如我们所看到的那样,我们可能只需要nameFirst字段,即name_first列,但会获取整行。当然,GraphQL响应仅包含请求的字段,即

{
  "data": {
    "author": {
      "nameFirst": "Robert"
    }
  }
}

但是我们仍然提取整行,这在处理宽表时成为一个主要问题。

有没有办法自动传达SQLAlchemy需要哪些列,以排除这种形式的过度获取?

1 个答案:

答案 0 :(得分:0)

我的问题已在GitHub问题(https://github.com/graphql-python/graphene-sqlalchemy/issues/134)上得到解答。

我们的想法是识别info参数(类型为graphql.execution.base.ResolveInfo)的请求字段,该字段通过get_field_names函数传递给解析器函数,如下所示:

def get_field_names(info):
    """
    Parses a query info into a list of composite field names.
    For example the following query:
        {
          carts {
            edges {
              node {
                id
                name
                ...cartInfo
              }
            }
          }
        }
        fragment cartInfo on CartType { whatever }

    Will result in an array:
        [
            'carts',
            'carts.edges',
            'carts.edges.node',
            'carts.edges.node.id',
            'carts.edges.node.name',
            'carts.edges.node.whatever'
        ]
    """

    fragments = info.fragments

    def iterate_field_names(prefix, field):
        name = field.name.value

        if isinstance(field, FragmentSpread):
            _results = []
            new_prefix = prefix
            sub_selection = fragments[field.name.value].selection_set.selections
        else:
            _results = [prefix + name]
            new_prefix = prefix + name + "."
            if field.selection_set:
                sub_selection = field.selection_set.selections
            else:
                sub_selection = []

        for sub_field in sub_selection:
            _results += iterate_field_names(new_prefix, sub_field)

        return _results

    results = iterate_field_names('', info.field_asts[0])

    return results
  

上述功能取自https://github.com/graphql-python/graphene/issues/348#issuecomment-267717809。该问题包含此功能的其他版本,但我觉得这是最完整的。

并使用标识的字段来限制SQ​​LAlchemy查询中的检索字段:

fields = get_field_names(info=info)
query = TypeAuthor.get_query(info=info).options(load_only(*relation_fields))

应用于上述示例查询时:

query GetAuthor{
  author(authorId: 1) {
    nameFirst
  }
}

get_field_names函数将返回['author', 'author.nameFirst']。然而,作为原作' SQLAlchemy ORM字段是蛇形的,需要更新get_field_names查询以删除author前缀并通过graphene.utils.str_converters.to_snake_case函数转换字段名。

长话短说,上面的方法产生了一个原始SQL查询,如下所示:

INFO:sqlalchemy.engine.base.Engine:SELECT authors.author_id AS authors_author_id, authors.name_first AS authors_name_first
FROM authors
WHERE authors.author_id = ?
 LIMIT ? OFFSET ?
2018-06-09 13:22:16,396 INFO sqlalchemy.engine.base.Engine (1, 1, 0)

<强>更新

如果有人在这里想到了实施,我就开始实施我自己版本的get_query_fields功能:

from typing import List, Dict, Union, Type

import graphql
from graphql.language.ast import FragmentSpread
from graphql.language.ast import Field
from graphene.utils.str_converters import to_snake_case
import sqlalchemy.orm

from demo.orm_base import OrmBaseMixin

def extract_requested_fields(
    info: graphql.execution.base.ResolveInfo,
    fields: List[Union[Field, FragmentSpread]],
    do_convert_to_snake_case: bool = True,
) -> Dict:
    """Extracts the fields requested in a GraphQL query by processing the AST
    and returns a nested dictionary representing the requested fields.

    Note:
        This function should support arbitrarily nested field structures
        including fragments.

    Example:
        Consider the following query passed to a resolver and running this
        function with the `ResolveInfo` object passed to the resolver.

        >>> query = "query getAuthor{author(authorId: 1){nameFirst, nameLast}}"
        >>> extract_requested_fields(info, info.field_asts, True)
        {'author': {'name_first': None, 'name_last': None}}

    Args:
        info (graphql.execution.base.ResolveInfo): The GraphQL query info passed
            to the resolver function.
        fields (List[Union[Field, FragmentSpread]]): The list of `Field` or
            `FragmentSpread` objects parsed out of the GraphQL query and stored
            in the AST.
        do_convert_to_snake_case (bool): Whether to convert the fields as they
            appear in the GraphQL query (typically in camel-case) back to
            snake-case (which is how they typically appear in ORM classes).

    Returns:
        Dict: The nested dictionary containing all the requested fields.
    """

    result = {}
    for field in fields:

        # Set the `key` as the field name.
        key = field.name.value

        # Convert the key from camel-case to snake-case (if required).
        if do_convert_to_snake_case:
            key = to_snake_case(name=key)

        # Initialize `val` to `None`. Fields without nested-fields under them
        # will have a dictionary value of `None`.
        val = None

        # If the field is of type `Field` then extract the nested fields under
        # the `selection_set` (if defined). These nested fields will be
        # extracted recursively and placed in a dictionary under the field
        # name in the `result` dictionary.
        if isinstance(field, Field):
            if (
                hasattr(field, "selection_set") and
                field.selection_set is not None
            ):
                # Extract field names out of the field selections.
                val = extract_requested_fields(
                    info=info,
                    fields=field.selection_set.selections,
                )
            result[key] = val
        # If the field is of type `FragmentSpread` then retrieve the fragment
        # from `info.fragments` and recursively extract the nested fields but
        # as we don't want the name of the fragment appearing in the result
        # dictionary (since it does not match anything in the ORM classes) the
        # result will simply be result of the extraction.
        elif isinstance(field, FragmentSpread):
            # Retrieve referened fragment.
            fragment = info.fragments[field.name.value]
            # Extract field names out of the fragment selections.
            val = extract_requested_fields(
                info=info,
                fields=fragment.selection_set.selections,
            )
            result = val

    return result

将AST解析为dict,保留查询结构,并(希望)匹配ORM的结构。

运行查询的info对象,如:

query getAuthor{
  author(authorId: 1) {
    nameFirst,
    nameLast
  }
}

产生

{'author': {'name_first': None, 'name_last': None}}

虽然这是一个更复杂的查询:

query getAuthor{
  author(nameFirst: "Brandon") {
    ...authorFields
    books {
      ...bookFields
    }
  }
}

fragment authorFields on TypeAuthor {
  nameFirst,
  nameLast
}

fragment bookFields on TypeBook {
  title,
  year
}

产生

{'author': {'books': {'title': None, 'year': None},
  'name_first': None,
  'name_last': None}}

现在,这些词典可用于定义主表(在这种情况下为Author)上的字段,因为它们的值为None,例如{{1或{@ 1}}关系中该主要表格的关系字段,例如字段name_first

自动应用这些字段的简单方法可以采用以下函数的形式:

title