Skip to content

Commit 45b12bf

Browse files
committed
Fix issue with json_cursor if django cursor is wrapped.
1 parent 07caae3 commit 45b12bf

5 files changed

Lines changed: 49 additions & 13 deletions

File tree

README.rst

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,8 @@ where django querysets simplify the sql generation process for simple queries.
3434

3535
Requirements
3636
------------
37-
* Python 2.7
38-
* Python 3.3, 3.4
39-
* Django 1.7+
37+
* Python 3.7 - 3.9
38+
* Django 2.2 - 4.1
4039
* Postgres 9.3+
4140

4241
Installation

docs/release_notes.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Release Notes
22
=============
33

4+
v3.0.3
5+
------
6+
* Addressed bug in `json_cursor` if Django cursor has extra wrappers
7+
48
v3.0.2
59
------
610
* Add `json_cursor` context to handle Django3.1.1+ no longer automatically parsing json fields

querybuilder/cursor.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,45 @@
33
import psycopg2
44

55

6+
def jsonify_cursor(django_cursor):
7+
"""
8+
Adjust an already existing cursor to ensure it will return structured types (list or dict)
9+
from jsonb columns instead of strings. Django 3.1.1+ returns strings for raw queries.
10+
https://code.djangoproject.com/ticket/31956
11+
https://code.djangoproject.com/ticket/31973
12+
https://www.psycopg.org/docs/extras.html#psycopg2.extras.register_default_jsonb
13+
"""
14+
15+
# The thing that is returned by connection.cursor() is (normally) a Django object
16+
# of type CursorWrapper that itself has the "real" cursor as a property called cursor.
17+
# However, it could be a CursorDebugWrapper instead, or it could be an outer wrapper
18+
# wrapping one of those. For example django-debug-toolbar wraps CursorDebugWrapper in
19+
# a NormalCursorWrapper. The django-db-readonly package wraps the Django CursorWrapper
20+
# in a ReadOnlyCursorWrapper. I'm not sure if they ever nest multiple levels. I tried
21+
# looping with `while isinstance(inner_cursor, CursorWrapper)`, but it seems that the
22+
# outer wrapper is not necessarily a subclass of the Django wrapper. My next best option
23+
# is to make the assumption that we need to get to the last property called `cursor`,
24+
# basically assuming that any wrapper is going to have a property called `cursor`
25+
# that is the real cursor or the next-level wrapper.
26+
# Another option might be to check the class of inner_cursor to see if it is the real
27+
# database cursor. That would require importing more django libraries, and probably
28+
# having to handle some changes in those libraries over different versions.
29+
30+
# We expect that there is always at least one wrapper, but we might as well handle
31+
# the possibility that we get passed the inner cursor.
32+
inner_cursor = django_cursor
33+
34+
while hasattr(inner_cursor, 'cursor'):
35+
inner_cursor = inner_cursor.cursor
36+
37+
# Hopefully we have the right thing now, but try/catch so we can get a little better info
38+
# if it is not. Another option might be an isinstance, or another function that tests the cursor?
39+
try:
40+
psycopg2.extras.register_default_jsonb(conn_or_curs=inner_cursor, loads=json.loads)
41+
except TypeError as e:
42+
raise Exception(f'jsonify_cursor: conn_or_curs was actually a {type(inner_cursor)}: {e}')
43+
44+
645
@contextlib.contextmanager
746
def json_cursor(django_database_connection):
847
"""
@@ -12,5 +51,5 @@ def json_cursor(django_database_connection):
1251
https://www.psycopg.org/docs/extras.html#psycopg2.extras.register_default_jsonb
1352
"""
1453
with django_database_connection.cursor() as cursor:
15-
psycopg2.extras.register_default_jsonb(conn_or_curs=cursor.cursor, loads=json.loads)
54+
jsonify_cursor(cursor)
1655
yield cursor

querybuilder/query.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,11 @@
88
from django.apps import apps
99
get_model = apps.get_model
1010
import six
11-
import json
12-
import psycopg2
1311

1412
from querybuilder.fields import FieldFactory, CountField, MaxField, MinField, SumField, AvgField
1513
from querybuilder.helpers import set_value_for_keypath, copy_instance
1614
from querybuilder.tables import TableFactory, ModelTable, QueryTable
15+
from querybuilder.cursor import jsonify_cursor
1716

1817
SERIAL_DTYPES = ['serial', 'bigserial']
1918

@@ -641,13 +640,8 @@ def get_cursor(self):
641640
:rtype: :class:`CursorDebugWrapper <django:django.db.backends.util.CursorDebugWrapper>`
642641
:returns: A database cursor
643642
"""
644-
645-
# From Django 3.1.1 forward, json columns in raw select statements return a string of json instead of a
646-
# json type such as a dict or list. But we can tell psycopg2 to put the
647-
# json.loads() call back in place. Technically we would only need this addition for cursors being used
648-
# for a SELECT, but it should not cause any issues for other operations.
649643
cursor = self.connection.cursor()
650-
psycopg2.extras.register_default_jsonb(conn_or_curs=cursor.cursor, loads=json.loads)
644+
jsonify_cursor(cursor)
651645
return cursor
652646

653647
def from_table(self, table=None, fields='*', schema=None, **kwargs):

querybuilder/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '3.0.2'
1+
__version__ = '3.0.3'

0 commit comments

Comments
 (0)