Repository: graphql-python/graphene-django

Issue:
How to get `only_fields = ((),)` behavior? Goal: only shows fields declared in object type.
First: Apologies for this if it's not descriptive or covered by graphene-django. I actually think this is something that's requested in other issues, via #671 #97

When updating graphene-django from 2.4.0 -> 2.8.0, behavior with `only_fields = ((), )` changed

I inherited this as a code snippet and don't know where it's from.

What it used to do: Only fields that were in defined in the graphene object type would be shown.

Is there a way to get the equivalent to that?

Also, is there a way to get computed fields to also show via `fields = ()`?

Assume this example:

```python
from django.db import models

class Reporter(models.Model):
    first_name = models.CharField(max_length=30)
    last_name = models.CharField(max_length=30)
    email = models.EmailField()

    def website(self):
        return "https://www.google.com"
```

Before: Only showed model fields declared in object type
Now: 

`fields = ((),)` gives:

```
  File "lib/python3.7/site-packages/graphene/utils/subclass_with_meta.py", line 52, in __init_subclass__
    super_class.__init_subclass_with_meta__(**options)
  File "lib/python3.7/site-packages/graphene_django/types.py", line 174, in __init_subclass_with_meta__
    construct_fields(model, registry, fields, exclude, convert_choices_to_enum),
  File "lib/python3.7/site-packages/graphene_django/types.py", line 42, in construct_fields
    if hasattr(model, name):
```

Changing to `fields = []` will show all fields on model.

The problem with this is its very exhaustive maintain a blacklist of fields via `exclude`. Since the model lives elsewhere in the code, it's thought of a bit separately. (Aside: This issue also exists in `django.contrib.admin` itself.)

```python
class Reporter(DjangoObjectType):
    first_name = graphene.String(required=True, description='First name')
    class Meta:
        model = ReporterModel
        fields = []

# expected
fields = list(Reporter._meta.fields.keys())
assert fields == ["first_name"]
```

Before: Allowed computed fields
Now: `Exception: "my_computed field" exists on model <class 'mymodel.Reporter'> but it's not a field.`

```python
class Reporter(DjangoObjectType):
    website = graphene.String(required=True, description='First name')
    class Meta:
        model = ReporterModel
        fields = ['website']

# Expected
fields = list(Reporter._meta.fields.keys())
assert fields == ["website"]
```

Discussion:
tony: @jkimbo Nice!  Can you rebase this by chance?

Patch:
diff --git a/docs/fields.rst b/docs/fields.rst
new file mode 100644
index 0000000..1a8afc3
--- /dev/null
+++ b/docs/fields.rst
@@ -0,0 +1,83 @@
+Fields
+======
+
+Graphene-Django provides some useful fields to help integrate Django with your GraphQL
+Schema.
+
+DjangoListField
+---------------
+
+``DjangoListField`` allows you to define a list of :ref:`DjangoObjectType<queries-objecttypes>`'s. By default it will resolve the default queryset of the Django model.
+
+.. code:: python
+
+   from graphene import ObjectType, Schema
+   from graphene_django import DjangoListField
+
+   class RecipeType(DjangoObjectType):
+      class Meta:
+         model = Recipe
+         fields = ("title", "instructions")
+
+   class Query(ObjectType):
+      recipes = DjangoListField(RecipeType)
+
+   schema = Schema(query=Query)
+
+The above code results in the following schema definition:
+
+.. code::
+
+   schema {
+     query: Query
+   }
+
+   type Query {
+     recipes: [RecipeType!]
+   }
+
+   type RecipeType {
+     title: String!
+     instructions: String!
+   }
+
+Custom resolvers
+****************
+
+If your ``DjangoObjectType`` has defined a custom
+:ref:`get_queryset<django-objecttype-get-queryset>` method, when resolving a
+``DjangoListField`` it will be called with either the return of the field
+resolver (if one is defined) or the default queryeset from the Django model.
+
+For example the following schema will only resolve recipes which have been
+published and have a title:
+
+.. code:: python
+
+   from graphene import ObjectType, Schema
+   from graphene_django import DjangoListField
+
+   class RecipeType(DjangoObjectType):
+      class Meta:
+         model = Recipe
+         fields = ("title", "instructions")
+
+      @classmethod
+      def get_queryset(cls, queryset, info):
+         # Filter out recipes that have no title
+         return queryset.exclude(title__exact="")
+
+   class Query(ObjectType):
+      recipes = DjangoListField(RecipeType)
+
+      def resolve_recipes(parent, info):
+         # Only get recipes that have been published
+         return Recipe.objects.filter(published=True)
+
+   schema = Schema(query=Query)
+
+
+DjangoConnectionField
+---------------------
+
+*TODO*
diff --git a/docs/filtering.rst b/docs/filtering.rst
index 0d37f46..dbbab9d 100644
--- a/docs/filtering.rst
+++ b/docs/filtering.rst
@@ -1,7 +1,7 @@
 Filtering
 =========
 
