├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── django_db_constraints ├── __init__.py ├── apps.py ├── autodetector.py └── operations.py └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 David Sanders 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: dist deploy clean 2 | 3 | dist: 4 | python setup.py sdist 5 | python setup.py bdist_wheel 6 | 7 | deploy: dist 8 | twine upload dist/* 9 | 10 | clean: 11 | rm -rf dist/ build/ django_db_constraints.egg-info/ 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-db-constraints 2 | 3 | ## What is this? 4 | 5 | Add database table-level constraints to your Django model's Meta class and have `makemigrations` add the appropriate migration. 6 | 7 | ```python 8 | class Foo(models.Model): 9 | bar = models.IntegerField() 10 | baz = models.IntegerField() 11 | 12 | class Meta: 13 | db_constraints = { 14 | 'bar_equal_baz': 'check (bar = baz)', 15 | } 16 | ``` 17 | 18 | This should generate a migration like so: 19 | 20 | ```python 21 | class Migration(migrations.Migration): 22 | 23 | initial = True 24 | 25 | dependencies = [ 26 | ] 27 | 28 | operations = [ 29 | migrations.CreateModel( 30 | name='Foo', 31 | fields=[ 32 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 33 | ('bar', models.IntegerField()), 34 | ('baz', models.IntegerField()), 35 | ], 36 | ), 37 | django_db_constraints.operations.AlterConstraints( 38 | name='Foo', 39 | db_constraints={'bar_equal_baz': 'check (bar = baz)'}, 40 | ), 41 | ] 42 | ``` 43 | 44 | The resulting SQL applied: 45 | 46 | ```sql 47 | CREATE TABLE "sample_foo" ("id" serial NOT NULL PRIMARY KEY, "bar" integer NOT NULL, "baz" integer NOT NULL) 48 | ALTER TABLE "sample_foo" ADD CONSTRAINT "bar_equal_baz" check (bar = baz) 49 | ``` 50 | 51 | ## Composite foreign keys 52 | 53 | It's possible to support composite foreign keys if you have a unique key on your reference model: 54 | 55 | ([Why are composite foreign keys useful?](https://github.com/rapilabs/blog/blob/master/articles/same-parent-db-pattern.md)) 56 | 57 | ```python 58 | class Bar(models.Model): 59 | baz = models.IntegerField() 60 | 61 | class Meta: 62 | unique_together = ('id', 'baz') 63 | 64 | 65 | class Foo(models.Model): 66 | bar = models.ForeignKey(Bar) 67 | baz = models.IntegerField() 68 | 69 | class Meta: 70 | db_constraints = { 71 | 'composite_fk': 'foreign key (bar_id, baz) references sample_bar (id, baz)', 72 | } 73 | ``` 74 | 75 | Results in: 76 | 77 | ```sql 78 | ALTER TABLE "sample_foo" ADD CONSTRAINT "composite_fk" foreign key (bar_id, baz) references sample_bar (id, baz) 79 | ``` 80 | 81 | ## Notes 82 | 83 | ### Migration operation ordering 84 | 85 | Given that nothing will depend on a constraint operation, they're simply added to the end of the list of operations 86 | for a migration. This includes operations that drop fields used in a constraint as the database drop will any related 87 | constraints as well (at least with PostgreSQL). 88 | 89 | ### Caveats 90 | 91 | It's possible to end up in a situation where the constraints are declared on the Meta class but do not exist in the database 92 | due to a database dropping a constraint implicitly when a field in the constraint is dropped. 93 | 94 | ### Please test your constraints! 95 | 96 | I encourage folks to write tests for their constraints to ensure they write are actually applied in the database. 97 | 98 | ### Acknowledgements 99 | 100 | Thanks to @schinckel and @MarkusH for their advice and ideas. 101 | 102 | ## Installation 103 | 104 | ``` 105 | pip install django-db-constraints 106 | ``` 107 | 108 | in your settings.py: 109 | 110 | ```python 111 | INSTALLED_APPS = [ 112 | 'django_db_constraints', 113 | … 114 | ] 115 | ``` 116 | -------------------------------------------------------------------------------- /django_db_constraints/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'django_db_constraints.apps.DjangoDbConstraintsConfig' 2 | -------------------------------------------------------------------------------- /django_db_constraints/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.db.migrations import state 3 | from django.db.models import options 4 | 5 | options.DEFAULT_NAMES = options.DEFAULT_NAMES + ('db_constraints',) 6 | state.DEFAULT_NAMES = options.DEFAULT_NAMES 7 | 8 | 9 | class DjangoDbConstraintsConfig(AppConfig): 10 | name = 'django_db_constraints' 11 | 12 | def ready(self): 13 | from django.core.management.commands import makemigrations, migrate # noqa 14 | from .autodetector import MigrationAutodetectorWithDbConstraints # noqa 15 | 16 | makemigrations.MigrationAutodetector = MigrationAutodetectorWithDbConstraints 17 | migrate.MigrationAutodetector = MigrationAutodetectorWithDbConstraints 18 | -------------------------------------------------------------------------------- /django_db_constraints/autodetector.py: -------------------------------------------------------------------------------- 1 | from django.db.migrations import operations 2 | from django.db.migrations.autodetector import MigrationAutodetector 3 | 4 | from .operations import AlterConstraints 5 | 6 | 7 | class MigrationAutodetectorWithDbConstraints(MigrationAutodetector): 8 | db_constraints_operations = [] 9 | 10 | def generate_created_models(self, *args, **kwargs): 11 | rv = super().generate_created_models(*args, **kwargs) 12 | for (app_label, migration_operations) in self.generated_operations.items(): 13 | for operation in migration_operations: 14 | if isinstance(operation, operations.CreateModel) and 'db_constraints' in operation.options: 15 | db_constraints = operation.options.pop('db_constraints') 16 | self.db_constraints_operations.append(( 17 | app_label, 18 | AlterConstraints(name=operation.name, db_constraints=db_constraints), 19 | )) 20 | return rv 21 | 22 | def generate_altered_unique_together(self, *args, **kwargs): 23 | rv = super().generate_altered_unique_together(*args, **kwargs) 24 | 25 | for app_label, model_name in sorted(self.kept_model_keys): 26 | old_model_name = self.renamed_models.get((app_label, model_name), model_name) 27 | old_model_state = self.from_state.models[app_label, old_model_name] 28 | new_model_state = self.to_state.models[app_label, model_name] 29 | 30 | old_value = old_model_state.options.get('db_constraints', {}) 31 | new_value = new_model_state.options.get('db_constraints', {}) 32 | if old_value != new_value: 33 | self.db_constraints_operations.append(( 34 | app_label, 35 | AlterConstraints( 36 | name=model_name, 37 | db_constraints=new_value, 38 | ), 39 | )) 40 | 41 | return rv 42 | 43 | def _sort_migrations(self, *args, **kwargs): 44 | rv = super()._sort_migrations() 45 | for app_label, operation in self.db_constraints_operations: 46 | self.generated_operations.setdefault(app_label, []).append(operation) 47 | return rv 48 | -------------------------------------------------------------------------------- /django_db_constraints/operations.py: -------------------------------------------------------------------------------- 1 | from django.db.migrations.operations.models import ModelOptionOperation 2 | 3 | 4 | class AlterConstraints(ModelOptionOperation): 5 | option_name = 'db_constraints' 6 | reduces_to_sql = True 7 | reversible = True 8 | 9 | # xxx 10 | _auto_deps = [] 11 | 12 | def __init__(self, name, db_constraints): 13 | self.db_constraints = db_constraints 14 | super().__init__(name) 15 | 16 | def state_forwards(self, app_label, state): 17 | model_state = state.models[app_label, self.name_lower] 18 | model_state.options[self.option_name] = self.db_constraints 19 | state.reload_model(app_label, self.name_lower, delay=True) 20 | 21 | def database_forwards(self, app_label, schema_editor, from_state, to_state): 22 | to_model = to_state.apps.get_model(app_label, self.name) 23 | 24 | if self.allow_migrate_model(schema_editor.connection.alias, to_model): 25 | from_model = from_state.apps.get_model(app_label, self.name) 26 | 27 | to_constraints = getattr(to_model._meta, self.option_name, {}).keys() 28 | from_constraints = getattr(from_model._meta, self.option_name, {}).keys() 29 | 30 | table_operations = tuple( 31 | 'DROP CONSTRAINT IF EXISTS {name}'.format( 32 | name=schema_editor.connection.ops.quote_name(constraint_name), 33 | ) 34 | for constraint_name in from_constraints - to_constraints 35 | ) + tuple( 36 | 'ADD CONSTRAINT {name} {constraint}'.format( 37 | name=schema_editor.connection.ops.quote_name(constraint_name), 38 | constraint=to_model._meta.db_constraints[constraint_name], 39 | ) 40 | for constraint_name in to_constraints - from_constraints 41 | ) + tuple( 42 | 'DROP CONSTRAINT IF EXISTS {name}, ADD CONSTRAINT {name} {constraint}'.format( 43 | name=schema_editor.connection.ops.quote_name(constraint_name), 44 | constraint=to_model._meta.db_constraints[constraint_name], 45 | ) 46 | for constraint_name in to_constraints & from_constraints 47 | if to_model._meta.db_constraints[constraint_name] != from_model._meta.db_constraints[constraint_name] 48 | ) 49 | 50 | if table_operations: 51 | schema_editor.execute('ALTER TABLE {table} {table_operations}'.format( 52 | table=schema_editor.connection.ops.quote_name(to_model._meta.db_table), 53 | table_operations=', '.join(table_operations), 54 | )) 55 | 56 | def database_backwards(self, app_label, schema_editor, from_state, to_state): 57 | return self.database_forwards(app_label, schema_editor, from_state, to_state) 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | def readme(): 4 | with open('README.md') as f: 5 | return f.read() 6 | 7 | setup( 8 | name='django-db-constraints', 9 | version='0.3.0', 10 | author='shangxiao', 11 | description='Add database table-level constraints to your Django model\'s Meta', 12 | long_description=readme(), 13 | url='https://github.com/rapilabs/django-db-constraints', 14 | license='MIT', 15 | packages=find_packages(), 16 | install_requires=('django',), 17 | ) 18 | --------------------------------------------------------------------------------