diff --git a/django/contrib/gis/db/backends/mysql/base.py b/django/contrib/gis/db/backends/mysql/base.py index 25ca0da204..d237efc114 100644 --- a/django/contrib/gis/db/backends/mysql/base.py +++ b/django/contrib/gis/db/backends/mysql/base.py @@ -6,6 +6,7 @@ from django.contrib.gis.db.backends.base import BaseSpatialFeatures from django.contrib.gis.db.backends.mysql.creation import MySQLCreation from django.contrib.gis.db.backends.mysql.introspection import MySQLIntrospection from django.contrib.gis.db.backends.mysql.operations import MySQLOperations +from django.contrib.gis.db.backends.mysql.schema import MySQLGISSchemaEditor class DatabaseFeatures(BaseSpatialFeatures, MySQLDatabaseFeatures): @@ -25,3 +26,7 @@ class DatabaseWrapper(MySQLDatabaseWrapper): self.creation = MySQLCreation(self) self.ops = MySQLOperations(self) self.introspection = MySQLIntrospection(self) + + def schema_editor(self, *args, **kwargs): + "Returns a new instance of this backend's SchemaEditor" + return MySQLGISSchemaEditor(self, *args, **kwargs) diff --git a/django/contrib/gis/db/backends/mysql/introspection.py b/django/contrib/gis/db/backends/mysql/introspection.py index aae1c1460c..364cdeecfe 100644 --- a/django/contrib/gis/db/backends/mysql/introspection.py +++ b/django/contrib/gis/db/backends/mysql/introspection.py @@ -31,3 +31,11 @@ class MySQLIntrospection(DatabaseIntrospection): cursor.close() return field_type, field_params + + def supports_spatial_index(self, cursor, table_name): + # Supported with MyISAM, or InnoDB on MySQL 5.7.5+ + storage_engine = self.get_storage_engine(cursor, table_name) + return ( + (storage_engine == 'InnoDB' and self.connection.mysql_version >= (5, 7, 5)) or + storage_engine == 'MyISAM' + ) diff --git a/django/contrib/gis/db/backends/mysql/schema.py b/django/contrib/gis/db/backends/mysql/schema.py new file mode 100644 index 0000000000..3979182c19 --- /dev/null +++ b/django/contrib/gis/db/backends/mysql/schema.py @@ -0,0 +1,70 @@ +import logging + +from django.contrib.gis.db.models.fields import GeometryField +from django.db.utils import OperationalError +from django.db.backends.mysql.schema import DatabaseSchemaEditor + +logger = logging.getLogger('django.contrib.gis') + + +class MySQLGISSchemaEditor(DatabaseSchemaEditor): + sql_add_spatial_index = 'CREATE SPATIAL INDEX %(index)s ON %(table)s(%(column)s)' + sql_drop_spatial_index = 'DROP INDEX %(index)s ON %(table)s' + + def __init__(self, *args, **kwargs): + super(MySQLGISSchemaEditor, self).__init__(*args, **kwargs) + self.geometry_sql = [] + + def column_sql(self, model, field, include_default=False): + column_sql = super(MySQLGISSchemaEditor, self).column_sql(model, field, include_default) + # MySQL doesn't support spatial indexes on NULL columns + if isinstance(field, GeometryField) and field.spatial_index and not field.null: + qn = self.connection.ops.quote_name + db_table = model._meta.db_table + self.geometry_sql.append( + self.sql_add_spatial_index % { + 'index': qn(self._create_spatial_index_name(model, field)), + 'table': qn(db_table), + 'column': qn(field.column), + } + ) + return column_sql + + def create_model(self, model): + super(MySQLGISSchemaEditor, self).create_model(model) + self.create_spatial_indexes() + + def add_field(self, model, field): + super(MySQLGISSchemaEditor, self).add_field(model, field) + self.create_spatial_indexes() + + def remove_field(self, model, field): + if isinstance(field, GeometryField) and field.spatial_index: + qn = self.connection.ops.quote_name + sql = self.sql_drop_spatial_index % { + 'index': qn(self._create_spatial_index_name(model, field)), + 'table': qn(model._meta.db_table), + } + try: + self.execute(sql) + except OperationalError: + logger.error( + "Couldn't remove spatial index: %s (may be expected " + "if your storage engine doesn't support them)." % sql + ) + + super(MySQLGISSchemaEditor, self).remove_field(model, field) + + def _create_spatial_index_name(self, model, field): + return '%s_%s_id' % (model._meta.db_table, field.column) + + def create_spatial_indexes(self): + for sql in self.geometry_sql: + try: + self.execute(sql) + except OperationalError: + logger.error( + "Cannot create SPATIAL INDEX %s. Only MyISAM and (as of " + "MySQL 5.7.5) InnoDB support them." % sql + ) + self.geometry_sql = [] diff --git a/django/contrib/gis/db/backends/spatialite/introspection.py b/django/contrib/gis/db/backends/spatialite/introspection.py index 9e1616c514..b81a2e4089 100644 --- a/django/contrib/gis/db/backends/spatialite/introspection.py +++ b/django/contrib/gis/db/backends/spatialite/introspection.py @@ -53,3 +53,12 @@ class SpatiaLiteIntrospection(DatabaseIntrospection): cursor.close() return field_type, field_params + + def get_indexes(self, cursor, table_name): + indexes = super(SpatiaLiteIntrospection, self).get_indexes(cursor, table_name) + cursor.execute('SELECT f_geometry_column ' + 'FROM geometry_columns ' + 'WHERE f_table_name=%s AND spatial_index_enabled=1', (table_name,)) + for row in cursor.fetchall(): + indexes[row[0]] = {'primary_key': False, 'unique': False} + return indexes diff --git a/django/contrib/gis/tests/gis_migrations/migrations/0001_initial.py b/django/contrib/gis/tests/gis_migrations/migrations/0001_initial.py index a808e49444..f5d1aade6d 100644 --- a/django/contrib/gis/tests/gis_migrations/migrations/0001_initial.py +++ b/django/contrib/gis/tests/gis_migrations/migrations/0001_initial.py @@ -12,7 +12,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('name', models.CharField(max_length=100, unique=True)), - ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326, null=True)), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(srid=4326)), ], options={ }, @@ -25,7 +25,7 @@ class Migration(migrations.Migration): ('neighborhood', models.ForeignKey(to='gis.Neighborhood', to_field='id', null=True)), ('address', models.CharField(max_length=100)), ('zip_code', models.IntegerField(null=True, blank=True)), - ('geom', django.contrib.gis.db.models.fields.PointField(srid=4326, null=True, geography=True)), + ('geom', django.contrib.gis.db.models.fields.PointField(srid=4326, geography=True)), ], options={ }, diff --git a/django/contrib/gis/tests/gis_migrations/test_operations.py b/django/contrib/gis/tests/gis_migrations/test_operations.py index d44c22c134..32072eec86 100644 --- a/django/contrib/gis/tests/gis_migrations/test_operations.py +++ b/django/contrib/gis/tests/gis_migrations/test_operations.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from django.contrib.gis.tests.utils import mysql from django.db import connection, migrations, models from django.db.migrations.migration import Migration from django.db.migrations.state import ProjectState @@ -45,7 +46,7 @@ class OperationTests(TransactionTestCase): [ ("id", models.AutoField(primary_key=True)), ('name', models.CharField(max_length=100, unique=True)), - ('geom', fields.MultiPolygonField(srid=4326, null=True)), + ('geom', fields.MultiPolygonField(srid=4326)), ], )] return self.apply_operations('gis', ProjectState(), operations) @@ -58,7 +59,7 @@ class OperationTests(TransactionTestCase): operation = migrations.AddField( "Neighborhood", "path", - fields.LineStringField(srid=4326, null=True, blank=True), + fields.LineStringField(srid=4326), ) new_state = project_state.clone() operation.state_forwards("gis", new_state) @@ -74,6 +75,11 @@ class OperationTests(TransactionTestCase): 2 ) + if self.has_spatial_indexes: + with connection.cursor() as cursor: + indexes = connection.introspection.get_indexes(cursor, "gis_neighborhood") + self.assertIn('path', indexes) + def test_remove_gis_field(self): """ Tests the RemoveField operation with a GIS-enabled column. @@ -93,3 +99,20 @@ class OperationTests(TransactionTestCase): GeometryColumns.objects.filter(**{GeometryColumns.table_name_col(): "gis_neighborhood"}).count(), 0 ) + + def test_create_model_spatial_index(self): + self.current_state = self.set_up_test_model() + + if not self.has_spatial_indexes: + self.skipTest("No support for Spatial indexes") + + with connection.cursor() as cursor: + indexes = connection.introspection.get_indexes(cursor, "gis_neighborhood") + self.assertIn('geom', indexes) + + @property + def has_spatial_indexes(self): + if mysql: + with connection.cursor() as cursor: + return connection.introspection.supports_spatial_index(cursor, "gis_neighborhood") + return True diff --git a/django/db/backends/mysql/introspection.py b/django/db/backends/mysql/introspection.py index 8b7ff3c1e5..47bb92859a 100644 --- a/django/db/backends/mysql/introspection.py +++ b/django/db/backends/mysql/introspection.py @@ -127,6 +127,16 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): indexes[row[4]]['unique'] = True return indexes + def get_storage_engine(self, cursor, table_name): + """ + Retrieves the storage engine for a given table. + """ + cursor.execute( + "SELECT engine " + "FROM information_schema.tables " + "WHERE table_name = %s", [table_name]) + return cursor.fetchone()[0] + def get_constraints(self, cursor, table_name): """ Retrieves any constraints or keys (unique, pk, fk, check, index) across one or more columns. diff --git a/docs/releases/1.7.1.txt b/docs/releases/1.7.1.txt index a43e1efa8c..a19e19d03b 100644 --- a/docs/releases/1.7.1.txt +++ b/docs/releases/1.7.1.txt @@ -73,3 +73,6 @@ Bugfixes * Fixed bug in migrations that prevented foreign key constraints to unmanaged models with a custom primary key (:ticket:`23415`). + +* Added ``SchemaEditor`` for MySQL GIS backend so that spatial indexes will be + created for apps with migrations (:ticket:`23538`).