-Graphene integrates with
+Graphene-Django integrates with
 `django-filter <https://django-filter.readthedocs.io/en/master/>`__ (2.x for
 Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
 documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
diff --git a/docs/index.rst b/docs/index.rst
index 93f37db..f4f718c 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -25,6 +25,7 @@ For more advanced use, check out the Relay tutorial.
    tutorial-relay
    schema
    queries
+   fields
    extra-types
    mutations
    filtering
diff --git a/docs/queries.rst b/docs/queries.rst
index 36cdab1..4b3f718 100644
--- a/docs/queries.rst
+++ b/docs/queries.rst
@@ -1,3 +1,5 @@
+.. _queries-objecttypes:
+
 Queries & ObjectTypes
 =====================
 
@@ -205,6 +207,8 @@ need to create the most basic class for this to work:
         class Meta:
             model = Category
 
+.. _django-objecttype-get-queryset:
+
 Default QuerySet
 -----------------
 
diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py
index 38f8d8a..62318e9 100644
--- a/graphene_django/__init__.py
+++ b/graphene_django/__init__.py
@@ -1,6 +1,11 @@
+from .fields import DjangoConnectionField, DjangoListField
 from .types import DjangoObjectType
-from .fields import DjangoConnectionField
 
 __version__ = "2.9.1"
 
-__all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"]
+__all__ = [
+    "__version__",
+    "DjangoObjectType",
+    "DjangoListField",
+    "DjangoConnectionField",
+]
diff --git a/graphene_django/fields.py b/graphene_django/fields.py
index fb6b98a..7539cf2 100644
--- a/graphene_django/fields.py
+++ b/graphene_django/fields.py
@@ -38,16 +38,21 @@ class DjangoListField(Field):
     def model(self):
         return self._underlying_type._meta.model
 
+    def get_default_queryset(self):
+        return self.model._default_manager.get_queryset()
+
     @staticmethod
-    def list_resolver(django_object_type, resolver, root, info, **args):
+    def list_resolver(
+        django_object_type, resolver, default_queryset, root, info, **args
+    ):
         queryset = maybe_queryset(resolver(root, info, **args))
         if queryset is None:
-            # Default to Django Model queryset
-            # N.B. This happens if DjangoListField is used in the top level Query object
-            model_manager = django_object_type._meta.model.objects
-            queryset = maybe_queryset(
-                django_object_type.get_queryset(model_manager, info)
-            )
+            queryset = default_queryset
+
+        if isinstance(queryset, QuerySet):
+            # Pass queryset to the DjangoObjectType get_queryset method
+            queryset = maybe_queryset(django_object_type.get_queryset(queryset, info))
+
         return queryset
 
     def get_resolver(self, parent_resolver):
@@ -55,7 +60,12 @@ class DjangoListField(Field):
         if isinstance(_type, NonNull):
             _type = _type.of_type
         django_object_type = _type.of_type.of_type
-        return partial(self.list_resolver, django_object_type, parent_resolver)
+        return partial(
+            self.list_resolver,
+            django_object_type,
+            parent_resolver,
+            self.get_default_queryset(),
+        )
 
 
 class DjangoConnectionField(ConnectionField):
diff --git a/graphene_django/types.py b/graphene_django/types.py
index 0c0cb1c..18dccb2 100644
--- a/graphene_django/types.py
+++ b/graphene_django/types.py
@@ -35,9 +35,15 @@ def construct_fields(
 
     fields = OrderedDict()
     for name, field in _model_fields:
-        is_not_in_only = only_fields and name not in only_fields
+        is_not_in_only = (
+            only_fields is not None
+            and only_fields != ALL_FIELDS
+            and name not in only_fields
+        )
         # is_already_created = name in options.fields
-        is_excluded = name in exclude_fields  # or is_already_created
+        is_excluded = (
+            exclude_fields is not None and name in exclude_fields
+        )  # or is_already_created
         # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name
         is_no_backref = str(name).endswith("+")
         if is_not_in_only or is_excluded or is_no_backref:
@@ -65,6 +71,7 @@ def construct_fields(
 def validate_fields(type_, model, fields, only_fields, exclude_fields):
     # Validate the given fields against the model's fields and custom fields
     all_field_names = set(fields.keys())
+    only_fields = only_fields if only_fields is not ALL_FIELDS else ()
     for name in only_fields or ():
         if name in all_field_names:
             continue
@@ -142,10 +149,10 @@ class DjangoObjectType(ObjectType):
         model=None,
         registry=None,
         skip_registry=False,
-        only_fields=(),  # deprecated in favour of `fields`
-        fields=(),
-        exclude_fields=(),  # deprecated in favour of `exclude`
-        exclude=(),
+        only_fields=None,  # deprecated in favour of `fields`
+        fields=None,
+        exclude_fields=None,  # deprecated in favour of `exclude`
+        exclude=None,
         filter_fields=None,
         filterset_class=None,
         connection=None,
@@ -200,9 +207,6 @@ class DjangoObjectType(ObjectType):
                 "Got %s." % type(fields).__name__
             )
 
-        if fields == ALL_FIELDS:
-            fields = None
-
         # Alias exclude_fields -> exclude
         if exclude_fields and exclude:
             raise Exception("Can't set both exclude_fields and exclude")


Test patch:
diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py
index 67b3a35..cd5bd1b 100644
--- a/graphene_django/tests/test_fields.py
+++ b/graphene_django/tests/test_fields.py
@@ -1,4 +1,5 @@
 import datetime
+from django.db.models import Count
 
 import pytest
 
@@ -141,13 +142,26 @@ class TestDjangoListField:
             pub_date_time=datetime.datetime.now(),
             editor=r1,
         )
+        ArticleModel.objects.create(
+            headline="Not so good news",
+            reporter=r1,
+            pub_date=datetime.date.today(),
+            pub_date_time=datetime.datetime.now(),
+            editor=r1,
+        )
 
         result = schema.execute(query)
 
         assert not result.errors
         assert result.data == {
             "reporters": [
-                {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]},
+                {
+                    "firstName": "Tara",
+                    "articles": [
+                        {"headline": "Amazing news"},
+                        {"headline": "Not so good news"},
+                    ],
+                },
                 {"firstName": "Debra", "articles": []},
             ]
         }
