├── .gitignore ├── CONTRIBUTING.rst ├── LICENSE.txt ├── Pipfile ├── README.md ├── django_add_default_value ├── __init__.py └── add_default_value.py ├── setup.cfg ├── setup.py ├── test_project ├── .travis.yml ├── dadv │ ├── __init__.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_testtextdefault.py │ │ ├── 0003_testhappypath.py │ │ ├── 0004_time_related_fields.py │ │ ├── 0005_testcustomcolumnname.py │ │ └── __init__.py │ └── models.py ├── manage.py ├── test_project │ ├── __init__.py │ ├── settings.py │ ├── settings_crdb.sample.py │ ├── settings_mysql.sample.py │ ├── settings_pgsql.sample.py │ ├── urls.py │ └── wsgi.py └── tests │ └── __init__.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | /test_project/test_project/settings_mssql.py 57 | /test_project/test_project/settings_mysql.py 58 | /test_project/test_project/settings_pgsql.py 59 | /test_project/test_project/settings_crdb.py 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # dotenv 87 | .env 88 | 89 | # virtualenv 90 | .venv 91 | venv/ 92 | ENV/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | 107 | # PyCharm 108 | .idea/ 109 | 110 | # Lockfile for pip: do not version it, as we are a "library", not a "project". 111 | Pipfile.lock 112 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | CONTRIBUTING 2 | ============ 3 | 4 | First of all, thank you very much for contributing to this project. Please base 5 | your work on the ``master`` branch and target ``master`` in your pull request. 6 | 7 | Tools 8 | ----- 9 | We are currently evaluating the upcoming `Pipfile`_ standard and for that employ `pipenv`_ . 10 | The assumption is that you have `pipenv` installed as a user installation (`pip install --user pipenv`). If 11 | you are not familiar with `pipenv`, you can view a solid introduction on why `requirements.txt` no longer suffices in 12 | the python packaging community in `this video`_. 13 | 14 | Pep8 compliance 15 | --------------- 16 | Where possible we adhere to pep8, and a max McCabe complexity of 5. The tool `flake8`_ will help you accomplish this. 17 | 18 | 19 | .. _Pipfile: https://github.com/pypa/pipfile 20 | .. _pipenv: https://github.com/pypa/pipfile 21 | .. _tox plugin: https://github.com/tox-dev/tox-pipenv 22 | .. _this video: https://www.youtube.com/watch?v=GBQAKldqgZs 23 | .. _flake8: http://flake8.pycqa.org/en/latest/ 24 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | 8 | [dev-packages] 9 | coverage = "*" 10 | Django = ">=1.11" 11 | "flake8" = "*" 12 | bumpversion = "*" 13 | mysql-connector-python = "*" 14 | mysqlclient = "2.0.1" 15 | "psycopg2-binary" = "*" 16 | django-cockroachdb = ">=2.2" # Use the version of django-cockroachdb that corresponds to your version of Django. 17 | tox = "*" 18 | black = "==18.6b4" 19 | ipython = "*" 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django Add Default Value 2 | ======================== 3 | 4 | Django Migration Operation that can be used to transfer a field's default value 5 | to the database scheme. 6 | 7 | [![PyPi](https://img.shields.io/pypi/v/django-add-default-value.svg?branch=master)](https://pypi.python.org/pypi/django-add-default-value/) 8 | [![License](https://img.shields.io/github/license/3yourmind/django-add-default-value.svg)](./LICENSE) 9 | [![Contributing](https://img.shields.io/badge/PR-welcome-green.svg)](https://github.com/3YOURMIND/django-add-default-value/pulls) 10 | [![3yourminD-Careers](https://img.shields.io/badge/3YOURMIND-Hiring-brightgreen.svg)](https://www.3yourmind.com/career) 11 | [![Stars](https://img.shields.io/github/stars/3YOURMIND/django-add-default-value.svg?style=social&label=Stars)](https://github.com/3YOURMIND/django-add-default-value/stargazers) 12 | 13 | 14 | Supported Databases 15 | ------------ 16 | 17 | * MySQL (or compatible) 18 | * PostgreSQL 19 | * MSSQL (currently not tested) 20 | * CockroachDB 21 | 22 | Installation 23 | ------------ 24 | `pip install django-add-default-value` 25 | 26 | You can then use ``AddDefaultValue`` in your migration file to transfer the default 27 | values to your database. Afterwards, it's just the usual ``./manage.py migrate``. 28 | 29 | Usage 30 | ----- 31 | 32 | Add this manually to a autogenerated Migration, that adds a new field 33 | 34 | ```python 35 | AddDefaultValue( 36 | model_name='my_model', 37 | name='my_field', 38 | value='my_default' 39 | ) 40 | ``` 41 | 42 | ### Example 43 | 44 | Given the following migration: 45 | 46 | **BEFORE** 47 | 48 | ```python 49 | from django.db import migrations, models 50 | 51 | 52 | class Migration(migrations.Migration): 53 | dependencies = [ 54 | ... 55 | ] 56 | 57 | operations = [ 58 | migrations.AddField( 59 | field=models.CharField(default='my_default', max_length=255), 60 | model_name='my_model', 61 | name='my_field', 62 | ), 63 | ] 64 | ``` 65 | 66 | Modify the migration to add a default value: 67 | 68 | **AFTER** 69 | 70 | ```python 71 | from django.db import migrations, models 72 | 73 | from django_add_default_value import AddDefaultValue 74 | 75 | 76 | class Migration(migrations.Migration): 77 | dependencies = [ 78 | ... 79 | ] 80 | 81 | operations = [ 82 | migrations.AddField( 83 | field=models.CharField(default='my_default', max_length=255), 84 | model_name='my_model', 85 | name='my_field', 86 | ), 87 | AddDefaultValue( 88 | model_name='my_model', 89 | name='my_field', 90 | value='my_default' 91 | ) 92 | ] 93 | ``` 94 | 95 | If you check ``python manage.py sqlmigrate [app name] [migration]``, 96 | you will see that the default value now gets set. 97 | 98 | Contributing 99 | ------------ 100 | 101 | First of all, thank you very much for contributing to this project. Please base 102 | your work on the ``master`` branch and target ``master`` in your pull request. 103 | 104 | To succesfully use the `dbshell` management command (very useful for debugging), 105 | the client binaries for the respective database engines are needed. 106 | 107 | Then install [pipenv](https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv). 108 | Edit the `Pipfile` to select your Django version and the accompanying MS-SQL 109 | driver. Make sure you don't commit this change in any pull request - we always 110 | set it to the oldest supported version. 111 | 112 | Once you've updated the Pipfile, run `pipenv install --python 3 --dev`. You 113 | should now have a working development environment as a virtualenv. To access it, 114 | run `pipenv shell` or prefix commands with `pipenv run`. For more information 115 | see the [pipenv documentation](https://pipenv.readthedocs.io/en/latest/basics/). 116 | 117 | ### Testing 118 | Copy the relevant sample settings file in `test_project` to the file without 119 | `.sample` in it. Adjust the values to match your environment (or match your 120 | environment to the values). 121 | 122 | You should now be able to run the tests using `tox`. Select your environment 123 | when needed, using the `-e` command line flag. See 124 | [Tox's excellent documentation](https://tox.readthedocs.io/en/latest/). 125 | 126 | 127 | License 128 | ------- 129 | 130 | ``django-add-default-value`` is released under the Apache 2.0 License. 131 | 132 | 133 | -------------------------------------------------------------------------------- /django_add_default_value/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 3YOURMIND GmbH 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from .add_default_value import * # noqa 16 | -------------------------------------------------------------------------------- /django_add_default_value/add_default_value.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 3YOURMIND GmbH 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | from __future__ import unicode_literals 14 | import warnings 15 | 16 | import django 17 | from django.db.migrations.operations.base import Operation 18 | from django.db import models 19 | from datetime import date, datetime 20 | from django.utils import timezone 21 | 22 | NOW = "__NOW__" 23 | TODAY = "__TODAY__" 24 | START = 0 25 | END = 1 26 | 27 | 28 | def is_text_field(model, field_name): 29 | options = model._meta # type: models.base.Options 30 | field = options.get_field(field_name) 31 | return isinstance(field, models.TextField) 32 | 33 | 34 | def is_date_field(model, field_name): 35 | options = model._meta # type: models.base.Options 36 | field = options.get_field(field_name) 37 | return isinstance(field, models.DateField) 38 | 39 | 40 | class AddDefaultValue(Operation): 41 | reversible = True 42 | quotes = { 43 | "value": ("'", "'"), 44 | "constant": ("", ""), 45 | "function": ("", ""), 46 | "name": ('"', '"'), 47 | } 48 | 49 | def __init__(self, model_name, name, value): 50 | self.model_name = model_name 51 | self.name = name 52 | self.value = value 53 | 54 | def describe(self): 55 | """ 56 | Output a brief summary of what the action does. 57 | """ 58 | return "Add to field {model}.{field} the default value {value}".format( 59 | model=self.model_name, field=self.name, value=self.value 60 | ) 61 | 62 | def state_forwards(self, app_label, state): 63 | """ 64 | Take the state from the previous migration, and mutate it 65 | so that it matches what this migration would perform. 66 | """ 67 | # Nothing to do 68 | # because the field should have the default set anyway 69 | pass 70 | 71 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 72 | """ 73 | Perform the mutation on the database schema in the normal 74 | (forwards) direction. 75 | """ 76 | if not self.is_supported_vendor(schema_editor.connection.vendor): 77 | warnings.warn( 78 | "AddDefaultValue cannot be applied on a non-supported vendor." 79 | ) 80 | return 81 | 82 | self.initialize_vendor_state(schema_editor) 83 | 84 | to_model = to_state.apps.get_model(app_label, self.model_name) 85 | 86 | if not self.allow_migrate_model(schema_editor.connection.alias, to_model): 87 | return 88 | 89 | if not self.can_apply_default(to_model, self.name, schema_editor.connection): 90 | warnings.warn( 91 | "You requested a default for a field / database combination " 92 | "that does not allow one. The default will not be set on: " 93 | "{model}.{field}.".format(model=to_model.__name__, field=self.name) 94 | ) 95 | return 96 | 97 | # Fetch the actual field so we get the column name properly 98 | options = to_model._meta # type: models.base.Options 99 | field = options.get_field(self.name) 100 | 101 | sql_value, value_quote = self.clean_value( 102 | schema_editor.connection.vendor, self.value 103 | ) 104 | format_kwargs = dict( 105 | table=to_model._meta.db_table, 106 | field=field.get_attname_column()[1], 107 | value=sql_value, 108 | value_quote_start=value_quote[START], 109 | value_quote_end=value_quote[END], 110 | name_quote_start=self.quotes["name"][START], 111 | name_quote_end=self.quotes["name"][END], 112 | ) 113 | if not self.is_mssql(schema_editor.connection.vendor): 114 | sql_query = ( 115 | "ALTER TABLE {name_quote_start}{table}{name_quote_end} " 116 | "ALTER COLUMN {name_quote_start}{field}{name_quote_end} " 117 | "SET DEFAULT {value_quote_start}{value}{value_quote_end};".format( 118 | **format_kwargs 119 | ) 120 | ) 121 | else: 122 | constraint_name = self.mssql_constraint_name() 123 | format_kwargs.update(constraint_name=constraint_name) 124 | sql_query = ( 125 | "ALTER TABLE {name_quote_start}{table}{name_quote_end} " 126 | "ADD CONSTRAINT {name_quote_start}{constraint_name}{name_quote_end} " 127 | "DEFAULT {value_quote_start}{value}{value_quote_end} " 128 | "FOR {name_quote_start}{field}{name_quote_end};".format(**format_kwargs) 129 | ) 130 | 131 | schema_editor.execute(sql_query) 132 | 133 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 134 | """ 135 | Perform the mutation on the database schema in the reverse 136 | direction - e.g. if this were CreateModel, it would in fact 137 | drop the model's table. 138 | """ 139 | if not self.is_supported_vendor(schema_editor.connection.vendor): 140 | warnings.warn( 141 | "AddDefaultValue cannot be applied on a non-supported vendor." 142 | ) 143 | return 144 | 145 | self.initialize_vendor_state(schema_editor) 146 | 147 | to_model = to_state.apps.get_model(app_label, self.model_name) 148 | 149 | if not self.allow_migrate_model(schema_editor.connection.alias, to_model): 150 | return 151 | 152 | if not self.can_apply_default(to_model, self.name, schema_editor.connection): 153 | return 154 | 155 | # Fetch the actual field so we get the column name properly 156 | options = to_model._meta # type: models.base.Options 157 | field = options.get_field(self.name) 158 | 159 | format_kwargs = dict( 160 | table=to_model._meta.db_table, 161 | field=field.get_attname_column()[1], 162 | name_quote_start=self.quotes["name"][START], 163 | name_quote_end=self.quotes["name"][END], 164 | ) 165 | if not self.is_mssql(schema_editor.connection.vendor): 166 | sql_query = ( 167 | "ALTER TABLE {name_quote_start}{table}{name_quote_end} " 168 | "ALTER COLUMN {name_quote_start}{field}{name_quote_end} " 169 | "DROP DEFAULT;".format(**format_kwargs) 170 | ) 171 | else: 172 | constraint_name = self.mssql_constraint_name() 173 | format_kwargs.update(constraint_name=constraint_name) 174 | sql_query = ( 175 | "ALTER TABLE {name_quote_start}{table}{name_quote_end} " 176 | "DROP CONSTRAINT {name_quote_start}{constraint_name}{name_quote_end}".format( 177 | **format_kwargs 178 | ) 179 | ) 180 | 181 | schema_editor.execute(sql_query) 182 | 183 | def deconstruct(self): 184 | return ( 185 | self.__class__.__name__, 186 | [], 187 | {"model_name": self.model_name, "name": self.name, "value": self.value}, 188 | ) 189 | 190 | def initialize_vendor_state(self, schema_editor): 191 | self.set_quotes(schema_editor.connection.vendor) 192 | major, minor, patch, __, ___ = django.VERSION 193 | if ( 194 | self.is_mysql(schema_editor.connection.vendor) 195 | and version_with_broken_quote_value(major, minor, patch) 196 | and not hasattr(schema_editor.__class__, "_patched_quote_value") 197 | ): 198 | schema_editor.__class__.quote_value = quote_value 199 | schema_editor.__class__._patched_quote_value = True 200 | 201 | def set_quotes(self, vendor): 202 | """ 203 | Set the various quotes according to vendor. The default quotes are set to the 204 | default vendor. 205 | 206 | :param vendor: Connection vendor string as provided by the db backend 207 | """ 208 | if self.is_default_vendor(vendor): 209 | self.quotes["name"] = ('"', '"') 210 | 211 | if self.is_mysql(vendor): 212 | self.quotes["name"] = ("`", "`") 213 | 214 | if self.is_mssql(vendor): 215 | self.quotes["name"] = ("[", "]") 216 | 217 | @classmethod 218 | def is_supported_vendor(cls, vendor): 219 | return ( 220 | cls.is_postgresql(vendor) 221 | or cls.is_mysql(vendor) 222 | or cls.is_mssql(vendor) 223 | or cls.is_cockroachdb(vendor) 224 | ) 225 | 226 | @classmethod 227 | def is_default_vendor(cls, vendor): 228 | return cls.is_postgresql(vendor) 229 | 230 | @classmethod 231 | def is_mysql(cls, vendor): 232 | return vendor.startswith("mysql") 233 | 234 | @classmethod 235 | def is_postgresql(cls, vendor): 236 | return vendor.startswith("postgre") 237 | 238 | @classmethod 239 | def is_mssql(cls, vendor): 240 | return vendor.startswith("microsoft") 241 | 242 | @classmethod 243 | def is_cockroachdb(cls, vendor): 244 | return vendor.startswith("cockroachdb") 245 | 246 | @classmethod 247 | def is_postgresql_syntax_compatible(cls, vendor): 248 | return cls.is_postgresql(vendor) or cls.is_cockroachdb(vendor) 249 | 250 | @classmethod 251 | def is_mariadb(cls, connection): 252 | if hasattr(connection, "mysql_is_mariadb"): 253 | if callable(connection.mysql_is_mariadb): 254 | return connection.mysql_is_mariadb() 255 | else: 256 | return connection.mysql_is_mariadb 257 | return False 258 | 259 | def can_apply_default(self, model, name, connection): 260 | if is_text_field(model, name) and not self.can_have_default_for_text( 261 | connection 262 | ): 263 | return False 264 | 265 | if self.value == TODAY and self.is_mysql(connection.vendor): 266 | return False 267 | 268 | return True 269 | 270 | @classmethod 271 | def can_have_default_for_text(cls, connection): 272 | """ 273 | MySQL has not allowed DEFAULT for BLOB and TEXT fields since the 274 | beginning of time, but it is changing: 275 | 276 | Before MariaDB 10.2.1, BLOB and TEXT columns could not be assigned 277 | a DEFAULT value. This restriction was lifted in MariaDB 10.2.1. 278 | 279 | Oracle does not yet have a version available that supports it, 280 | quoting the `documentation 281 | `_: 282 | 283 | BLOB and TEXT columns cannot have DEFAULT values. 284 | 285 | :param connection: The DB connection, aka `schema_editor.connection` 286 | :type connection: django.db.backends.base.base.BaseDatabaseWrapper 287 | :return: A boolean indicating we support default values for text 288 | fields. 289 | :rtype: bool 290 | """ 291 | if cls.is_postgresql_syntax_compatible(connection.vendor) or cls.is_mssql( 292 | connection.vendor 293 | ): 294 | return True 295 | 296 | if not hasattr(connection, "mysql_version"): 297 | return False 298 | 299 | if not cls.is_mariadb(connection): 300 | return False 301 | 302 | # noinspection PyUnresolvedReferences 303 | try: # see if we need to calculate the version 304 | mysql_version = connection.mysql_version() 305 | except TypeError: # if it is already calulcated, then it can't be called 306 | mysql_version = connection.mysql_version 307 | major, minor, patch = mysql_version 308 | return major > 9 and minor > 1 and patch > 0 309 | 310 | def clean_value(self, vendor, value): 311 | """ 312 | Lie, cheat and apply plastic surgery where needed 313 | 314 | :param vendor: database vendor we need to perform operations for 315 | :param value: the value as provided in the migration 316 | :return: a 2-tuple containing the new value and the quotation to use 317 | """ 318 | if isinstance(value, bool) and not self.is_postgresql_syntax_compatible(vendor): 319 | if value: 320 | return 1, self.quotes["value"] 321 | 322 | return 0, self.quotes["value"] 323 | 324 | value, quote, handled = self._clean_temporal(vendor, value) 325 | if handled: 326 | return value, quote 327 | 328 | value, quote, handled = self._clean_temporal_constants(vendor, value) 329 | if handled: 330 | return value, quote 331 | 332 | return value, self.quotes["value"] 333 | 334 | def mssql_constraint_name(self): 335 | return "DADV_{model}_{field}_DEFAULT".format( 336 | model=self.model_name, field=self.name 337 | ) 338 | 339 | def _clean_temporal(self, vendor, value): 340 | if isinstance(value, date): 341 | return value.isoformat(), self.quotes["value"], True 342 | 343 | if isinstance(value, datetime): 344 | if self.is_postgresql_syntax_compatible(vendor): 345 | return ( 346 | value.isoformat(" ", timespec="seconds"), 347 | self.quotes["value"], 348 | True, 349 | ) 350 | else: 351 | naive = timezone.make_naive(value) 352 | return ( 353 | naive.isoformat(" ", timespec="seconds"), 354 | self.quotes["value"], 355 | True, 356 | ) 357 | 358 | return value, self.quotes["value"], False 359 | 360 | def _clean_temporal_constants(self, vendor, value): 361 | if value == NOW or value == TODAY: 362 | if self.is_postgresql_syntax_compatible(vendor): 363 | return "now()", self.quotes["function"], True 364 | elif self.is_mssql(vendor): 365 | return "GETDATE()", self.quotes["function"], True 366 | 367 | # https://stackoverflow.com/a/20461045/10000573 368 | if value == NOW and self.is_mysql(vendor): 369 | return "CURRENT_TIMESTAMP", self.quotes["constant"], True 370 | 371 | return value, self.quotes["value"], False 372 | 373 | 374 | def version_with_broken_quote_value(major, minor, patch): 375 | if major == 2: 376 | if minor == 1 and patch < 9: 377 | return True 378 | elif minor == 2 and patch < 2: 379 | return True 380 | 381 | return False 382 | 383 | 384 | def quote_value(self, value): 385 | self.connection.ensure_connection() 386 | 387 | # MySQLdb escapes to string, PyMySQL to bytes. 388 | quoted = self.connection.connection.escape( 389 | value, self.connection.connection.encoders 390 | ) 391 | if isinstance(value, str) and isinstance(quoted, bytes): 392 | quoted = quoted.decode() 393 | return quoted 394 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.10.0 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [metadata] 8 | name = django-add-default-value 9 | description = This django Migration Operation can be used to transfer a fields default value to the database scheme. 10 | long_description = file:README.md 11 | url = https://github.com/3YOURMIND/django-add-default-value 12 | author = 3YOURMIND GmbH 13 | license = Apache-2.0 14 | classifiers = 15 | Development Status :: 3 - Alpha 16 | Intended Audience :: Developers 17 | Environment :: Web Environment 18 | Framework :: Django 19 | Framework :: Django :: 1.11 20 | Framework :: Django :: 2.0 21 | Framework :: Django :: 2.1 22 | Framework :: Django :: 2.2 23 | Framework :: Django :: 3.0 24 | Framework :: Django :: 3.1 25 | Framework :: Django :: 3.2 26 | License :: OSI Approved :: Apache Software License 27 | Programming Language :: Python 28 | Programming Language :: Python :: 2 29 | Programming Language :: Python :: 3 30 | Programming Language :: Python :: 3.4 31 | Programming Language :: Python :: 3.5 32 | Programming Language :: Python :: 3.6 33 | Programming Language :: Python :: 3.7 34 | Programming Language :: Python :: 3.8 35 | Programming Language :: Python :: 3.9 36 | Topic :: Software Development :: Libraries :: Python Modules 37 | keywords = 38 | django 39 | migration 40 | default 41 | database backward compatibility 42 | 43 | [options] 44 | install_requires = 45 | Django>=1.11 46 | tests_require = 47 | tox 48 | coverage 49 | flake8 50 | 51 | [bumpversion:file:setup.py] 52 | 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 3YOURMIND GmbH 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from setuptools import setup 16 | 17 | 18 | setup( 19 | version='0.10.0', 20 | packages=['django_add_default_value'], 21 | long_description_content_type='text/markdown', 22 | ) 23 | -------------------------------------------------------------------------------- /test_project/.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | - "3.7" 9 | - "3.8" 10 | - "3.9" 11 | install: pip install tox-travis 12 | script: tox 13 | -------------------------------------------------------------------------------- /test_project/dadv/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3YOURMIND/django-add-default-value/c7f5f5869ae6ecf93760f061ff38c00bab820b2d/test_project/dadv/__init__.py -------------------------------------------------------------------------------- /test_project/dadv/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DadvConfig(AppConfig): 5 | name = "dadv" 6 | -------------------------------------------------------------------------------- /test_project/dadv/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-08-11 21:25 2 | 3 | from django.db import migrations, models 4 | from django_add_default_value import AddDefaultValue 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="TestBoolDefault", 16 | fields=[ 17 | ("id", models.BigAutoField(primary_key=True, serialize=False)), 18 | ("is_functional", models.BooleanField(default=False)), 19 | ], 20 | ), 21 | AddDefaultValue("TestBoolDefault", "is_functional", False), 22 | ] 23 | -------------------------------------------------------------------------------- /test_project/dadv/migrations/0002_testtextdefault.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-08-12 16:09 2 | 3 | from django.db import migrations, models 4 | from django_add_default_value import AddDefaultValue 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("dadv", "0001_initial")] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="TestTextDefault", 14 | fields=[ 15 | ("id", models.BigAutoField(primary_key=True, serialize=False)), 16 | ("description", models.TextField(default="No description provided")), 17 | ], 18 | ), 19 | AddDefaultValue( 20 | model_name="TestTextDefault", 21 | name="description", 22 | value="No description provided", 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /test_project/dadv/migrations/0003_testhappypath.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-08-12 17:06 2 | 3 | from django.db import migrations, models 4 | from django_add_default_value import AddDefaultValue 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("dadv", "0002_testtextdefault")] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="TestHappyPath", 14 | fields=[ 15 | ("id", models.BigAutoField(primary_key=True, serialize=False)), 16 | ("name", models.CharField(default="Happy path", max_length=15)), 17 | ], 18 | ), 19 | AddDefaultValue(model_name="TestHappyPath", name="name", value="Happy path"), 20 | ] 21 | -------------------------------------------------------------------------------- /test_project/dadv/migrations/0004_time_related_fields.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.8 on 2018-08-12 18:23 2 | 3 | import datetime 4 | from django.db import migrations, models 5 | import django.utils.timezone 6 | from django_add_default_value import AddDefaultValue, NOW, TODAY 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [("dadv", "0003_testhappypath")] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="testhappypath", 16 | name="dob", 17 | field=models.DateField(default=datetime.date(1970, 1, 1)), 18 | ), 19 | migrations.AddField( 20 | model_name="testhappypath", 21 | name="rebirth", 22 | field=models.DateTimeField(default=django.utils.timezone.now), 23 | ), 24 | migrations.AddField( 25 | model_name="testhappypath", 26 | name="married", 27 | field=models.DateField(default=datetime.date.today), 28 | ), 29 | AddDefaultValue( 30 | model_name="testhappypath", name="dob", value=datetime.date(1970, 1, 1) 31 | ), 32 | AddDefaultValue(model_name="testhappypath", name="rebirth", value=NOW), 33 | AddDefaultValue(model_name="testhappypath", name="married", value=TODAY), 34 | ] 35 | -------------------------------------------------------------------------------- /test_project/dadv/migrations/0005_testcustomcolumnname.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.3 on 2020-11-05 16:38 2 | 3 | from django.db import migrations, models 4 | from django_add_default_value import AddDefaultValue 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("dadv", "0004_time_related_fields"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="TestCustomColumnName", 16 | fields=[ 17 | ( 18 | "id", 19 | models.BigAutoField( 20 | db_column="custom_id", primary_key=True, serialize=False 21 | ), 22 | ), 23 | ( 24 | "is_functional", 25 | models.BooleanField(db_column="custom_field", default=False), 26 | ), 27 | ], 28 | ), 29 | AddDefaultValue( 30 | model_name="TestCustomColumnName", name="is_functional", value=False 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /test_project/dadv/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3YOURMIND/django-add-default-value/c7f5f5869ae6ecf93760f061ff38c00bab820b2d/test_project/dadv/migrations/__init__.py -------------------------------------------------------------------------------- /test_project/dadv/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from datetime import date 3 | from django.utils import timezone 4 | 5 | 6 | class TestBoolDefault(models.Model): 7 | id = models.BigAutoField(primary_key=True) 8 | is_functional = models.BooleanField(default=False) 9 | 10 | 11 | class TestTextDefault(models.Model): 12 | id = models.BigAutoField(primary_key=True) 13 | description = models.TextField(default="No description provided") 14 | 15 | 16 | class TestHappyPath(models.Model): 17 | id = models.BigAutoField(primary_key=True) 18 | name = models.CharField(default="Happy path", max_length=15) 19 | dob = models.DateField(default=date(1970, 1, 1)) 20 | rebirth = models.DateTimeField(default=timezone.now) 21 | married = models.DateField(default=date.today) 22 | 23 | 24 | class TestCustomColumnName(models.Model): 25 | id = models.BigAutoField(primary_key=True, db_column="custom_id") 26 | is_functional = models.BooleanField(default=False, db_column="custom_field") 27 | -------------------------------------------------------------------------------- /test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /test_project/test_project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/3YOURMIND/django-add-default-value/c7f5f5869ae6ecf93760f061ff38c00bab820b2d/test_project/test_project/__init__.py -------------------------------------------------------------------------------- /test_project/test_project/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | INSTALLED_APPS = [ 6 | "django.contrib.admin", 7 | "django.contrib.auth", 8 | "django.contrib.contenttypes", 9 | "django.contrib.sessions", 10 | "django.contrib.messages", 11 | "django.contrib.staticfiles", 12 | # 'dadv', 13 | ] 14 | 15 | MIDDLEWARE = [ 16 | "django.middleware.security.SecurityMiddleware", 17 | "django.contrib.sessions.middleware.SessionMiddleware", 18 | "django.middleware.common.CommonMiddleware", 19 | "django.middleware.csrf.CsrfViewMiddleware", 20 | "django.contrib.auth.middleware.AuthenticationMiddleware", 21 | "django.contrib.messages.middleware.MessageMiddleware", 22 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 23 | ] 24 | 25 | ROOT_URLCONF = "test_project.urls" 26 | 27 | TEMPLATES = [ 28 | { 29 | "BACKEND": "django.template.backends.django.DjangoTemplates", 30 | "DIRS": [], 31 | "APP_DIRS": True, 32 | "OPTIONS": { 33 | "context_processors": [ 34 | "django.template.context_processors.debug", 35 | "django.template.context_processors.request", 36 | "django.contrib.auth.context_processors.auth", 37 | "django.contrib.messages.context_processors.messages", 38 | ] 39 | }, 40 | } 41 | ] 42 | 43 | WSGI_APPLICATION = "test_project.wsgi.application" 44 | 45 | # Password validation 46 | # https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators 47 | 48 | AUTH_PASSWORD_VALIDATORS = [ 49 | { 50 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 51 | }, 52 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 53 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 54 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 55 | ] 56 | 57 | 58 | # Internationalization 59 | # https://docs.djangoproject.com/en/2.0/topics/i18n/ 60 | 61 | LANGUAGE_CODE = "en-us" 62 | 63 | TIME_ZONE = "UTC" 64 | 65 | USE_I18N = False 66 | 67 | USE_L10N = False 68 | 69 | USE_TZ = True 70 | 71 | 72 | # Static files (CSS, JavaScript, Images) 73 | # https://docs.djangoproject.com/en/2.0/howto/static-files/ 74 | 75 | STATIC_URL = "/static/" 76 | -------------------------------------------------------------------------------- /test_project/test_project/settings_crdb.sample.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .settings import * 3 | 4 | DBUSER = os.environ.get("CRDB_USER", "root") 5 | DBHOST = os.environ.get("CRDB_HOST", "localhost") 6 | DBPORT = None 7 | if not DBHOST.startswith("/"): 8 | DBPORT = os.environ.get("CRDB_PORT", "26257") 9 | DBNAME = os.environ.get("CRDB_DATABASE", "defaultdb") 10 | 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django_cockroachdb", 14 | "NAME": DBNAME, 15 | "HOST": DBHOST, 16 | "PORT": DBPORT, 17 | "USER": DBUSER, 18 | }, 19 | } 20 | 21 | SECRET_KEY = "django_tests_secret_key" 22 | 23 | # Use a fast hasher to speed up tests. 24 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 25 | 26 | if os.environ.get("ADD_TEST_APP", False): 27 | INSTALLED_APPS.append("dadv.apps.DadvConfig") 28 | -------------------------------------------------------------------------------- /test_project/test_project/settings_mysql.sample.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .settings import * 3 | 4 | OPTIONS_FILE = os.path.join(os.environ["HOME"], ".my.cnf") 5 | DBNAME = os.environ.get("MYSQL_DBNAME", "dadv") 6 | 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "django.db.backends.mysql", 10 | "NAME": DBNAME, 11 | "OPTIONS": {"read_default_file": OPTIONS_FILE}, 12 | }, 13 | } 14 | 15 | SECRET_KEY = "django_tests_secret_key" 16 | 17 | # Use a fast hasher to speed up tests. 18 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 19 | 20 | if os.environ.get("ADD_TEST_APP", False): 21 | INSTALLED_APPS.append("dadv.apps.DadvConfig") 22 | -------------------------------------------------------------------------------- /test_project/test_project/settings_pgsql.sample.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from .settings import * 3 | 4 | DBUSER = os.environ.get("PGUSER", os.environ["USER"]) 5 | DBHOST = os.environ.get("PGHOST", "localhost") 6 | DBPORT = None 7 | if not DBHOST.startswith("/"): 8 | DBPORT = os.environ.get("PGPORT", "5432") 9 | DBNAME = os.environ.get("PGDATABASE", "dadv") 10 | 11 | DATABASES = { 12 | "default": { 13 | "ENGINE": "django.db.backends.postgresql", 14 | "HOST": DBHOST, 15 | "PORT": DBPORT, 16 | "NAME": DBNAME, 17 | "USER": DBUSER, 18 | }, 19 | } 20 | 21 | SECRET_KEY = "django_tests_secret_key" 22 | 23 | # Use a fast hasher to speed up tests. 24 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 25 | 26 | if os.environ.get("ADD_TEST_APP", False): 27 | INSTALLED_APPS.append("dadv.apps.DadvConfig") 28 | -------------------------------------------------------------------------------- /test_project/test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.conf.urls import url 3 | 4 | urlpatterns = [url(r"^admin/", admin.site.urls)] 5 | -------------------------------------------------------------------------------- /test_project/test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for tests 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/2.0/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", "test_project.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /test_project/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import io 4 | import os 5 | import unittest 6 | 7 | from django.core.management import call_command 8 | from django.test import TestCase, modify_settings 9 | 10 | settings_module = os.environ["DJANGO_SETTINGS_MODULE"] 11 | 12 | 13 | class MigrateMixin: 14 | @unittest.skipIf( 15 | settings_module != "test_project.settings_pgsql", 16 | "Executing DDL statements while in a transaction on databases that can't perform a " 17 | "rollback is prohibited.", 18 | ) 19 | def test_migrate(self): 20 | """Make sure migrations actually work""" 21 | # with open(os.devnull, "w") as nothing: 22 | # self.assertIsNone(call_command("migrate", "dadv", stdout=nothing)) 23 | try: 24 | call_command("migrate") 25 | except Exception: 26 | self.assertTrue(False, "Migrations failed") 27 | else: 28 | self.assertTrue(True, "Migrations succeded") 29 | 30 | 31 | class CommandOutputMixin: 32 | def get_command_output(self, cmd, *cmd_args, **cmd_options): 33 | file_obj = io.StringIO() 34 | cmd_options.update(stdout=file_obj) 35 | call_command(cmd, *cmd_args, **cmd_options) 36 | output = file_obj.getvalue() 37 | 38 | file_obj.close() 39 | return output 40 | 41 | 42 | class MigrationsTesterBase(MigrateMixin, CommandOutputMixin): 43 | bool_match = "ALTER COLUMN \"is_functional\" SET DEFAULT 'False';" 44 | text_match = ( 45 | 'ALTER TABLE "dadv_testtextdefault" ALTER COLUMN "description" ' 46 | "SET DEFAULT 'No description provided';" 47 | ) 48 | charfield_match = ( 49 | 'ALTER TABLE "dadv_testhappypath" ALTER COLUMN "name" ' 50 | "SET DEFAULT 'Happy path'" 51 | ) 52 | date_match = 'ALTER TABLE "dadv_testhappypath" ALTER COLUMN "dob" SET DEFAULT \'1970-01-01\';' 53 | current_timestamp_match = ( 54 | 'ALTER TABLE "dadv_testhappypath" ALTER COLUMN "rebirth" SET DEFAULT now();' 55 | ) 56 | current_date_match = ( 57 | 'ALTER TABLE "dadv_testhappypath" ALTER COLUMN "married" SET DEFAULT now();' 58 | ) 59 | 60 | custom_column_match = 'ALTER TABLE "dadv_testcustomcolumnname" ALTER COLUMN "custom_field" SET DEFAULT \'False\';' 61 | 62 | def test_bool_default(self): 63 | actual = self.get_command_output("sqlmigrate", "dadv", "0001") 64 | self.assertIn(self.bool_match, actual) 65 | 66 | def test_text_default(self): 67 | """Make sure we can add defaults for text fields""" 68 | actual = self.get_command_output("sqlmigrate", "dadv", "0002") 69 | self.assertIn(self.text_match, actual) 70 | 71 | def test_charfield_default(self): 72 | """Make sure we can add defaults for char fields""" 73 | actual = self.get_command_output("sqlmigrate", "dadv", "0003") 74 | self.assertIn(self.charfield_match, actual) 75 | 76 | def test_default_date(self): 77 | """Make sure temporal values work""" 78 | actual = self.get_command_output("sqlmigrate", "dadv", "0004") 79 | self.assertIn(self.date_match, actual) 80 | 81 | def test_current_timestamp(self): 82 | """Make sure we can provide current timestamps as default""" 83 | actual = self.get_command_output("sqlmigrate", "dadv", "0004") 84 | self.assertIn(self.current_timestamp_match, actual) 85 | 86 | def test_current_date(self): 87 | """Make sure we can provide current dates as default""" 88 | actual = self.get_command_output("sqlmigrate", "dadv", "0004") 89 | self.assertIn(self.current_date_match, actual) 90 | 91 | def test_custom_column_name(self): 92 | """Make sure we can provide current dates as default""" 93 | actual = self.get_command_output("sqlmigrate", "dadv", "0005") 94 | self.assertIn(self.custom_column_match, actual) 95 | 96 | 97 | @unittest.skipUnless( 98 | settings_module == "test_project.settings_pgsql", 99 | "PostgreSQL settings file not selected", 100 | ) 101 | @modify_settings(INSTALLED_APPS={"append": "dadv.apps.DadvConfig"}) 102 | class MigrationsTesterPgSQL(TestCase, MigrationsTesterBase): 103 | pass 104 | 105 | 106 | @unittest.skipUnless( 107 | settings_module == "test_project.settings_crdb", 108 | "CockroachDB settings file not selected", 109 | ) 110 | @modify_settings(INSTALLED_APPS={"append": "dadv.apps.DadvConfig"}) 111 | class MigrationsTesterCRDB(TestCase, MigrationsTesterBase): 112 | pass 113 | 114 | 115 | @unittest.skipUnless( 116 | settings_module == "test_project.settings_mysql", "MySQL settings file not selected" 117 | ) 118 | @modify_settings(INSTALLED_APPS={"append": "dadv.apps.DadvConfig"}) 119 | class MigrationsTesterMySQL(TestCase, MigrationsTesterBase): 120 | bool_match = "ALTER COLUMN `is_functional` SET DEFAULT '0';" 121 | charfield_match = ( 122 | "ALTER TABLE `dadv_testhappypath` ALTER COLUMN `name` SET DEFAULT 'Happy path';" 123 | ) 124 | date_match = ( 125 | "ALTER TABLE `dadv_testhappypath` ALTER COLUMN `dob` SET DEFAULT '1970-01-01';" 126 | ) 127 | current_timestamp_match = ( 128 | "ALTER TABLE `dadv_testhappypath` ALTER COLUMN `rebirth` SET DEFAULT " 129 | "CURRENT_TIMESTAMP;" 130 | ) 131 | 132 | custom_column_match = "ALTER TABLE `dadv_testcustomcolumnname` ALTER COLUMN `custom_field` SET DEFAULT '0';" 133 | 134 | @unittest.expectedFailure 135 | def test_text_default(self): 136 | super(MigrationsTesterMySQL, self).test_text_default() 137 | 138 | @unittest.expectedFailure 139 | def test_current_date(self): 140 | super(MigrationsTesterMySQL, self).test_current_date() 141 | 142 | 143 | @unittest.skipUnless( 144 | settings_module == "test_project.settings_mssql", 145 | "Microsoft SQL Server settings file not selected", 146 | ) 147 | @modify_settings(INSTALLED_APPS={"append": "dadv.apps.DadvConfig"}) 148 | class MigrationsTesterMicrosoftSQL(TestCase, MigrationsTesterBase): 149 | bool_match = ( 150 | "ALTER TABLE [dadv_testbooldefault] " 151 | "ADD CONSTRAINT [DADV_TestBoolDefault_is_functional_DEFAULT] " 152 | "DEFAULT '0' FOR [is_functional];" 153 | ) 154 | charfield_match = ( 155 | "ALTER TABLE [dadv_testhappypath] ADD CONSTRAINT [DADV_TestHappyPath_name_DEFAULT] " 156 | "DEFAULT 'Happy path' FOR [name];" 157 | ) 158 | text_match = ( 159 | "ALTER TABLE [dadv_testtextdefault] ADD CONSTRAINT [" 160 | "DADV_TestTextDefault_description_DEFAULT] DEFAULT 'No description provided' FOR [" 161 | "description];" 162 | ) 163 | date_match = ( 164 | "ALTER TABLE [dadv_testhappypath] " 165 | "ADD CONSTRAINT [DADV_testhappypath_dob_DEFAULT] DEFAULT '1970-01-01' FOR [dob];" 166 | ) 167 | current_date_match = ( 168 | "ALTER TABLE [dadv_testhappypath] " 169 | "ADD CONSTRAINT [DADV_testhappypath_married_DEFAULT] " 170 | "DEFAULT GETDATE() " 171 | "FOR [married];" 172 | ) 173 | current_timestamp_match = ( 174 | "ALTER TABLE [dadv_testhappypath] ADD CONSTRAINT [DADV_testhappypath_rebirth_DEFAULT] " 175 | "DEFAULT GETDATE() FOR [rebirth];" 176 | ) 177 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | format 4 | lint 5 | py{34,35,36,37}-django{11,20,21,22}-{pgsql,mysql} 6 | py{35,36,37}-django21-{pgsql,mysql} 7 | py{35,36,37,38,39}-django22-{pgsql,mysql,crdb} 8 | py{36,37,38,39}-django{30,31,32}-{pgsql,mysql,crdb} 9 | py27-django11-{pgsql,mysql} 10 | 11 | [flake8] 12 | exclude = 13 | __pycache__ 14 | *.pyc 15 | *.pyo 16 | max-complexity = 5 17 | max-line-length = 88 18 | select = C,E,F,W,B,B950 19 | ignore = E501 20 | 21 | [testenv] 22 | changedir = {toxinidir}/test_project 23 | passenv = PGSQL_* MYSQL_* CRDB_* 24 | setenv = 25 | HOME={env:HOME} 26 | USER={env:USER} 27 | pgsql: DJANGO_SETTINGS_MODULE=test_project.settings_pgsql 28 | mysql: DJANGO_SETTINGS_MODULE=test_project.settings_mysql 29 | crdb: DJANGO_SETTINGS_MODULE=test_project.settings_crdb 30 | commands = {envpython} manage.py test tests 31 | 32 | deps = 33 | django11: Django>=1.11,<2 34 | django20: Django>=2.0,<2.0.99 35 | django21: Django>=2.1,<2.1.99 36 | django22: Django>=2.2,<2.2.99 37 | django30: Django>=3.0,<3.0.99 38 | django31: Django>=3.1,<3.1.99 39 | django32: Django>=3.2,<3.2.99 40 | mysql: mysqlclient>=2.0.1 41 | pgsql,crdb: psycopg2-binary 42 | django22-crdb: django-cockroachdb>=2.2,<2.2.99 43 | django30-crdb: django-cockroachdb>=3.0,<3.0.99 44 | django31-crdb: django-cockroachdb>=3.1,<3.1.99 45 | django32-crdb: django-cockroachdb>=3.2,<3.2.99 46 | 47 | [testenv:lint] 48 | deps = flake8 49 | changedir = {toxinidir} 50 | commands = flake8 django_add_default_value 51 | 52 | 53 | [testenv:format] 54 | deps = black 55 | changedir = {toxinidir} 56 | commands = 57 | - black django_add_default_value 58 | - black test_project/tests 59 | - black test_project/dadv 60 | 61 | 62 | --------------------------------------------------------------------------------