Repository: nautobot/nautobot

Issue:
GraphQL single-object endpoints broken
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: 3.7
* Nautobot version: 1.2.0-beta1

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Build Nautobot instance of 1.2.0-beta1.
2. Add some sample devices and associated data.
3. Attempt to use the GraphiQL endpoint with a simple query such as:

```
query ($device_id: ID!) {
  device(id: $device_id) {
    hostname: name
  }
}
```

along with one of your device's UUID as the query.

<!-- What did you expect to happen? -->
### Expected Behavior
Expect to see the device's hostname returned.

<!-- What happened instead? -->
### Observed Behavior
Get error with following:

```
{
  "errors": [
    {
      "message": "'Device' object has no attribute 'only'",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "device"
      ]
    }
  ],
  "data": {
    "device": null
  }
}
```

A larger query returns this:

```
{
  "errors": [
    {
      "message": "'Device' object has no attribute 'select_related'",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "device"
      ]
    }
  ],
  "data": {
    "device": null
  }
}
```

This appears to be related to optimizations that were done in the core.


Discussion:
Probably a regression introduced by #769.
Seems to be specifically an issue with "single" queries (e.g. `device` as opposed to `devices`.) The corresponding `devices` query seems to work fine:

```graphql
query ($device_id: String!) {
  devices (id: [$device_id]) {
    hostname: name
  }
}
```

Resolving patch:
diff --git a/nautobot/core/graphql/generators.py b/nautobot/core/graphql/generators.py
--- a/nautobot/core/graphql/generators.py
+++ b/nautobot/core/graphql/generators.py
@@ -235,7 +235,9 @@ def single_resolver(self, info, **kwargs):
 
         obj_id = kwargs.get("id", None)
         if obj_id:
-            return gql_optimizer.query(model.objects.restrict(info.context.user, "view").get(pk=obj_id), info)
+            return gql_optimizer.query(
+                model.objects.restrict(info.context.user, "view").filter(pk=obj_id), info
+            ).first()
         return None
 
     single_resolver.__name__ = resolver_name


Test patch:
diff --git a/nautobot/core/tests/test_graphql.py b/nautobot/core/tests/test_graphql.py
--- a/nautobot/core/tests/test_graphql.py
+++ b/nautobot/core/tests/test_graphql.py
@@ -488,6 +488,14 @@ def setUp(self):
         }
         """
 
+        self.get_rack_query = """
+        query ($id: ID!) {
+            rack (id: $id) {
+                name
+            }
+        }
+        """
+
     def test_graphql_api_token_with_perm(self):
         """Validate that users can query based on their permissions."""
         # First user
@@ -579,6 +587,14 @@ def test_graphql_query_variables(self):
         names = [item["name"] for item in response.data["data"]["racks"]]
         self.assertEqual(names, ["Rack 2-1", "Rack 2-2"])
 
+    def test_graphql_single_object_query(self):
+        """Validate graphql query for a single object as opposed to a set of objects also works."""
+        payload = {"query": self.get_rack_query, "variables": {"id": Rack.objects.first().pk}}
+        response = self.clients[2].post(self.api_url, payload, format="json")
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertIsInstance(response.data["data"]["rack"], dict)
+        self.assertEqual(response.data["data"]["rack"]["name"], Rack.objects.first().name)
+
     def test_graphql_query_multi_level(self):
         """Validate request with multiple levels return the proper information, following the permissions."""
         response = self.clients[0].post(self.api_url, {"query": self.get_sites_racks_query}, format="json")
@@ -805,24 +821,36 @@ def test_query_config_context_and_custom_field_data(self):
                 config_context
                 _custom_field_data
             }
+            device (id: "%s") {
+                name
+                config_context
+                _custom_field_data
+            }
         }
-        """
+        """ % (
+            self.device1.id,
+        )
 
         expected_data = {"a": 123, "b": 456, "c": 777}
 
         result = self.execute_query(query)
 
         self.assertIsInstance(result.data["devices"], list)
+        self.assertIsInstance(result.data["device"], dict)
+
         device_names = [item["name"] for item in result.data["devices"]]
         self.assertEqual(sorted(device_names), ["Device 1", "Device 2", "Device 3"])
+        self.assertEqual(result.data["device"]["name"], "Device 1")
 
         config_contexts = [item["config_context"] for item in result.data["devices"]]
         self.assertIsInstance(config_contexts[0], dict)
         self.assertDictEqual(config_contexts[0], expected_data)
+        self.assertEqual(result.data["device"]["config_context"], expected_data)
 
         custom_field_data = [item["_custom_field_data"] for item in result.data["devices"]]
         self.assertIsInstance(custom_field_data[0], dict)
         self.assertEqual(custom_field_data[0], {})
+        self.assertEqual(result.data["device"]["_custom_field_data"], {})
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_query_device_role_filter(self):