Repository: googleapis/python-bigquery

Issue:
Expose the QueryJob on the DBAPI cursor
 **Is your feature request related to a problem? Please describe.**
I would like to store the `job_id` that references the job created after `cursor.execute` is called. Currently I do this:

```python
job_id = dbapi_cursor._query_job.job_id
```

 **Describe the solution you'd like**
I would prefer to do something like this:

```python
# Returns the `QueryJob` created by the last `execute*()` call
job = dbapi_cursor.job()
job_id = job.job_id
```

 **Describe alternatives you've considered**
It is possible to specify the `job_id` when calling `cursor.execute`, however:

- I don't call `cursor.execute` in my code (see additional context below)
- I would prefer to use the generated ID

 **Additional context**
I interact with this library from the [BigQuery SQLAlchemy](https://github.com/googleapis/python-bigquery-sqlalchemy) dialect, so I never call DBAPI `execute` directly:

```python
sqlalchemy_cursor = engine.execute(...)  # eventually calls `dbapi_cursor.execute`
dbapi_cursor = sqlalchemy_cursor.cursor
job_id = dbapi_cursor._query_job.job_id
```


Discussion:

Resolving patch:
diff --git a/google/cloud/bigquery/dbapi/cursor.py b/google/cloud/bigquery/dbapi/cursor.py
--- a/google/cloud/bigquery/dbapi/cursor.py
+++ b/google/cloud/bigquery/dbapi/cursor.py
@@ -79,6 +79,16 @@ def __init__(self, connection):
         self._query_job = None
         self._closed = False
 
+    @property
+    def query_job(self):
+        """google.cloud.bigquery.job.query.QueryJob: The query job created by
+        the last ``execute*()`` call.
+
+        .. note::
+            If the last ``execute*()`` call was ``executemany()``, this is the
+            last job created by ``executemany()``."""
+        return self._query_job
+
     def close(self):
         """Mark the cursor as closed, preventing its further use."""
         self._closed = True


Test patch:
diff --git a/tests/unit/test_dbapi_cursor.py b/tests/unit/test_dbapi_cursor.py
--- a/tests/unit/test_dbapi_cursor.py
+++ b/tests/unit/test_dbapi_cursor.py
@@ -662,6 +662,29 @@ def test_is_iterable(self):
             "Iterating again over the same results should produce no rows.",
         )
 
+    def test_query_job_wo_execute(self):
+        from google.cloud.bigquery import dbapi
+
+        connection = dbapi.connect(self._mock_client())
+        cursor = connection.cursor()
+        self.assertIsNone(cursor.query_job)
+
+    def test_query_job_w_execute(self):
+        from google.cloud.bigquery import dbapi, QueryJob
+
+        connection = dbapi.connect(self._mock_client())
+        cursor = connection.cursor()
+        cursor.execute("SELECT 1;")
+        self.assertIsInstance(cursor.query_job, QueryJob)
+
+    def test_query_job_w_executemany(self):
+        from google.cloud.bigquery import dbapi, QueryJob
+
+        connection = dbapi.connect(self._mock_client())
+        cursor = connection.cursor()
+        cursor.executemany("SELECT %s;", (("1",), ("2",)))
+        self.assertIsInstance(cursor.query_job, QueryJob)
+
     def test__format_operation_w_dict(self):
         from google.cloud.bigquery.dbapi import cursor