├── .coveragerc ├── .gitignore ├── .gitlab-ci.yml ├── .travis-install-db.sh ├── .travis.yml ├── AUTHORS ├── LICENSE ├── README.rst ├── jsonfallback ├── __init__.py ├── fields.py └── functions.py ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── test_manage.py ├── tests ├── __init__.py ├── conftest.py ├── settings.py ├── test_query.py ├── test_serialization.py └── testapp │ ├── __init__.py │ ├── apps.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── urls.py │ └── wsgi.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = jsonfallback 3 | 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | def __str__ 8 | der __repr__ 9 | if settings.DEBUG 10 | NOQA 11 | NotImplementedError 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | dist/ 4 | *.egg-info 5 | env 6 | .idea/ 7 | *.sqlite3 8 | .cache 9 | .tox 10 | htmlcov/ 11 | *.pyc 12 | .coverage 13 | 14 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | pypi: 2 | script: 3 | - cp /keys/.pypirc ~/.pypirc 4 | - virtualenv env 5 | - source env/bin/activate 6 | - XDG_CACHE_HOME=/cache pip3 install -U pip setuptools wheel 7 | - XDG_CACHE_HOME=/cache pip3 install -r requirements_dev.txt 8 | - python setup.py sdist develop 9 | - python setup.py sdist clean 10 | - python setup.py sdist upload 11 | - python setup.py bdist_wheel upload 12 | tags: 13 | - python3 14 | only: 15 | - pypi 16 | artifacts: 17 | paths: 18 | - dist/ 19 | -------------------------------------------------------------------------------- /.travis-install-db.sh: -------------------------------------------------------------------------------- 1 | # debug 2 | set -x 3 | # verbose 4 | set -v 5 | 6 | if [ ! -z "${DB}" ]; then 7 | # disable existing database server in case of accidential connection 8 | sudo service mysql stop 9 | 10 | docker pull ${DB} 11 | docker run -it --name=mysqld -d -e MYSQL_ALLOW_EMPTY_PASSWORD=yes -p 3306:3306 ${DB} 12 | sleep 10 13 | 14 | mysql() { 15 | docker exec mysqld mysql "${@}" 16 | } 17 | while : 18 | do 19 | sleep 5 20 | mysql -e 'select version()' 21 | if [ $? = 0 ]; then 22 | break 23 | fi 24 | echo "server logs" 25 | docker logs --tail 5 mysqld 26 | done 27 | 28 | mysql -e 'select VERSION()' 29 | 30 | if [ $DB == 'mysql:8.0' ]; then 31 | WITH_PLUGIN='with mysql_native_password' 32 | mysql -e 'SET GLOBAL local_infile=on' 33 | docker cp mysqld:/var/lib/mysql/public_key.pem "${HOME}" 34 | docker cp mysqld:/var/lib/mysql/ca.pem "${HOME}" 35 | docker cp mysqld:/var/lib/mysql/server-cert.pem "${HOME}" 36 | docker cp mysqld:/var/lib/mysql/client-key.pem "${HOME}" 37 | docker cp mysqld:/var/lib/mysql/client-cert.pem "${HOME}" 38 | else 39 | WITH_PLUGIN='' 40 | fi 41 | 42 | mysql -uroot -e 'create database testdb DEFAULT CHARACTER SET utf8mb4' 43 | fi 44 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install tox 4 | dist: xenial 5 | sudo: required 6 | before_script: 7 | - ./.travis-install-db.sh 8 | script: 9 | - tox 10 | services: 11 | - docker 12 | - postgresql 13 | matrix: 14 | include: 15 | - python: 3.6 16 | env: TOXENV=py36-django21 TOXDB=mysql DB=mysql:5.7 17 | - python: 3.7 18 | env: TOXENV=py37-django22 TOXDB=mysql DB=mysql:5.7 19 | - python: 3.4 20 | env: TOXENV=py34-django20 TOXDB=mariadb DB=mariadb:10.3 21 | - python: 3.7 22 | env: TOXENV=py37-django22 TOXDB=mariadb DB=mariadb:10.3 23 | - python: 3.4 24 | env: TOXENV=py34-django20 TOXDB=postgres 25 | - python: 3.5 26 | env: TOXENV=py35-django20 TOXDB=postgres 27 | - python: 3.5 28 | env: TOXENV=py35-django21 TOXDB=postgres 29 | - python: 3.6 30 | env: TOXENV=py36-django20 TOXDB=postgres 31 | - python: 3.6 32 | env: TOXENV=py36-django21 TOXDB=postgres 33 | - python: 3.7 34 | env: TOXENV=py37-django21 TOXDB=postgres 35 | - python: 3.7 36 | env: TOXENV=py37-django22 TOXDB=postgres 37 | - python: 3.4 38 | env: TOXENV=py34-django20 TOXDB=sqlite 39 | - python: 3.6 40 | env: TOXENV=py36-django21 TOXDB=sqlite 41 | - python: 3.7 42 | env: TOXENV=py37-django22 TOXDB=sqlite 43 | - python: 3.6 44 | env: TOXENV=coverage TOXDB=sqlite 45 | - python: 3.6 46 | env: TOXENV=style TOXDB=sqlite 47 | addons: 48 | postgresql: "9.4" 49 | 50 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Here is an inevitably incomplete list of much-appreciated contributors -- 2 | people who have submitted patches, reported bugs, added translations, helped 3 | answer newbie questions, improved the documentation, and generally made this 4 | an awesome project. Thank you all! 5 | 6 | Raphael Michel 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | JSONField with fallback for Django 2 | ================================== 3 | 4 | UNMAINTAINED 5 | ------------ 6 | 7 | **This library is no longer maintained since Django now includes this functionality natively.** You do not need any upgrade steps, you can just replace all your FallbackJSONField usages with JSONField *including in your old migrations*. 8 | 9 | 10 | .. image:: https://img.shields.io/pypi/v/django-jsonfallback.svg 11 | :target: https://pypi.python.org/pypi/django-jsonfallback 12 | 13 | .. image:: https://travis-ci.com/raphaelm/django-jsonfallback.svg?branch=master 14 | :target: https://travis-ci.com/raphaelm/django-jsonfallback 15 | 16 | .. image:: https://codecov.io/gh/raphaelm/django-jsonfallback/branch/master/graph/badge.svg 17 | :target: https://codecov.io/gh/raphaelm/django-jsonfallback 18 | 19 | This is an extension to ``django.contrib.postgres.fields.JSONField``, that works on other 20 | databases than PostgreSQL. 21 | 22 | * On **MySQL** and **MariaDB**, it uses the native JSON data type and supports most features. 23 | * On **SQLite** and all other databases, it just stores JSON strings in a text field and does not support querying. 24 | 25 | This is tested against: 26 | 27 | * Python 3.4 to 3.7 28 | * Django 2.0 to 2.2 29 | * MySQL 5.7 (only on Django 2.1+) 30 | * MariaDB 10.3 31 | * PostgreSQL 9.4 32 | * SQLite (no querying funcationality) 33 | 34 | Usage 35 | ----- 36 | 37 | Just use our drop-in replacement for JSONField:: 38 | 39 | from django.db import models 40 | from jsonfallback.fields import FallbackJSONField 41 | 42 | 43 | class Book(models.Model): 44 | data = FallbackJSONField() 45 | 46 | def __str__(self): 47 | return str(self.data['title']) 48 | 49 | 50 | License 51 | ------- 52 | The code in this repository is published under the terms of the Apache License. 53 | See the LICENSE file for the complete license text. 54 | 55 | This project is maintained by Raphael Michel . See the 56 | AUTHORS file for a list of all the awesome folks who contributed to this project. 57 | 58 | .. _pretix: https://github.com/pretix/pretix 59 | .. _django: https://www.djangoproject.com/ 60 | .. _django-hvad: https://github.com/KristianOellegaard/django-hvad 61 | .. _django-modeltranslation: https://github.com/deschler/django-modeltranslation 62 | .. _django-parler: https://github.com/django-parler/django-parler 63 | .. _nece: https://pypi.python.org/pypi/nece 64 | .. _1NF: https://en.wikipedia.org/wiki/First_normal_form 65 | -------------------------------------------------------------------------------- /jsonfallback/__init__.py: -------------------------------------------------------------------------------- 1 | version = '2.1.2' 2 | -------------------------------------------------------------------------------- /jsonfallback/fields.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import json 3 | 4 | import django 5 | from django.contrib.postgres import lookups 6 | from django.contrib.postgres.fields import JSONField, jsonb 7 | from django.core import checks 8 | from django.db import NotSupportedError 9 | from django.db.models import Func, TextField, Value, lookups as builtin_lookups, Expression 10 | from django_mysql.checks import mysql_connections 11 | from django_mysql.utils import connection_is_mariadb 12 | 13 | 14 | class JsonAdapter(jsonb.JsonAdapter): 15 | """ 16 | Customized psycopg2.extras.Json to allow for a custom encoder. 17 | """ 18 | 19 | def __init__(self, adapted, dumps=None, encoder=None): 20 | super().__init__(adapted, dumps=dumps, encoder=encoder) 21 | 22 | def dumps(self, obj): 23 | options = {'cls': self.encoder} if self.encoder else {} 24 | options['sort_keys'] = True 25 | return json.dumps(obj, **options) 26 | 27 | 28 | class FallbackJSONField(jsonb.JSONField): 29 | 30 | def __init__(self, **kwargs): 31 | super().__init__(**kwargs) 32 | self.decoder = json.JSONDecoder() 33 | 34 | def db_type(self, connection): 35 | if '.postgresql' in connection.settings_dict['ENGINE']: 36 | return super().db_type(connection) 37 | elif '.mysql' in connection.settings_dict['ENGINE']: 38 | return 'json' 39 | else: 40 | data = self.db_type_parameters(connection) 41 | try: 42 | return connection.data_types["TextField"] % data 43 | except KeyError: 44 | return None 45 | 46 | def get_prep_value(self, value): 47 | if value is not None: 48 | return JsonAdapter(value, encoder=self.encoder) 49 | return value 50 | 51 | def get_db_prep_value(self, value, connection, prepared=False): 52 | value = super().get_db_prep_value(value, connection, prepared) 53 | if '.postgresql' in connection.settings_dict['ENGINE']: 54 | return value 55 | elif value is None: 56 | return None 57 | else: 58 | return value.dumps(value.adapted) 59 | 60 | def from_db_value(self, value, expression, connection): 61 | if '.postgresql' in connection.settings_dict['ENGINE']: 62 | return value 63 | elif '.postgresql' in connection.settings_dict['ENGINE']: 64 | if isinstance(value, str): 65 | return self.decoder.decode(value) 66 | else: 67 | return value 68 | elif value is None: 69 | return None 70 | else: 71 | return json.loads(value) 72 | 73 | def get_transform(self, name): 74 | transform = super(jsonb.JSONField, self).get_transform(name) 75 | if transform: 76 | return transform 77 | return FallbackKeyTransformFactory(name) 78 | 79 | def check(self, **kwargs): 80 | errors = super(JSONField, self).check(**kwargs) 81 | errors.extend(self._check_mysql_version()) 82 | return errors 83 | 84 | def _check_mysql_version(self): 85 | errors = [] 86 | any_conn_works = False 87 | conns = list(mysql_connections()) 88 | for alias, conn in conns: 89 | if ((hasattr(conn, 'mysql_version') and conn.mysql_version >= (5, 7)) 90 | or (connection_is_mariadb(conn) and hasattr(conn, 'mysql_version') and 91 | conn.mysql_version >= (10, 2, 7))): 92 | any_conn_works = True 93 | 94 | if conns and self.null: 95 | errors.append( 96 | checks.Error( 97 | 'You should not use nullable JSONFields if you have MySQL connections.', 98 | obj=self, 99 | id='jsonfallback.E001', 100 | ), 101 | ) 102 | 103 | if conns and not any_conn_works: 104 | errors.append( 105 | checks.Error( 106 | 'MySQL 5.7+ is required to use JSONField', 107 | hint='At least one of your DB connections should be to ' 108 | 'MySQL 5.7+ or MariaDB 10.2.7+', 109 | obj=self, 110 | id='django_mysql.E016', 111 | ), 112 | ) 113 | return errors 114 | 115 | def get_lookup(self, lookup_name): 116 | # Have to 'unregister' some incompatible lookups 117 | if lookup_name in { 118 | 'range', 'iexact', 'icontains', 'startswith', 119 | 'istartswith', 'endswith', 'iendswith', 'search', 'regex', 120 | 'iregex', 'length' 121 | }: 122 | raise NotImplementedError( 123 | "Lookup '{}' doesn't work with JSONField".format(lookup_name), 124 | ) 125 | return super().get_lookup(lookup_name) 126 | 127 | 128 | class FallbackLookup: 129 | def as_sql(self, qn, connection): 130 | if '.postgresql' in connection.settings_dict['ENGINE']: 131 | return super().as_sql(qn, connection) 132 | raise NotSupportedError( 133 | 'Lookups on JSONFields are only supported on PostgreSQL and MySQL at the moment.' 134 | ) 135 | 136 | 137 | @FallbackJSONField.register_lookup 138 | class DataContains(FallbackLookup, lookups.DataContains): 139 | 140 | def as_sql(self, qn, connection): 141 | if '.postgresql' in connection.settings_dict['ENGINE']: 142 | return super().as_sql(qn, connection) 143 | if '.mysql' in connection.settings_dict['ENGINE']: 144 | lhs, lhs_params = self.process_lhs(qn, connection) 145 | rhs, rhs_params = self.process_rhs(qn, connection) 146 | for i, p in enumerate(rhs_params): 147 | rhs_params[i] = p.dumps(p.adapted) # Convert JSONAdapter to str 148 | params = lhs_params + rhs_params 149 | return 'JSON_CONTAINS({}, {})'.format(lhs, rhs), params 150 | raise NotSupportedError('Lookup not supported for %s' % connection.settings_dict['ENGINE']) 151 | 152 | 153 | @FallbackJSONField.register_lookup 154 | class ContainedBy(FallbackLookup, lookups.ContainedBy): 155 | 156 | def as_sql(self, qn, connection): 157 | if '.postgresql' in connection.settings_dict['ENGINE']: 158 | return super().as_sql(qn, connection) 159 | if '.mysql' in connection.settings_dict['ENGINE']: 160 | lhs, lhs_params = self.process_lhs(qn, connection) 161 | rhs, rhs_params = self.process_rhs(qn, connection) 162 | for i, p in enumerate(rhs_params): 163 | rhs_params[i] = p.dumps(p.adapted) # Convert JSONAdapter to str 164 | params = rhs_params + lhs_params 165 | return 'JSON_CONTAINS({}, {})'.format(rhs, lhs), params 166 | raise NotSupportedError('Lookup not supported for %s' % connection.settings_dict['ENGINE']) 167 | 168 | 169 | @FallbackJSONField.register_lookup 170 | class HasKey(FallbackLookup, lookups.HasKey): 171 | 172 | def get_prep_lookup(self): 173 | if not isinstance(self.rhs, str): 174 | raise ValueError( 175 | "JSONField's 'has_key' lookup only works with {} values".format(str), 176 | ) 177 | return super().get_prep_lookup() 178 | 179 | def as_sql(self, qn, connection): 180 | if '.postgresql' in connection.settings_dict['ENGINE']: 181 | return super().as_sql(qn, connection) 182 | if '.mysql' in connection.settings_dict['ENGINE']: 183 | lhs, lhs_params = self.process_lhs(qn, connection) 184 | key_name = self.rhs 185 | path = '$.{}'.format(json.dumps(key_name)) 186 | params = lhs_params + [path] 187 | return "JSON_CONTAINS_PATH({}, 'one', %s)".format(lhs), params 188 | raise NotSupportedError('Lookup not supported for %s' % connection.settings_dict['ENGINE']) 189 | 190 | 191 | class JSONSequencesMixin(object): 192 | def get_prep_lookup(self): 193 | if not isinstance(self.rhs, collections.Sequence): 194 | raise ValueError( 195 | "JSONField's '{}' lookup only works with Sequences".format(self.lookup_name), 196 | ) 197 | return self.rhs 198 | 199 | 200 | @FallbackJSONField.register_lookup 201 | class HasKeys(FallbackLookup, lookups.HasKeys): 202 | 203 | def as_sql(self, qn, connection): 204 | if '.postgresql' in connection.settings_dict['ENGINE']: 205 | return super().as_sql(qn, connection) 206 | if '.mysql' in connection.settings_dict['ENGINE']: 207 | lhs, lhs_params = self.process_lhs(qn, connection) 208 | paths = [ 209 | '$.{}'.format(json.dumps(key_name)) 210 | for key_name in self.rhs 211 | ] 212 | params = lhs_params + paths 213 | 214 | sql = ['JSON_CONTAINS_PATH(', lhs, ", 'all', "] 215 | sql.append(', '.join('%s' for _ in paths)) 216 | sql.append(')') 217 | return ''.join(sql), params 218 | raise NotSupportedError('Lookup not supported for %s' % connection.settings_dict['ENGINE']) 219 | 220 | 221 | @FallbackJSONField.register_lookup 222 | class HasAnyKeys(FallbackLookup, lookups.HasAnyKeys): 223 | 224 | def as_sql(self, qn, connection): 225 | if '.postgresql' in connection.settings_dict['ENGINE']: 226 | return super().as_sql(qn, connection) 227 | if '.mysql' in connection.settings_dict['ENGINE']: 228 | lhs, lhs_params = self.process_lhs(qn, connection) 229 | paths = [ 230 | '$.{}'.format(json.dumps(key_name)) 231 | for key_name in self.rhs 232 | ] 233 | params = lhs_params + paths 234 | 235 | sql = ['JSON_CONTAINS_PATH(', lhs, ", 'one', "] 236 | sql.append(', '.join('%s' for _ in paths)) 237 | sql.append(')') 238 | return ''.join(sql), params 239 | raise NotSupportedError('Lookup not supported for %s' % connection.settings_dict['ENGINE']) 240 | 241 | 242 | class JSONValue(Func): 243 | function = 'CAST' 244 | template = '%(function)s(%(expressions)s AS JSON)' 245 | 246 | def __init__(self, expression): 247 | super(JSONValue, self).__init__(Value(expression)) 248 | 249 | 250 | def postgres_compile_json_path(key_transforms): 251 | return "{" + ','.join(key_transforms) + "}" 252 | 253 | 254 | def mysql_compile_json_path(key_transforms): 255 | path = ['$'] 256 | for key_transform in key_transforms: 257 | try: 258 | num = int(key_transform) 259 | path.append('[{}]'.format(num)) 260 | except ValueError: # non-integer 261 | path.append('.') 262 | path.append(key_transform) 263 | return ''.join(path) 264 | 265 | 266 | if django.VERSION >= (2, 1): 267 | @FallbackJSONField.register_lookup 268 | class JSONExact(lookups.JSONExact): 269 | 270 | def process_rhs(self, compiler, connection): 271 | rhs, rhs_params = super().process_rhs(compiler, connection) 272 | if '.mysql' in connection.settings_dict['ENGINE']: 273 | if not connection_is_mariadb(connection): 274 | func_params = [] 275 | new_params = [] 276 | for i, p in enumerate(rhs_params): 277 | if not hasattr(p, '_prepare') and p is not None: 278 | func, this_func_param = JSONValue(p).as_sql(compiler, connection) 279 | func_params.append(func) 280 | new_params += this_func_param 281 | else: 282 | func_params.append(p) 283 | rhs, rhs_params = rhs % tuple(func_params), new_params 284 | 285 | return rhs, rhs_params 286 | 287 | 288 | class FallbackKeyTransform(jsonb.KeyTransform): 289 | def as_sql(self, compiler, connection): 290 | if '.postgresql' in connection.settings_dict['ENGINE']: 291 | return super().as_sql(compiler, connection) 292 | elif '.mysql' in connection.settings_dict['ENGINE']: 293 | key_transforms = [self.key_name] 294 | previous = self.lhs 295 | while isinstance(previous, FallbackKeyTransform): 296 | key_transforms.insert(0, previous.key_name) 297 | previous = previous.lhs 298 | 299 | lhs, params = compiler.compile(previous) 300 | json_path = mysql_compile_json_path(key_transforms) 301 | return 'JSON_EXTRACT({}, %s)'.format(lhs), params + [json_path] 302 | 303 | raise NotSupportedError( 304 | 'Transforms on JSONFields are only supported on PostgreSQL and MySQL at the moment.' 305 | ) 306 | 307 | 308 | class FallbackKeyTransformFactory: 309 | 310 | def __init__(self, key_name): 311 | self.key_name = key_name 312 | 313 | def __call__(self, *args, **kwargs): 314 | return FallbackKeyTransform(self.key_name, *args, **kwargs) 315 | 316 | 317 | class KeyTextTransform(FallbackKeyTransform): 318 | operator = '->>' 319 | nested_operator = '#>>' 320 | output_field = TextField() 321 | 322 | 323 | class KeyTransformTextLookupMixin: 324 | """ 325 | Mixin for combining with a lookup expecting a text lhs from a JSONField 326 | key lookup. Make use of the ->> operator instead of casting key values to 327 | text and performing the lookup on the resulting representation. 328 | """ 329 | 330 | def __init__(self, key_transform, *args, **kwargs): 331 | assert isinstance(key_transform, FallbackKeyTransform) 332 | key_text_transform = KeyTextTransform( 333 | key_transform.key_name, *key_transform.source_expressions, **key_transform.extra 334 | ) 335 | super().__init__(key_text_transform, *args, **kwargs) 336 | 337 | 338 | class StringKeyTransformTextLookupMixin(KeyTransformTextLookupMixin): 339 | def process_rhs(self, qn, connection): 340 | rhs = super().process_rhs(qn, connection) 341 | if '.mysql' in connection.settings_dict['ENGINE']: 342 | params = [] 343 | for p in rhs[1]: 344 | params.append(json.dumps(p)) 345 | return rhs[0], params 346 | return rhs 347 | 348 | 349 | class NonStringKeyTransformTextLookupMixin: 350 | def process_rhs(self, qn, connection): 351 | rhs = super().process_rhs(qn, connection) 352 | if '.mysql' in connection.settings_dict['ENGINE']: 353 | params = [] 354 | for p in rhs[1]: 355 | val = json.loads(p) 356 | if isinstance(val, (list, dict)): 357 | val = json.dumps(val) 358 | params.append(val) 359 | return rhs[0], params 360 | return rhs 361 | 362 | 363 | class MySQLCaseInsensitiveMixin: 364 | def process_lhs(self, compiler, connection, lhs=None): 365 | lhs = super().process_lhs(compiler, connection, lhs=None) 366 | if '.mysql' in connection.settings_dict['ENGINE']: 367 | lhs = 'LOWER(%s)' % lhs[0], lhs[1] 368 | return lhs 369 | 370 | def process_rhs(self, qn, connection): 371 | rhs = super().process_rhs(qn, connection) 372 | if '.mysql' in connection.settings_dict['ENGINE']: 373 | rhs = 'LOWER(%s)' % rhs[0], rhs[1] 374 | return rhs 375 | 376 | 377 | @FallbackKeyTransform.register_lookup 378 | class KeyTransformExact(builtin_lookups.Exact): 379 | def process_rhs(self, compiler, connection): 380 | rhs, rhs_params = super().process_rhs(compiler, connection) 381 | if '.mysql' in connection.settings_dict['ENGINE']: 382 | func_params = [] 383 | new_params = [] 384 | 385 | for i, p in enumerate(rhs_params): 386 | val = json.loads(p) 387 | if isinstance(val, (list, dict)): 388 | if not connection_is_mariadb(connection): 389 | func, this_func_param = JSONValue(json.dumps(val)).as_sql(compiler, connection) 390 | func_params.append(func) 391 | new_params += this_func_param 392 | else: 393 | func_params.append('%s') 394 | new_params.append(json.dumps(val)) 395 | else: 396 | func_params.append('%s') 397 | new_params.append(val) 398 | rhs, rhs_params = rhs % tuple(func_params), new_params 399 | return rhs, rhs_params 400 | 401 | 402 | @FallbackKeyTransform.register_lookup 403 | class KeyTransformIExact(MySQLCaseInsensitiveMixin, StringKeyTransformTextLookupMixin, builtin_lookups.IExact): 404 | pass 405 | 406 | 407 | @FallbackKeyTransform.register_lookup 408 | class KeyTransformIContains(MySQLCaseInsensitiveMixin, StringKeyTransformTextLookupMixin, builtin_lookups.IContains): 409 | pass 410 | 411 | 412 | @FallbackKeyTransform.register_lookup 413 | class KeyTransformContains(StringKeyTransformTextLookupMixin, builtin_lookups.Contains): 414 | pass 415 | 416 | 417 | @FallbackKeyTransform.register_lookup 418 | class KeyTransformStartsWith(StringKeyTransformTextLookupMixin, builtin_lookups.StartsWith): 419 | pass 420 | 421 | 422 | @FallbackKeyTransform.register_lookup 423 | class KeyTransformIStartsWith(MySQLCaseInsensitiveMixin, StringKeyTransformTextLookupMixin, builtin_lookups.IStartsWith): 424 | pass 425 | 426 | 427 | @FallbackKeyTransform.register_lookup 428 | class KeyTransformEndsWith(StringKeyTransformTextLookupMixin, builtin_lookups.EndsWith): 429 | pass 430 | 431 | 432 | @FallbackKeyTransform.register_lookup 433 | class KeyTransformIEndsWith(MySQLCaseInsensitiveMixin, StringKeyTransformTextLookupMixin, builtin_lookups.IEndsWith): 434 | pass 435 | 436 | 437 | @FallbackKeyTransform.register_lookup 438 | class KeyTransformRegex(StringKeyTransformTextLookupMixin, builtin_lookups.Regex): 439 | pass 440 | 441 | 442 | @FallbackKeyTransform.register_lookup 443 | class KeyTransformIRegex(StringKeyTransformTextLookupMixin, builtin_lookups.IRegex): 444 | pass 445 | 446 | 447 | @FallbackKeyTransform.register_lookup 448 | class KeyTransformLte(NonStringKeyTransformTextLookupMixin, builtin_lookups.LessThanOrEqual): 449 | pass 450 | 451 | 452 | @FallbackKeyTransform.register_lookup 453 | class KeyTransformLt(NonStringKeyTransformTextLookupMixin, builtin_lookups.LessThan): 454 | pass 455 | 456 | 457 | @FallbackKeyTransform.register_lookup 458 | class KeyTransformGte(NonStringKeyTransformTextLookupMixin, builtin_lookups.GreaterThanOrEqual): 459 | pass 460 | 461 | 462 | @FallbackKeyTransform.register_lookup 463 | class KeyTransformGt(NonStringKeyTransformTextLookupMixin, builtin_lookups.GreaterThan): 464 | pass 465 | -------------------------------------------------------------------------------- /jsonfallback/functions.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from django.db import NotSupportedError 3 | from django.db.models import Expression 4 | 5 | from .fields import mysql_compile_json_path, postgres_compile_json_path, FallbackJSONField 6 | 7 | 8 | class JSONExtract(Expression): 9 | def __init__(self, expression, *path, output_field=FallbackJSONField(), **extra): 10 | super().__init__(output_field=output_field) 11 | self.path = path 12 | self.source_expression = self._parse_expressions(expression)[0] 13 | self.extra = extra 14 | 15 | def resolve_expression(self, query=None, allow_joins=True, reuse=None, summarize=False, for_save=False): 16 | c = self.copy() 17 | c.is_summary = summarize 18 | c.source_expression = c.source_expression.resolve_expression(query, allow_joins, reuse, summarize, for_save) 19 | return c 20 | 21 | def as_sql(self, compiler, connection, function=None, template=None, arg_joiner=None, **extra_context): 22 | if '.postgresql' in connection.settings_dict['ENGINE']: 23 | params = [] 24 | arg_sql, arg_params = compiler.compile(self.source_expression) 25 | params.extend(arg_params) 26 | json_path = postgres_compile_json_path(self.path) 27 | params.append(json_path) 28 | template = '{} #> %s'.format(arg_sql) 29 | return template, params 30 | elif '.mysql' in connection.settings_dict['ENGINE']: 31 | params = [] 32 | arg_sql, arg_params = compiler.compile(self.source_expression) 33 | params.extend(arg_params) 34 | json_path = mysql_compile_json_path(self.path) 35 | params.append(json_path) 36 | template = 'JSON_EXTRACT({}, %s)'.format(arg_sql) 37 | return template, params 38 | else: 39 | raise NotSupportedError( 40 | 'Functions on JSONFields are only supported on PostgreSQL and MySQL at the moment.' 41 | ) 42 | 43 | def copy(self): 44 | c = super().copy() 45 | c.source_expression = copy.copy(self.source_expression) 46 | c.extra = self.extra.copy() 47 | return c 48 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-django 3 | djangorestframework 4 | tox 5 | coverage 6 | codecov 7 | sphinx 8 | sphinx-autobuild 9 | isort 10 | flake8 11 | lxml>=4.2.4 12 | html5lib 13 | psycopg2 14 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 160 3 | exclude = migrations,.ropeproject,settings.py,conftest.py 4 | max-complexity = 11 5 | 6 | [isort] 7 | combine_as_imports = true 8 | default_section = THIRDPARTY 9 | include_trailing_comma = true 10 | known_first_party = i18nfield 11 | known_standard_library = typing 12 | multi_line_output = 5 13 | not_skip = __init__.py 14 | skip = settings.py,conftest.py 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from codecs import open 2 | from os import path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | here = path.abspath(path.dirname(__file__)) 7 | 8 | # Get the long description from the relevant file 9 | try: 10 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 11 | long_description = f.read() 12 | except: 13 | long_description = '' 14 | 15 | try: 16 | from jsonfallback import version 17 | except ImportError: 18 | version = '?' 19 | 20 | setup( 21 | name='django-jsonfallback', 22 | version=version, 23 | description='JSONField from django.contrib.postgres, but with a fallback to TextField', 24 | long_description=long_description, 25 | url='https://github.com/raphaelm/django-jsonfallback', 26 | author='Raphael Michel', 27 | author_email='mail@raphaelmichel.de', 28 | license='Apache License 2.0', 29 | classifiers=[ 30 | 'Intended Audience :: Developers', 31 | 'Intended Audience :: Other Audience', 32 | 'License :: OSI Approved :: Apache Software License', 33 | 'Programming Language :: Python :: 3.4', 34 | 'Programming Language :: Python :: 3.5', 35 | 'Programming Language :: Python :: 3.6', 36 | 'Framework :: Django :: 2.1' 37 | ], 38 | 39 | keywords='json database models', 40 | install_requires=[ 41 | 'django-mysql' 42 | ], 43 | 44 | packages=find_packages(exclude=['tests', 'tests.*', 'demoproject', 'demoproject.*']), 45 | include_package_data=True, 46 | ) 47 | -------------------------------------------------------------------------------- /test_manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/django-jsonfallback/ea1258b2c6ec2e9e03fe33c2095924173e82a1ae/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 4 | 5 | import django 6 | 7 | django.setup() 8 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.utils.translation import ugettext_lazy as _ 4 | 5 | 6 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 7 | SECRET_KEY = 'kk0ai8i0dm-8^%&0&+e-rsmk8#t&)6r*y!wh=xx7l12+6k5mg4' 8 | 9 | DEBUG = True 10 | ALLOWED_HOSTS = ['*'] 11 | 12 | 13 | INSTALLED_APPS = [ 14 | 'django.contrib.admin', 15 | 'django.contrib.auth', 16 | 'django.contrib.contenttypes', 17 | 'django.contrib.sessions', 18 | 'django.contrib.messages', 19 | 'django.contrib.staticfiles', 20 | 'tests.testapp' 21 | ] 22 | 23 | MIDDLEWARE = [ 24 | 'django.middleware.security.SecurityMiddleware', 25 | 'django.contrib.sessions.middleware.SessionMiddleware', 26 | 'django.middleware.common.CommonMiddleware', 27 | 'django.middleware.csrf.CsrfViewMiddleware', 28 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 29 | 'django.contrib.messages.middleware.MessageMiddleware', 30 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 31 | ] 32 | 33 | ROOT_URLCONF = 'tests.testapp.urls' 34 | 35 | TEMPLATES = [ 36 | { 37 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 38 | 'DIRS': [], 39 | 'APP_DIRS': True, 40 | 'OPTIONS': { 41 | 'context_processors': [ 42 | 'django.template.context_processors.debug', 43 | 'django.template.context_processors.request', 44 | 'django.contrib.auth.context_processors.auth', 45 | 'django.contrib.messages.context_processors.messages', 46 | ], 47 | }, 48 | }, 49 | ] 50 | 51 | WSGI_APPLICATION = 'tests.testapp.wsgi.application' 52 | 53 | if os.environ['TOXDB'] == 'postgres': 54 | DATABASES = { 55 | 'default': { 56 | 'ENGINE': 'django.db.backends.postgresql', 57 | 'NAME': 'jsonfallback', 58 | } 59 | } 60 | elif os.environ['TOXDB'] in ('mysql', 'mariadb'): 61 | DATABASES = { 62 | 'default': { 63 | 'ENGINE': 'django.db.backends.mysql', 64 | 'NAME': 'jsonfallback', 65 | 'USER': 'root', 66 | 'PASSWORD': '', 67 | 'HOST': '127.0.0.1', 68 | 'PORT': 3306 69 | } 70 | } 71 | else: 72 | DATABASES = { 73 | 'default': { 74 | 'ENGINE': 'django.db.backends.sqlite3', 75 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 76 | } 77 | } 78 | 79 | STATIC_URL = '/static/' 80 | 81 | LANGUAGE_CODE = 'en' 82 | TIME_ZONE = 'UTC' 83 | USE_I18N = True 84 | USE_L10N = True 85 | USE_TZ = True 86 | 87 | LANGUAGES = [ 88 | ('de', _('German')), 89 | ('en', _('English')), 90 | ('fr', _('French')), 91 | ] 92 | 93 | 94 | LOGGING = { 95 | 'version': 1, 96 | 'filters': { 97 | 'require_debug_true': { 98 | '()': 'django.utils.log.RequireDebugTrue', 99 | } 100 | }, 101 | 'handlers': { 102 | 'console': { 103 | 'level': 'DEBUG', 104 | 'filters': ['require_debug_true'], 105 | 'class': 'logging.StreamHandler', 106 | } 107 | }, 108 | 'loggers': { 109 | 'django.db.backends': { 110 | 'level': 'DEBUG', 111 | 'handlers': ['console'], 112 | } 113 | } 114 | } 115 | 116 | 117 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | import django 2 | import pytest 3 | from django.conf import settings 4 | from django.db import NotSupportedError 5 | from django.db.models import CharField 6 | 7 | from .testapp.models import Book 8 | from jsonfallback.functions import JSONExtract 9 | 10 | xfail = pytest.mark.xfail( 11 | condition=( 12 | 'postgres' not in settings.DATABASES['default']['ENGINE'] 13 | and 'mysql' not in settings.DATABASES['default']['ENGINE'] 14 | ), 15 | reason='Not supported on this database', 16 | raises=NotSupportedError 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def books(): 22 | return ( 23 | Book.objects.create(data={ 24 | 'title': 'The Lord of the Rings', 25 | 'author': 'Tolkien', 26 | 'publication': { 27 | 'year': 1954 28 | } 29 | }), 30 | Book.objects.create(data={ 31 | 'title': 'Harry Potter', 32 | 'author': 'Rowling', 33 | 'publication': { 34 | 'year': 1997 35 | } 36 | }) 37 | ) 38 | 39 | 40 | @pytest.mark.django_db 41 | @xfail 42 | def test_query_subfield(books): 43 | assert Book.objects.filter(data__author='Tolkien').count() == 1 44 | assert Book.objects.filter(data__author='Brett').count() == 0 45 | assert Book.objects.filter(data__publication__year=1997).count() == 1 46 | assert Book.objects.filter(data__publication__year=1998).count() == 0 47 | assert Book.objects.filter(data__publication={'year': 1997}).count() == 1 48 | assert Book.objects.filter(data__publication={'year': 1998}).count() == 0 49 | 50 | 51 | @pytest.mark.django_db 52 | @xfail 53 | def test_query_extract(books): 54 | assert list( 55 | Book.objects.annotate(author=JSONExtract('data', 'author')) 56 | .order_by('author') 57 | .values_list('author', flat=True) 58 | ) == [ 59 | 'Rowling', 60 | 'Tolkien', 61 | ] 62 | assert Book.objects.annotate(year=JSONExtract('data', 'publication', 'year')).filter(year=1997).count() == 1 63 | assert Book.objects.annotate(year=JSONExtract('data', 'publication', 'year')).filter(year=1998).count() == 0 64 | 65 | 66 | @pytest.mark.django_db 67 | @xfail 68 | def test_query_contains(books): 69 | assert Book.objects.filter(data__contains={'author': 'Tolkien'}).count() == 1 70 | assert Book.objects.filter(data__contains={'author': 'Brett'}).count() == 0 71 | 72 | 73 | @pytest.mark.django_db 74 | @xfail 75 | def test_query_contained_by(books): 76 | assert Book.objects.filter(data__contained_by={'title': 'Harry Potter', 'author': 'Rowling', 77 | 'publication': {'year': 1997}}).count() == 1 78 | assert Book.objects.filter(data__contained_by={'author': 'Brett'}).count() == 0 79 | 80 | 81 | @pytest.mark.django_db 82 | @xfail 83 | def test_query_has_key(books): 84 | assert Book.objects.filter(data__has_key='title').count() == 2 85 | assert Book.objects.filter(data__has_key='foo').count() == 0 86 | 87 | 88 | @pytest.mark.django_db 89 | @xfail 90 | def test_query_has_keys(books): 91 | assert Book.objects.filter(data__has_keys=['title']).count() == 2 92 | assert Book.objects.filter(data__has_keys=['foo']).count() == 0 93 | 94 | 95 | @pytest.mark.django_db 96 | @xfail 97 | def test_query_has_any_keys(books): 98 | assert Book.objects.filter(data__has_any_keys=['title', 'foo']).count() == 2 99 | assert Book.objects.filter(data__has_any_keys=['foo']).count() == 0 100 | 101 | 102 | @pytest.mark.django_db 103 | @xfail 104 | def test_query_exact_of_field(books): 105 | assert Book.objects.filter(data__title__exact='Harry Potter').count() == 1 106 | assert Book.objects.filter(data__title__exact='harry Potter').count() == 0 107 | assert Book.objects.filter(data__publication__year__exact=1997).count() == 1 108 | assert Book.objects.filter(data__publication__year__exact=1998).count() == 0 109 | 110 | 111 | @pytest.mark.django_db 112 | @xfail 113 | def test_query_iexact_of_field(books): 114 | assert Book.objects.filter(data__title__iexact='harry potter').count() == 1 115 | assert Book.objects.filter(data__title__iexact='Potter').count() == 0 116 | 117 | 118 | @pytest.mark.django_db 119 | @xfail 120 | def test_query_startswith_of_field(books): 121 | assert Book.objects.filter(data__title__startswith='Harry').count() == 1 122 | assert Book.objects.filter(data__title__startswith='Potter').count() == 0 123 | 124 | 125 | @pytest.mark.django_db 126 | @xfail 127 | def test_query_istartswith_of_field(books): 128 | assert Book.objects.filter(data__title__istartswith='harry').count() == 1 129 | assert Book.objects.filter(data__title__istartswith='potter').count() == 0 130 | 131 | 132 | @pytest.mark.django_db 133 | @xfail 134 | def test_query_endswith_of_field(books): 135 | assert Book.objects.filter(data__title__endswith='Potter').count() == 1 136 | assert Book.objects.filter(data__title__endswith='Harry').count() == 0 137 | 138 | 139 | @pytest.mark.django_db 140 | @xfail 141 | def test_query_iendswith_of_field(books): 142 | assert Book.objects.filter(data__title__iendswith='potter').count() == 1 143 | assert Book.objects.filter(data__title__iendswith='harry').count() == 0 144 | 145 | 146 | @pytest.mark.django_db 147 | @xfail 148 | def test_query_contains_of_field(books): 149 | assert Book.objects.filter(data__title__contains='Potter').count() == 1 150 | assert Book.objects.filter(data__title__contains='foo').count() == 0 151 | 152 | 153 | @pytest.mark.django_db 154 | @xfail 155 | def test_query_icontains_of_field(books): 156 | assert Book.objects.filter(data__title__icontains='potter').count() == 1 157 | assert Book.objects.filter(data__title__icontains='foo').count() == 0 158 | 159 | 160 | @pytest.mark.django_db 161 | @xfail 162 | def test_in_of_field(books): 163 | assert Book.objects.filter(data__publication__year__in=[1997, 1998]).count() == 1 164 | 165 | 166 | @pytest.mark.django_db 167 | @xfail 168 | def test_query_gt_lt_of_field(books): 169 | assert Book.objects.filter(data__publication__year__gt=1900).count() == 2 170 | assert Book.objects.filter(data__publication__year__gt=1990).count() == 1 171 | assert Book.objects.filter(data__publication__year__gte=1997).count() == 1 172 | assert Book.objects.filter(data__publication__year__gte=1998).count() == 0 173 | assert Book.objects.filter(data__publication__year__lt=1990).count() == 1 174 | assert Book.objects.filter(data__publication__year__lt=2000).count() == 2 175 | assert Book.objects.filter(data__publication__year__lte=1997).count() == 2 176 | assert Book.objects.filter(data__publication__year__lte=1996).count() == 1 177 | 178 | 179 | @pytest.mark.django_db 180 | @xfail 181 | @pytest.mark.skipif(django.VERSION < (2, 1), reason="Not supported on Django 2.0") 182 | def test_order_by(books): 183 | assert list(Book.objects.order_by('data__title').values_list('data__title', flat=True)) == [ 184 | 'Harry Potter', 185 | 'The Lord of the Rings' 186 | ] 187 | assert Book.objects.filter(data__title__icontains='foo').count() == 0 188 | 189 | 190 | @pytest.mark.django_db 191 | @pytest.mark.skipif(django.VERSION < (2, 1), reason="Not supported on Django 2.0") 192 | def test_query_equal(books): 193 | assert Book.objects.filter(data={'author': 'Rowling', 'title': 'Harry Potter', 'publication': {'year': 1997}}).count() == 1 194 | assert Book.objects.filter(data={'author': 'Brett'}).count() == 0 195 | -------------------------------------------------------------------------------- /tests/test_serialization.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | 5 | from .testapp.models import Book 6 | 7 | 8 | @pytest.mark.django_db 9 | def test_save_cycle(): 10 | Book.objects.create(data={'title': 'The Lord of the Rings', 'author': 'Tolkien'}) 11 | b = Book.objects.first() 12 | b.clean() 13 | assert b.data['title'] == 'The Lord of the Rings' 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_custom_encoder(): 18 | Book.objects.create(data={'title': 'The Lord of the Rings', 'date': date(1954, 7, 29)}) 19 | b = Book.objects.first() 20 | b.clean() 21 | assert b.data['date'] == '1954-07-29' 22 | 23 | 24 | @pytest.mark.django_db 25 | def test_default(): 26 | Book.objects.create() 27 | b = Book.objects.first() 28 | b.clean() 29 | assert b.data['foo'] == 'bar' 30 | 31 | 32 | """ 33 | @pytest.mark.django_db 34 | def test_nullable(): 35 | Book.objects.create(data=None) 36 | b = Book.objects.first() 37 | b.clean() 38 | assert b.data is None 39 | """ 40 | -------------------------------------------------------------------------------- /tests/testapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/django-jsonfallback/ea1258b2c6ec2e9e03fe33c2095924173e82a1ae/tests/testapp/__init__.py -------------------------------------------------------------------------------- /tests/testapp/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BookshelfConfig(AppConfig): 5 | name = 'bookshelf' 6 | -------------------------------------------------------------------------------- /tests/testapp/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.2 on 2018-10-16 19:46 2 | import jsonfallback.fields 3 | from django.core.serializers.json import DjangoJSONEncoder 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Book', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('data', jsonfallback.fields.FallbackJSONField( 20 | encoder=DjangoJSONEncoder, null=False, default={'foo': 'bar'} 21 | )), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /tests/testapp/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raphaelm/django-jsonfallback/ea1258b2c6ec2e9e03fe33c2095924173e82a1ae/tests/testapp/migrations/__init__.py -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | from django.core.serializers.json import DjangoJSONEncoder 2 | from django.db import models 3 | from jsonfallback.fields import FallbackJSONField 4 | 5 | 6 | class Book(models.Model): 7 | data = FallbackJSONField(encoder=DjangoJSONEncoder, null=False, default={'foo': 'bar'}) 8 | 9 | def __str__(self): 10 | return str(self.data['title']) 11 | -------------------------------------------------------------------------------- /tests/testapp/urls.py: -------------------------------------------------------------------------------- 1 | urlpatterns = [ 2 | ] 3 | -------------------------------------------------------------------------------- /tests/testapp/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for demoproject project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demoproject.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | py{34,35,36}-django20 4 | py{35,36,37}-django21 5 | py{35,36,37}-django22 6 | coverage 7 | style 8 | 9 | [testenv] 10 | passenv = TOXDB TOXENV CI TRAVIS TRAVIS_* 11 | deps = 12 | -Urrequirements_dev.txt 13 | py34: typing 14 | py34: mysqlclient<1.4.0 15 | py35: mysqlclient>=1.4.0 16 | py36: mysqlclient>=1.4.0 17 | py37: mysqlclient>=1.4.0 18 | django20: Django>=2.0,<2.1 19 | django21: Django>=2.1,<2.2 20 | django22: Django>=2.2,<2.3 21 | commands = py.test tests 22 | 23 | [testenv:coverage] 24 | basepython=python3.6 25 | deps= 26 | -Urrequirements_dev.txt 27 | django==2.1.* 28 | commands = 29 | coverage erase 30 | coverage run -m py.test tests 31 | coverage report 32 | codecov -e TOXENV 33 | 34 | [testenv:style] 35 | basepython=python3.6 36 | deps= 37 | -Urrequirements_dev.txt 38 | django==2.1.* 39 | commands = 40 | flake8 jsonfallback tests 41 | isort -c -rc flake8 jsonfallback tests 42 | changedir = docs 43 | --------------------------------------------------------------------------------