Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions django/db/backends/base/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ class BaseDatabaseFeatures:

# Does it support CHECK constraints?
supports_column_check_constraints = True
supports_table_check_constraints = True

# Does the backend support 'pyformat' style ("... %(name)s ...", {'name': value})
# parameter passing? Note this can be provided by the backend even if not
Expand Down
25 changes: 18 additions & 7 deletions django/db/backends/base/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ class BaseDatabaseSchemaEditor:
sql_rename_column = "ALTER TABLE %(table)s RENAME COLUMN %(old_column)s TO %(new_column)s"
sql_update_with_default = "UPDATE %(table)s SET %(column)s = %(default)s WHERE %(column)s IS NULL"

sql_create_check = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s CHECK (%(check)s)"
sql_check = "CONSTRAINT %(name)s CHECK (%(check)s)"
sql_create_check = "ALTER TABLE %(table)s ADD %(check)s"
sql_delete_check = "ALTER TABLE %(table)s DROP CONSTRAINT %(name)s"

sql_create_unique = "ALTER TABLE %(table)s ADD CONSTRAINT %(name)s UNIQUE (%(columns)s)"
Expand Down Expand Up @@ -299,10 +300,11 @@ def create_model(self, model):
for fields in model._meta.unique_together:
columns = [model._meta.get_field(field).column for field in fields]
self.deferred_sql.append(self._create_unique_sql(model, columns))
constraints = [check.constraint_sql(model, self) for check in model._meta.constraints]
# Make the table
sql = self.sql_create_table % {
"table": self.quote_name(model._meta.db_table),
"definition": ", ".join(column_sqls)
"definition": ", ".join((*column_sqls, *constraints)),
}
if model._meta.db_tablespace:
tablespace_sql = self.connection.ops.tablespace_sql(model._meta.db_tablespace)
Expand Down Expand Up @@ -343,6 +345,14 @@ def remove_index(self, model, index):
"""Remove an index from a model."""
self.execute(index.remove_sql(model, self))

def add_constraint(self, model, constraint):
"""Add a check constraint to a model."""
self.execute(constraint.create_sql(model, self))

def remove_constraint(self, model, constraint):
"""Remove a check constraint from a model."""
self.execute(constraint.remove_sql(model, self))

def alter_unique_together(self, model, old_unique_together, new_unique_together):
"""
Deal with a model changing its unique_together. The input
Expand Down Expand Up @@ -752,11 +762,12 @@ def _alter_field(self, model, old_field, new_field, old_type, new_type,
self.execute(
self.sql_create_check % {
"table": self.quote_name(model._meta.db_table),
"name": self.quote_name(
self._create_index_name(model._meta.db_table, [new_field.column], suffix="_check")
),
"column": self.quote_name(new_field.column),
"check": new_db_params['check'],
"check": self.sql_check % {
'name': self.quote_name(
self._create_index_name(model._meta.db_table, [new_field.column], suffix='_check'),
),
'check': new_db_params['check'],
},
}
)
# Drop the default if we need to
Expand Down
1 change: 1 addition & 0 deletions django/db/backends/mysql/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ class DatabaseFeatures(BaseDatabaseFeatures):
can_release_savepoints = True
atomic_transactions = False
supports_column_check_constraints = False
supports_table_check_constraints = False
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, so MariaDB does support them... I guess this'll help push me to get first class MariaDB support into Django after this gets merged.

Also there are some wonderful hacks to make them work as blogged by the MySQL team here: http://mysqlserverteam.com/new-and-old-ways-to-emulate-check-constraints-domain/ . I think the generated column method could potentially be used by Django, it means making very different SQL on MySQL though. Not sure if it's worth it, at least initially.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think it's not really worth it.

can_clone_databases = True
supports_temporal_subtraction = True
supports_select_intersection = False
Expand Down
19 changes: 18 additions & 1 deletion django/db/backends/sqlite3/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ def alter_field(self, model, old_field, new_field, strict=False):
else:
super().alter_field(model, old_field, new_field, strict=strict)

def _remake_table(self, model, create_field=None, delete_field=None, alter_field=None):
def _remake_table(self, model, create_field=None, delete_field=None, alter_field=None,
add_constraint=None, remove_constraint=None):
"""
Shortcut to transform a model from old_model into new_model

Expand Down Expand Up @@ -222,13 +223,23 @@ def is_self_referential(f):
if delete_field.name not in index.fields
]

constraints = list(model._meta.constraints)
if add_constraint:
constraints.append(add_constraint)
if remove_constraint:
constraints = [
constraint for constraint in constraints
if remove_constraint.name != constraint.name
]

# Construct a new model for the new state
meta_contents = {
'app_label': model._meta.app_label,
'db_table': model._meta.db_table,
'unique_together': unique_together,
'index_together': index_together,
'indexes': indexes,
'constraints': constraints,
'apps': apps,
}
meta = type("Meta", (), meta_contents)
Expand Down Expand Up @@ -362,3 +373,9 @@ def _alter_many_to_many(self, model, old_field, new_field, strict):
))
# Delete the old through table
self.delete_model(old_field.remote_field.through)