@@ -163,8 +177,8 @@ class TestDjangoListField:
                 model = ReporterModel
                 fields = ("first_name", "articles")
 
-            def resolve_reporters(reporter, info):
-                return reporter.articles.all()
+            def resolve_articles(reporter, info):
+                return reporter.articles.filter(headline__contains="Amazing")
 
         class Query(ObjectType):
             reporters = DjangoListField(Reporter)
@@ -192,6 +206,13 @@ class TestDjangoListField:
             pub_date_time=datetime.datetime.now(),
             editor=r1,
         )
+        ArticleModel.objects.create(
+            headline="Not so good news",
+            reporter=r1,
+            pub_date=datetime.date.today(),
+            pub_date_time=datetime.datetime.now(),
+            editor=r1,
+        )
 
         result = schema.execute(query)
 
@@ -202,3 +223,155 @@ class TestDjangoListField:
                 {"firstName": "Debra", "articles": []},
             ]
         }
+
+    def test_get_queryset_filter(self):
+        class Reporter(DjangoObjectType):
+            class Meta:
+                model = ReporterModel
+                fields = ("first_name", "articles")
+
+            @classmethod
+            def get_queryset(cls, queryset, info):
+                # Only get reporters with at least 1 article
+                return queryset.annotate(article_count=Count("articles")).filter(
+                    article_count__gt=0
+                )
+
+        class Query(ObjectType):
+            reporters = DjangoListField(Reporter)
+
+            def resolve_reporters(_, info):
+                return ReporterModel.objects.all()
+
+        schema = Schema(query=Query)
+
+        query = """
+            query {
+                reporters {
+                    firstName
+                }
+            }
+        """
+
+        r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
+        ReporterModel.objects.create(first_name="Debra", last_name="Payne")
+
+        ArticleModel.objects.create(
+            headline="Amazing news",
+            reporter=r1,
+            pub_date=datetime.date.today(),
+            pub_date_time=datetime.datetime.now(),
+            editor=r1,
+        )
+
+        result = schema.execute(query)
+
+        assert not result.errors
+        assert result.data == {"reporters": [{"firstName": "Tara"},]}
+
+    def test_resolve_list(self):
+        """Resolving a plain list should work (and not call get_queryset)"""
+
+        class Reporter(DjangoObjectType):
+            class Meta:
+                model = ReporterModel
+                fields = ("first_name", "articles")
+
+            @classmethod
+            def get_queryset(cls, queryset, info):
+                # Only get reporters with at least 1 article
+                return queryset.annotate(article_count=Count("articles")).filter(
+                    article_count__gt=0
+                )
+
+        class Query(ObjectType):
+            reporters = DjangoListField(Reporter)
+
+            def resolve_reporters(_, info):
+                return [ReporterModel.objects.get(first_name="Debra")]
+
+        schema = Schema(query=Query)
+
+        query = """
+            query {
+                reporters {
+                    firstName
+                }
+            }
+        """
+
+        r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
+        ReporterModel.objects.create(first_name="Debra", last_name="Payne")
+
+        ArticleModel.objects.create(
+            headline="Amazing news",
+            reporter=r1,
+            pub_date=datetime.date.today(),
+            pub_date_time=datetime.datetime.now(),
+            editor=r1,
+        )
+
+        result = schema.execute(query)
+
+        assert not result.errors
+        assert result.data == {"reporters": [{"firstName": "Debra"},]}
+
+    def test_get_queryset_foreign_key(self):
+        class Article(DjangoObjectType):
+            class Meta:
+                model = ArticleModel
+                fields = ("headline",)
+
+            @classmethod
+            def get_queryset(cls, queryset, info):
+                # Rose tinted glasses
+                return queryset.exclude(headline__contains="Not so good")
+
+        class Reporter(DjangoObjectType):
+            class Meta:
+                model = ReporterModel
+                fields = ("first_name", "articles")
+
+        class Query(ObjectType):
+            reporters = DjangoListField(Reporter)
+
+        schema = Schema(query=Query)
+
+        query = """
+            query {
+                reporters {
+                    firstName
+                    articles {
+                        headline
+                    }
+                }
+            }
+        """
+
+        r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
+        ReporterModel.objects.create(first_name="Debra", last_name="Payne")
+
+        ArticleModel.objects.create(
+            headline="Amazing news",
+            reporter=r1,
+            pub_date=datetime.date.today(),
+            pub_date_time=datetime.datetime.now(),
+            editor=r1,
+        )
+        ArticleModel.objects.create(
+            headline="Not so good news",
+            reporter=r1,
+            pub_date=datetime.date.today(),
+            pub_date_time=datetime.datetime.now(),
+            editor=r1,
+        )
+
+        result = schema.execute(query)
+
+        assert not result.errors
+        assert result.data == {
+            "reporters": [
+                {"firstName": "Tara", "articles": [{"headline": "Amazing news"},],},
+                {"firstName": "Debra", "articles": []},
+            ]
+        }
diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py
index 9b3ceb6..4d14749 100644
--- a/graphene_django/tests/test_types.py
+++ b/graphene_django/tests/test_types.py
@@ -230,6 +230,17 @@ def test_django_objecttype_fields():
     assert fields == ["id", "email", "films"]
 
 
+@with_local_registry
+def test_django_objecttype_fields_empty():
+    class Reporter(DjangoObjectType):
+        class Meta:
+            model = ReporterModel
+            fields = ()
+
+    fields = list(Reporter._meta.fields.keys())
+    assert fields == []
+
+
 @with_local_registry
 def test_django_objecttype_only_fields_and_fields():
     with pytest.raises(Exception):