1
1
import contextlib
2
2
import json
3
- import psycopg2
3
+ from psycopg2 .extras import register_default_jsonb
4
+ from psycopg2 ._json import JSONB_OID
4
5
5
6
6
- def jsonify_cursor (django_cursor ):
7
+ def jsonify_cursor (django_cursor , enabled = True ):
7
8
"""
8
9
Adjust an already existing cursor to ensure it will return structured types (list or dict)
9
10
from jsonb columns instead of strings. Django 3.1.1+ returns strings for raw queries.
@@ -27,6 +28,13 @@ def jsonify_cursor(django_cursor):
27
28
# database cursor. That would require importing more django libraries, and probably
28
29
# having to handle some changes in those libraries over different versions.
29
30
31
+ # This register_default_jsonb functionality in psycopg2 does not itself have a "deregister"
32
+ # capability. So to deregister, we pass in a different value for the loads method; in this
33
+ # case just the str() built-in, which just returns the value passed in. Note that passing
34
+ # None for loads does NOT do a deregister; it uses the default value, which as it turns out
35
+ # is json.loads anyway!
36
+ loads_func = json .loads if enabled else str
37
+
30
38
# We expect that there is always at least one wrapper, but we might as well handle
31
39
# the possibility that we get passed the inner cursor.
32
40
inner_cursor = django_cursor
@@ -37,11 +45,18 @@ def jsonify_cursor(django_cursor):
37
45
# Hopefully we have the right thing now, but try/catch so we can get a little better info
38
46
# if it is not. Another option might be an isinstance, or another function that tests the cursor?
39
47
try :
40
- psycopg2 . extras . register_default_jsonb (conn_or_curs = inner_cursor , loads = json . loads )
48
+ register_default_jsonb (conn_or_curs = inner_cursor , loads = loads_func )
41
49
except TypeError as e :
42
50
raise Exception (f'jsonify_cursor: conn_or_curs was actually a { type (inner_cursor )} : { e } ' )
43
51
44
52
53
+ def dejsonify_cursor (django_cursor ):
54
+ """
55
+ Re-adjust a cursor that was "jsonified" so it no longer performs the json.loads().
56
+ """
57
+ jsonify_cursor (django_cursor , enabled = False )
58
+
59
+
45
60
@contextlib .contextmanager
46
61
def json_cursor (django_database_connection ):
47
62
"""
@@ -53,3 +68,60 @@ def json_cursor(django_database_connection):
53
68
with django_database_connection .cursor () as cursor :
54
69
jsonify_cursor (cursor )
55
70
yield cursor
71
+ # This should really not be necessary, because the cursor context manager will
72
+ # be closing the cursor on __exit__ anyway. But just in case.
73
+ dejsonify_cursor (cursor )
74
+
75
+
76
+ def json_fetch_all_as_dict (cursor ):
77
+ """
78
+ Iterates over a result set and converts each row to a dictionary.
79
+ The cursor passed in is assumed to have just executed a raw Postgresql query.
80
+ If the cursor's columns include any with the jsonb type, the process includes
81
+ examining every value from those columns. If the value is a string, a json.loads()
82
+ is attempted on the value, because in Django 3.1.1 and later, this is not
83
+ handled automatically for raw sql as it was before. There is no compatibility
84
+ issue running with older Django versions because if the value is not a string,
85
+ (e.g. it has already been converted to a list or dict), the loads() is skipped.
86
+ Note that JSON decoding errors are ignored (and the original result value is provided)
87
+ because it is possible that the query involved an actual json query, say on a single
88
+ string property of the underlying column data. In that case, the column type is
89
+ still jsonb, but the result value is a string as it should be. This ignoring of
90
+ errors is the same logic used in json handling in Django's from_db_value() method.
91
+
92
+ :return: A list of dictionaries where each row is a dictionary
93
+ :rtype: list of dict
94
+ """
95
+
96
+ colnames = [col .name for col in cursor .description ]
97
+ coltypes = [col .type_code for col in cursor .description ]
98
+ # Identify any jsonb columns in the query, by column index
99
+ jsonbcols = [i for i , x in enumerate (coltypes ) if x == JSONB_OID ]
100
+
101
+ # Optimize with a simple comprehension if we know there are no jsonb columns to handle.
102
+ if not jsonbcols :
103
+ return [
104
+ dict (zip (colnames , row ))
105
+ for row in cursor .fetchall ()
106
+ ]
107
+
108
+ # If there are jsonb columns, intercept the result rows and run a json.loads() on any jsonb
109
+ # columns that are presenting as strings.
110
+ # In Django 3.1.0 they would already be a json type (e.g. dict or list) but in Django 3.1.1 it changes
111
+ # and raw sql queries return strings for jsonb columns.
112
+ # https://docs.djangoproject.com/en/4.0/releases/3.1.1/
113
+ results = []
114
+
115
+ for row in cursor .fetchall ():
116
+ rowvals = list (row )
117
+ for colindex in jsonbcols :
118
+ if type (rowvals [colindex ]) is str : # need to check type to avoid attempting to jsonify a None
119
+ try :
120
+ rowvals [colindex ] = json .loads (rowvals [colindex ])
121
+ # It is possible that we are selecting a sub-value from the json in the column. I.e.
122
+ # we got here because it IS a jsonb column, but what we selected is not json and will
123
+ # fail to parse. In that case, we already have the value we want in place.
124
+ except json .JSONDecodeError :
125
+ pass
126
+ results .append (dict (zip (colnames , rowvals )))
127
+ return results
0 commit comments