def add_constraint(self, model, constraint):
self._remake_table(model, add_constraint=constraint)

def remove_constraint(self, model, constraint):
self._remake_table(model, remove_constraint=constraint)
54 changes: 54 additions & 0 deletions django/db/migrations/autodetector.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def _detect_changes(self, convert_apps=None, graph=None):
# resolve dependencies caused by M2Ms and FKs.
self.generated_operations = {}
self.altered_indexes = {}
self.altered_constraints = {}

# Prepare some old/new state and model lists, separating
# proxy models and ignoring unmigrated apps.
Expand Down Expand Up @@ -175,7 +176,9 @@ def _detect_changes(self, convert_apps=None, graph=None):
# This avoids the same computation in generate_removed_indexes()
# and generate_added_indexes().
self.create_altered_indexes()
self.create_altered_constraints()
# Generate index removal operations before field is removed
self.generate_removed_constraints()
self.generate_removed_indexes()
# Generate field operations
self.generate_renamed_fields()
Expand All @@ -185,6 +188,7 @@ def _detect_changes(self, convert_apps=None, graph=None):
self.generate_altered_unique_together()
self.generate_altered_index_together()
self.generate_added_indexes()
self.generate_added_constraints()
self.generate_altered_db_table()
self.generate_altered_order_with_respect_to()

Expand Down Expand Up @@ -533,6 +537,7 @@ def generate_created_models(self):
related_fields[field.name] = field
# Are there indexes/unique|index_together to defer?
indexes = model_state.options.pop('indexes')
constraints = model_state.options.pop('constraints')
unique_together = model_state.options.pop('unique_together', None)
index_together = model_state.options.pop('index_together', None)
order_with_respect_to = model_state.options.pop('order_with_respect_to', None)
Expand Down Expand Up @@ -601,6 +606,15 @@ def generate_created_models(self):
),
dependencies=related_dependencies,
)
for constraint in constraints:
self.add_operation(
app_label,
operations.AddConstraint(
model_name=model_name,
constraint=constraint,
),
dependencies=related_dependencies,
)
if unique_together:
self.add_operation(
app_label,
Expand Down Expand Up @@ -997,6 +1011,46 @@ def generate_removed_indexes(self):
)
)

def create_altered_constraints(self):
option_name = operations.AddConstraint.option_name
for app_label, model_name in sorted(self.kept_model_keys):
old_model_name = self.renamed_models.get((app_label, model_name), model_name)
old_model_state = self.from_state.models[app_label, old_model_name]
new_model_state = self.to_state.models[app_label, model_name]

old_constraints = old_model_state.options[option_name]
new_constraints = new_model_state.options[option_name]
add_constraints = [c for c in new_constraints if c not in old_constraints]
rem_constraints = [c for c in old_constraints if c not in new_constraints]

self.altered_constraints.update({
(app_label, model_name): {
'added_constraints': add_constraints, 'removed_constraints': rem_constraints,
}
})

def generate_added_constraints(self):
for (app_label, model_name), alt_constraints in self.altered_constraints.items():
for constraint in alt_constraints['added_constraints']:
self.add_operation(
app_label,
operations.AddConstraint(
model_name=model_name,
constraint=constraint,
)
)

def generate_removed_constraints(self):
for (app_label, model_name), alt_constraints in self.altered_constraints.items():
for constraint in alt_constraints['removed_constraints']:
self.add_operation(
app_label,
operations.RemoveConstraint(
model_name=model_name,
name=constraint.name,
)
)

def _get_dependencies_for_foreign_key(self, field):
# Account for FKs to swappable models
swappable_setting = getattr(field, 'swappable_setting', None)
Expand Down
8 changes: 5 additions & 3 deletions django/db/migrations/operations/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
from .fields import AddField, AlterField, RemoveField, RenameField
from .models import (
AddIndex, AlterIndexTogether, AlterModelManagers, AlterModelOptions,
AlterModelTable, AlterOrderWithRespectTo, AlterUniqueTogether, CreateModel,
DeleteModel, RemoveIndex, RenameModel,
AddConstraint, AddIndex, AlterIndexTogether, AlterModelManagers,
AlterModelOptions, AlterModelTable, AlterOrderWithRespectTo,
AlterUniqueTogether, CreateModel, DeleteModel, RemoveConstraint,
RemoveIndex, RenameModel,
)
from .special import RunPython, RunSQL, SeparateDatabaseAndState

