Skip to content

Commit

Permalink
Fixed #26709 -- Added class-based indexes.
Browse files Browse the repository at this point in the history
Added the AddIndex and RemoveIndex operations to use them in migrations.

Thanks markush, mjtamlyn, timgraham, and charettes for review and advice.
  • Loading branch information
akki authored and timgraham committed Jun 27, 2016
1 parent c962b91 commit 156e2d5
Show file tree
Hide file tree
Showing 18 changed files with 473 additions and 20 deletions.
27 changes: 21 additions & 6 deletions django/db/backends/base/schema.py
Expand Up @@ -316,6 +316,18 @@ def delete_model(self, model):
"table": self.quote_name(model._meta.db_table),
})

def add_index(self, index):
"""
Add an index on a model.
"""
self.execute(index.create_sql(self))

def remove_index(self, index):
"""
Remove an index from a model.
"""
self.execute(index.remove_sql(self))

def alter_unique_together(self, model, old_unique_together, new_unique_together):
"""
Deals with a model changing its unique_together.
Expand Down Expand Up @@ -836,12 +848,7 @@ def _create_index_name(self, model, column_names, suffix=""):
index_name = "D%s" % index_name[:-1]
return index_name

def _create_index_sql(self, model, fields, suffix="", sql=None):
"""
Return the SQL statement to create the index for one or several fields.
`sql` can be specified if the syntax differs from the standard (GIS
indexes, ...).
"""
def _get_index_tablespace_sql(self, model, fields):
if len(fields) == 1 and fields[0].db_tablespace:
tablespace_sql = self.connection.ops.tablespace_sql(fields[0].db_tablespace)
elif model._meta.db_tablespace:
Expand All @@ -850,7 +857,15 @@ def _create_index_sql(self, model, fields, suffix="", sql=None):
tablespace_sql = ""
if tablespace_sql:
tablespace_sql = " " + tablespace_sql
return tablespace_sql

def _create_index_sql(self, model, fields, suffix="", sql=None):
"""
Return the SQL statement to create the index for one or several fields.
`sql` can be specified if the syntax differs from the standard (GIS
indexes, ...).
"""
tablespace_sql = self._get_index_tablespace_sql(model, fields)
columns = [field.column for field in fields]
sql_create_index = sql or self.sql_create_index
return sql_create_index % {
Expand Down
10 changes: 5 additions & 5 deletions django/db/migrations/operations/__init__.py
@@ -1,15 +1,15 @@
from .fields import AddField, AlterField, RemoveField, RenameField
from .models import (
AlterIndexTogether, AlterModelManagers, AlterModelOptions, AlterModelTable,
AlterOrderWithRespectTo, AlterUniqueTogether, CreateModel, DeleteModel,
RenameModel,
AddIndex, AlterIndexTogether, AlterModelManagers, AlterModelOptions,
AlterModelTable, AlterOrderWithRespectTo, AlterUniqueTogether, CreateModel,
DeleteModel, RemoveIndex, RenameModel,
)
from .special import RunPython, RunSQL, SeparateDatabaseAndState

__all__ = [
'CreateModel', 'DeleteModel', 'AlterModelTable', 'AlterUniqueTogether',
'RenameModel', 'AlterIndexTogether', 'AlterModelOptions',
'AddField', 'RemoveField', 'AlterField', 'RenameField',
'RenameModel', 'AlterIndexTogether', 'AlterModelOptions', 'AddIndex',
'RemoveIndex', 'AddField', 'RemoveField', 'AlterField', 'RenameField',
'SeparateDatabaseAndState', 'RunSQL', 'RunPython',
'AlterOrderWithRespectTo', 'AlterModelManagers',
]
77 changes: 77 additions & 0 deletions django/db/migrations/operations/models.py
Expand Up @@ -742,3 +742,80 @@ def database_backwards(self, app_label, schema_editor, from_state, to_state):

def describe(self):
return "Change managers on %s" % (self.name, )


class AddIndex(Operation):
"""
Add an index on a model.
"""

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

def state_forwards(self, app_label, state):
model_state = state.models[app_label, self.model_name.lower()]
self.index.model = state.apps.get_model(app_label, self.model_name)
model_state.options['indexes'].append(self.index)

def database_forwards(self, app_label, schema_editor, from_state, to_state):
schema_editor.add_index(self.index)

def database_backwards(self, app_label, schema_editor, from_state, to_state):
schema_editor.remove_index(self.index)

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

def describe(self):
return 'Create index on field(s) %s of model %s' % (
', '.join(self.index.fields),
self.model_name,
)


class RemoveIndex(Operation):
"""
Remove an index from a model.
"""

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()]
indexes = model_state.options['indexes']
model_state.options['indexes'] = [idx for idx in indexes if idx.name != self.name]

def database_forwards(self, app_label, schema_editor, from_state, to_state):
from_model_state = from_state.models[app_label, self.model_name.lower()]
index = from_model_state.get_index_by_name(self.name)
schema_editor.remove_index(index)

def database_backwards(self, app_label, schema_editor, from_state, to_state):
to_model_state = to_state.models[app_label, self.model_name.lower()]
index = to_model_state.get_index_by_name(self.name)
schema_editor.add_index(index)

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

def describe(self):
return 'Remove index %s from %s' % (self.name, self.model_name)
7 changes: 7 additions & 0 deletions django/db/migrations/state.py
Expand Up @@ -330,6 +330,7 @@ def __init__(self, app_label, name, fields, options=None, bases=None, managers=N
self.name = force_text(name)
self.fields = fields
self.options = options or {}
self.options.setdefault('indexes', [])
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 @@ -557,6 +558,12 @@ def get_field_by_name(self, name):
return field
raise ValueError("No field called %s on model %s" % (name, self.name))

def get_index_by_name(self, name):
for index in self.options['indexes']:
if index.name == name:
return index
raise ValueError("No index named %s on model %s" % (name, self.name))

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

Expand Down
1 change: 1 addition & 0 deletions django/db/models/__init__.py
Expand Up @@ -12,6 +12,7 @@
from django.db.models.fields import * # NOQA
from django.db.models.fields.files import FileField, ImageField # NOQA
from django.db.models.fields.proxy import OrderWrt # NOQA
from django.db.models.indexes import * # NOQA
from django.db.models.lookups import Lookup, Transform # NOQA
from django.db.models.manager import Manager # NOQA
from django.db.models.query import ( # NOQA
Expand Down
113 changes: 113 additions & 0 deletions django/db/models/indexes.py
@@ -0,0 +1,113 @@
from __future__ import unicode_literals

import hashlib

from django.utils.encoding import force_bytes
from django.utils.functional import cached_property

__all__ = ['Index']

# The max length of the names of the indexes (restricted to 30 due to Oracle)
MAX_NAME_LENGTH = 30


class Index(object):
suffix = 'idx'

def __init__(self, fields=[], name=None):
if not fields:
raise ValueError('At least one field is required to define an index.')
self.fields = fields
self._name = name or ''
if self._name:
errors = self.check_name()
if len(self._name) > MAX_NAME_LENGTH:
errors.append('Index names cannot be longer than %s characters.' % MAX_NAME_LENGTH)
if errors:
raise ValueError(errors)

@cached_property
def name(self):
if not self._name:
self._name = self.get_name()
self.check_name()
return self._name

def check_name(self):
errors = []
# Name can't start with an underscore on Oracle; prepend D if needed.
if self._name[0] == '_':
errors.append('Index names cannot start with an underscore (_).')
self._name = 'D%s' % self._name[1:]
# Name can't start with a number on Oracle; prepend D if needed.
elif self._name[0].isdigit():
errors.append('Index names cannot start with a number (0-9).')
self._name = 'D%s' % self._name[1:]
return errors

def create_sql(self, schema_editor):
fields = [self.model._meta.get_field(field) for field in self.fields]
tablespace_sql = schema_editor._get_index_tablespace_sql(self.model, fields)
columns = [field.column for field in fields]

quote_name = schema_editor.quote_name
return schema_editor.sql_create_index % {
'table': quote_name(self.model._meta.db_table),
'name': quote_name(self.name),
'columns': ', '.join(quote_name(column) for column in columns),
'extra': tablespace_sql,
}

def remove_sql(self, schema_editor):
quote_name = schema_editor.quote_name
return schema_editor.sql_delete_index % {
'table': quote_name(self.model._meta.db_table),
'name': quote_name(self.name),
}

def deconstruct(self):
path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__)
path = path.replace('django.db.models.indexes', 'django.db.models')
return (path, (), {'fields': self.fields})

@staticmethod
def _hash_generator(*args):
"""
Generate a 32-bit digest of a set of arguments that can be used to
shorten identifying names.
"""
h = hashlib.md5()
for arg in args:
h.update(force_bytes(arg))
return h.hexdigest()[:6]

def get_name(self):
"""
Generate a unique name for the index.
The name is divided into 3 parts - table name (12 chars), field name
(8 chars) and unique hash + suffix (10 chars). Each part is made to
fit its size by truncating the excess length.
"""
table_name = self.model._meta.db_table
column_names = [self.model._meta.get_field(field).column for field in self.fields]
hash_data = [table_name] + column_names + [self.suffix]
index_name = '%s_%s_%s' % (
table_name[:11],
column_names[0][:7],
'%s_%s' % (self._hash_generator(*hash_data), self.suffix),
)
assert len(index_name) <= 30, (
'Index too long for multiple database support. Is self.suffix '
'longer than 3 characters?'
)
return index_name

def __repr__(self):
return "<%s: fields='%s'>" % (self.__class__.__name__, ', '.join(self.fields))

def __eq__(self, other):
return (self.__class__ == other.__class__) and (self.deconstruct() == other.deconstruct())

def __ne__(self, other):
return not (self == other)
2 changes: 1 addition & 1 deletion django/db/models/options.py
Expand Up @@ -43,7 +43,7 @@
'auto_created', 'index_together', 'apps', 'default_permissions',
'select_on_save', 'default_related_name', 'required_db_features',
'required_db_vendor', 'base_manager_name', 'default_manager_name',
'manager_inheritance_from_future',
'manager_inheritance_from_future', 'indexes',
)


Expand Down
1 change: 1 addition & 0 deletions docs/index.txt
Expand Up @@ -82,6 +82,7 @@ manipulating the data of your Web application. Learn more about it below:
* **Models:**
:doc:`Introduction to models <topics/db/models>` |
:doc:`Field types <ref/models/fields>` |
:doc:`Indexes <ref/models/indexes>` |
:doc:`Meta options <ref/models/options>` |
:doc:`Model class <ref/models/class>`

Expand Down
37 changes: 37 additions & 0 deletions docs/ref/migration-operations.txt
Expand Up @@ -192,6 +192,43 @@ field like ``models.IntegerField()`` on most databases.
Changes a field's name (and, unless :attr:`~django.db.models.Field.db_column`
is set, its column name).

``AddIndex``
------------

.. class:: AddIndex(model_name, index)

.. versionadded:: 1.11

Creates an index in the database table for the model with ``model_name``.
``index`` is an instance of the :class:`~django.db.models.Index` class.

For example, to add an index on the ``title`` and ``author`` fields of the
``Book`` model::

from django.db import migrations, models

class Migration(migrations.Migration):
operations = [
migrations.AddIndex(
'Book',
models.Index(fields=['title', 'author'], name='my_index_name'),
),
]

If you're writing your own migration to add an index, it's recommended to pass
a ``name`` to the ``index`` as done above so that you can reference it if you
later want to remove it. Otherwise, a name will be autogenerated and you'll
have to inspect the database to find the index name if you want to remove it.

``RemoveIndex``
---------------

.. class:: RemoveIndex(model_name, name)

.. versionadded:: 1.11

Removes the index named ``name`` from the model with ``model_name``.

Special Operations
==================

Expand Down
1 change: 1 addition & 0 deletions docs/ref/models/index.txt
Expand Up @@ -8,6 +8,7 @@ Model API reference. For introductory material, see :doc:`/topics/db/models`.
:maxdepth: 1

fields
indexes
meta
relations
class
Expand Down

0 comments on commit 156e2d5

Please sign in to comment.