Fixed #23538 -- Added SchemaEditor for MySQL GIS.
Thanks Claude Paroz for suggestions and review.
This commit is contained in:
parent
215aa4f53b
commit
74e7f91e6d
|
@ -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.creation import MySQLCreation
|
||||||
from django.contrib.gis.db.backends.mysql.introspection import MySQLIntrospection
|
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.operations import MySQLOperations
|
||||||
|
from django.contrib.gis.db.backends.mysql.schema import MySQLGISSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
class DatabaseFeatures(BaseSpatialFeatures, MySQLDatabaseFeatures):
|
class DatabaseFeatures(BaseSpatialFeatures, MySQLDatabaseFeatures):
|
||||||
|
@ -25,3 +26,7 @@ class DatabaseWrapper(MySQLDatabaseWrapper):
|
||||||
self.creation = MySQLCreation(self)
|
self.creation = MySQLCreation(self)
|
||||||
self.ops = MySQLOperations(self)
|
self.ops = MySQLOperations(self)
|
||||||
self.introspection = MySQLIntrospection(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)
|
||||||
|
|
|
@ -31,3 +31,11 @@ class MySQLIntrospection(DatabaseIntrospection):
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
return field_type, field_params
|
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'
|
||||||
|
)
|
||||||
|
|
|
@ -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 = []
|
|
@ -53,3 +53,12 @@ class SpatiaLiteIntrospection(DatabaseIntrospection):
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
return field_type, field_params
|
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
|
||||||
|
|
|
@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||||
('name', models.CharField(max_length=100, unique=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={
|
options={
|
||||||
},
|
},
|
||||||
|
@ -25,7 +25,7 @@ class Migration(migrations.Migration):
|
||||||
('neighborhood', models.ForeignKey(to='gis.Neighborhood', to_field='id', null=True)),
|
('neighborhood', models.ForeignKey(to='gis.Neighborhood', to_field='id', null=True)),
|
||||||
('address', models.CharField(max_length=100)),
|
('address', models.CharField(max_length=100)),
|
||||||
('zip_code', models.IntegerField(null=True, blank=True)),
|
('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={
|
options={
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.contrib.gis.tests.utils import mysql
|
||||||
from django.db import connection, migrations, models
|
from django.db import connection, migrations, models
|
||||||
from django.db.migrations.migration import Migration
|
from django.db.migrations.migration import Migration
|
||||||
from django.db.migrations.state import ProjectState
|
from django.db.migrations.state import ProjectState
|
||||||
|
@ -45,7 +46,7 @@ class OperationTests(TransactionTestCase):
|
||||||
[
|
[
|
||||||
("id", models.AutoField(primary_key=True)),
|
("id", models.AutoField(primary_key=True)),
|
||||||
('name', models.CharField(max_length=100, unique=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)
|
return self.apply_operations('gis', ProjectState(), operations)
|
||||||
|
@ -58,7 +59,7 @@ class OperationTests(TransactionTestCase):
|
||||||
operation = migrations.AddField(
|
operation = migrations.AddField(
|
||||||
"Neighborhood",
|
"Neighborhood",
|
||||||
"path",
|
"path",
|
||||||
fields.LineStringField(srid=4326, null=True, blank=True),
|
fields.LineStringField(srid=4326),
|
||||||
)
|
)
|
||||||
new_state = project_state.clone()
|
new_state = project_state.clone()
|
||||||
operation.state_forwards("gis", new_state)
|
operation.state_forwards("gis", new_state)
|
||||||
|
@ -74,6 +75,11 @@ class OperationTests(TransactionTestCase):
|
||||||
2
|
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):
|
def test_remove_gis_field(self):
|
||||||
"""
|
"""
|
||||||
Tests the RemoveField operation with a GIS-enabled column.
|
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(),
|
GeometryColumns.objects.filter(**{GeometryColumns.table_name_col(): "gis_neighborhood"}).count(),
|
||||||
0
|
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
|
||||||
|
|
|
@ -127,6 +127,16 @@ class DatabaseIntrospection(BaseDatabaseIntrospection):
|
||||||
indexes[row[4]]['unique'] = True
|
indexes[row[4]]['unique'] = True
|
||||||
return indexes
|
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):
|
def get_constraints(self, cursor, table_name):
|
||||||
"""
|
"""
|
||||||
Retrieves any constraints or keys (unique, pk, fk, check, index) across one or more columns.
|
Retrieves any constraints or keys (unique, pk, fk, check, index) across one or more columns.
|
||||||
|
|
|
@ -73,3 +73,6 @@ Bugfixes
|
||||||
|
|
||||||
* Fixed bug in migrations that prevented foreign key constraints to unmanaged
|
* Fixed bug in migrations that prevented foreign key constraints to unmanaged
|
||||||
models with a custom primary key (:ticket:`23415`).
|
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`).
|
||||||
|
|
Loading…
Reference in New Issue