__all__ = [
'CreateModel', 'DeleteModel', 'AlterModelTable', 'AlterUniqueTogether',
'RenameModel', 'AlterIndexTogether', 'AlterModelOptions', 'AddIndex',
'RemoveIndex', 'AddField', 'RemoveField', 'AlterField', 'RenameField',
'AddConstraint', 'RemoveConstraint',
'SeparateDatabaseAndState', 'RunSQL', 'RunPython',
'AlterOrderWithRespectTo', 'AlterModelManagers',
]
69 changes: 69 additions & 0 deletions django/db/migrations/operations/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -822,3 +822,72 @@ def deconstruct(self):

def describe(self):
return 'Remove index %s from %s' % (self.name, self.model_name)


class AddConstraint(IndexOperation):
option_name = 'constraints'

def __init__(self, model_name, constraint):
self.model_name = model_name
self.constraint = constraint

def state_forwards(self, app_label, state):
model_state = state.models[app_label, self.model_name_lower]
constraints = list(model_state.options[self.option_name])
constraints.append(self.constraint)
model_state.options[self.option_name] = constraints

def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.model_name)
if self.allow_migrate_model(schema_editor.connection.alias, model):
schema_editor.add_constraint(model, self.constraint)

def database_backwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.model_name)
if self.allow_migrate_model(schema_editor.connection.alias, model):
schema_editor.remove_constraint(model, self.constraint)

def deconstruct(self):
return self.__class__.__name__, [], {
'model_name': self.model_name,
'constraint': self.constraint,
}

def describe(self):
return 'Create constraint %s on model %s' % (self.constraint.name, self.model_name)


class RemoveConstraint(IndexOperation):
option_name = 'constraints'

def __init__(self, model_name, name):
self.model_name = model_name
self.name = name

def state_forwards(self, app_label, state):
model_state = state.models[app_label, self.model_name_lower]
constraints = model_state.options[self.option_name]
model_state.options[self.option_name] = [c for c in constraints if c.name != self.name]

def database_forwards(self, app_label, schema_editor, from_state, to_state):
model = from_state.apps.get_model(app_label, self.model_name)
if self.allow_migrate_model(schema_editor.connection.alias, model):
from_model_state = from_state.models[app_label, self.model_name_lower]
constraint = from_model_state.get_constraint_by_name(self.name)
schema_editor.remove_constraint(model, constraint)

def database_backwards(self, app_label, schema_editor, from_state, to_state):
model = to_state.apps.get_model(app_label, self.model_name)
if self.allow_migrate_model(schema_editor.connection.alias, model):
to_model_state = to_state.models[app_label, self.model_name_lower]
constraint = to_model_state.get_constraint_by_name(self.name)
schema_editor.add_constraint(model, constraint)

def deconstruct(self):
return self.__class__.__name__, [], {
'model_name': self.model_name,
'name': self.name,
}

def describe(self):
return 'Remove constraint %s from model %s' % (self.name, self.model_name)
9 changes: 9 additions & 0 deletions django/db/migrations/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ def __init__(self, app_label, name, fields, options=None, bases=None, managers=N
self.fields = fields
self.options = options or {}
self.options.setdefault('indexes', [])
self.options.setdefault('constraints', [])
self.bases = bases or (models.Model,)
self.managers = managers or []
# Sanity-check that fields is NOT a dict. It must be ordered.
Expand Down Expand Up @@ -445,6 +446,8 @@ def from_model(cls, model, exclude_rels=False):
if not index.name:
index.set_name_with_model(model)
options['indexes'] = indexes
elif name == 'constraints':
options['constraints'] = [con.clone() for con in model._meta.constraints]
else:
options[name] = model._meta.original_attrs[name]
# If we're ignoring relationships, remove all field-listing model
Expand Down Expand Up @@ -585,6 +588,12 @@ def get_index_by_name(self, name):
return index
raise ValueError("No index named %s on model %s" % (name, self.name))

def get_constraint_by_name(self, name):
for constraint in self.options['constraints']:
if constraint.name == name:
return constraint
raise ValueError('No constraint named %s on model %s' % (name, self.name))

def __repr__(self):
return "<%s: '%s.%s'>" % (self.__class__.__name__, self.app_label, self.name)

Expand Down
4 changes: 3 additions & 1 deletion django/db/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from django.db.models import signals
from django.db.models.aggregates import * # NOQA
from django.db.models.aggregates import __all__ as aggregates_all
from django.db.models.constraints import * # NOQA
from django.db.models.constraints import __all__ as constraints_all
from django.db.models.deletion import (
CASCADE, DO_NOTHING, PROTECT, SET, SET_DEFAULT, SET_NULL, ProtectedError,
)
Expand Down Expand Up @@ -30,7 +32,7 @@
)


__all__ = aggregates_all + fields_all + indexes_all
__all__ = aggregates_all + constraints_all + fields_all + indexes_all
__all__ += [
'ObjectDoesNotExist', 'signals',
'CASCADE', 'DO_NOTHING', 'PROTECT', 'SET', 'SET_DEFAULT', 'SET_NULL',
Expand Down
Loading