Repository navigation
fix(django-google-spanner): support JSONField read and write operations - #19007
sakthivelmanii wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for Django's JSONField in the Google Cloud Spanner backend by adding utility functions to adapt and convert JSON values, along with corresponding unit and mock server tests. Feedback is provided to optimize the recursive _unwrap_json_object serialization helper by checking for standard Python types first, which avoids expensive isinstance checks on standard elements in performance-critical paths.
| Note that JSON null (`Value(None, JSONField())`) maps to a null | ||
| JsonObject whose `serialize()` returns `None` (bound as SQL NULL by | ||
| `spanner_v1`). |
There was a problem hiding this comment.
I think that this is Claude-speak for "Value(None, JSONField()) is equal to SQL NULL", but I am not sure. And the main question is:
- How do you write (and read) a JSON NULL in this Django provider? (
insert into my_table (col) values (JSON 'NULL')) - How do you write and read a JSON column with a SQL NULL value?
My impression is that the first point is not possible, and that this provider now only supports SQL NULL for JSON columns. Is that intentional?
There was a problem hiding this comment.
Updated adapt_json_value to wrap the serialized JSON string in _JsonString(JsonObject) so it is sent directly without re-parsing, and Value(None, JSONField()) now serializes as JSON null rather than SQL NULL.
| from google.cloud.spanner_v1 import JsonObject | ||
|
|
||
|
|
||
| def _unwrap_json_object(value): |
There was a problem hiding this comment.
I think that this entire method can be removed if this monkey patching in django-spanner/__inity__.py is removed:
def get_prep_value(self, value):
if not isinstance(value, JsonObject) and isinstance(value, dict):
return JsonObject(value)
return value
JSONField.get_prep_value = get_prep_valueThere was a problem hiding this comment.
Good catch — removed the JSONField.get_prep_value monkey-patch from init.py along with _unwrap_json_object in operations.py.
| :returns: A serialized JSON string for Django's JSONField.from_db_value, | ||
| or None. | ||
| """ | ||
| if isinstance(value, JsonObject): |
There was a problem hiding this comment.
The current implementation does a double serialize/parse on both the read and the write path. That is: Whenever a JSON value is read, it is parsed into a JsonObject by the client, and then serialized again into a string by Django, and then parsed back into a JsonObject. Whenever a JSON value is written, it is dumped into a string by Django, then parsed into a JsonObject by the client library, and then dumped back into a string by the client library. I think that we can avoid that double-parsing-serialization if we add an option to the client library to return JSON values as a string.
There was a problem hiding this comment.
Updated convert_jsonfield_value to unwrap the parsed JsonObject directly in memory (while still supporting custom decoders and subclasses), and updated SQLCompiler.get_converters to skip the redundant default JSONField.from_db_value pass.
| for db, config in DATABASES.items(): | ||
| if config["ENGINE"] == "django_spanner": | ||
| config.pop("DISABLE_RANDOM_ID_GENERATION", None) | ||
|
|
There was a problem hiding this comment.
This implementation does not support looking up or filtering on JSON keys, e.g. Detail.objects.filter(value__name='Jakob'). Is that intentional?
There was a problem hiding this comment.
Added Spanner lookup implementations for KeyTransform, KeyTextTransform, and HasKeyLookup using JSON_QUERY and JSON_VALUE, and added unit, mockserver, and emulator tests covering key filtering.
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
| import google.cloud.spanner_v1.types.result_set as result_set |
There was a problem hiding this comment.
There are a number of test gaps:
- No custom encoder integration test: Does not test saving a model using JSONField(encoder=DjangoJSONEncoder) with UUID, Decimal, or datetime end-to-end through the ORM.
- No test for updates or bulk inserts: No coverage for QuerySet.update() or bulk_create().
- No test for JSON filtering: No coverage for Model.objects.filter(value={"name": "Jakob"}).
- Emulator tests are still skipped: In tests/system/django_spanner/test_json_field.py, all JSON tests are skipped for the emulator
There was a problem hiding this comment.
There are also no tests for reading and writing JSON primitives, and the flag for JSON primitives is still disabled in django_spanner/features.py (the supports_primitives_in_json_field config field is false)
There was a problem hiding this comment.
Added mockserver and unit tests for SQL NULL, JSON null, primitives, custom encoders/decoders, bulk_create, update, and key lookups, and enabled TestJsonField on the emulator.
There was a problem hiding this comment.
Enabled supports_primitives_in_json_field, has_native_json_field, and supports_json_field in features.py, and added tests for primitive JSON values.
Problem
INSERT/UPDATE),DatabaseOperationsdid not overrideadapt_json_value, soBaseDatabaseOperations.adapt_json_valuereturned astrthatspanner_dbapibound asTypeCode.STRINGinstead ofTypeCode.JSON. Additionally, passing a pre-wrapped array, scalar, or nullJsonObjectintojson.dumpsserialized an empty object ({}) becauseJsonObjectsubclassesdict.SELECT),google-cloud-spannerdecodesTypeCode.JSONcolumns intoJsonObjectinstances, and becauseDatabaseOperations.get_db_convertersdid not register a converter forJSONField,JSONField.from_db_valuepassed theJsonObjectdirectly tojson.loads, raisingTypeError: the JSON object must be str, bytes or bytearray, not JsonObject.Solution
_unwrap_json_objectandDatabaseOperations.adapt_json_valueindjango_spanner/operations.pysoJSONFieldparameters are serialized with any custom fieldencoderand wrapped inJsonObject(binding asTypeCode.JSONinstead ofTypeCode.STRING). Note thatValue(None, JSONField())(JSONnull) is stored as SQLNULLbecauseJsonObject.serialize()returnsNonefor null JSON values.DatabaseOperations.convert_jsonfield_valueinget_db_convertersforJSONFieldto serializeJsonObjectvalues back into JSON strings (orNone) beforeJSONField.from_db_valuerunsjson.loads.tests/unit/django_spanner/test_operations.pyand end-to-end mockserver tests intests/mockserver_tests/test_basics.py.