diff --git a/geoalchemy2/__init__.py b/geoalchemy2/__init__.py index c9541ac6..9fddfa66 100644 --- a/geoalchemy2/__init__.py +++ b/geoalchemy2/__init__.py @@ -92,19 +92,23 @@ def after_parent_attach(column, table): postgresql_ops = {column.name: "gist_geometry_ops_nd"} else: postgresql_ops = {} - Index( - _spatial_idx_name(table, column), - column, - postgresql_using='gist', - postgresql_ops=postgresql_ops, - _column_flag=True, + table.append_constraint( + Index( + _spatial_idx_name(table, column), + column, + postgresql_using='gist', + postgresql_ops=postgresql_ops, + _column_flag=True, + ) ) elif _check_spatial_type(column.type, Raster): - Index( - _spatial_idx_name(table, column), - func.ST_ConvexHull(column), - postgresql_using='gist', - _column_flag=True, + table.append_constraint( + Index( + _spatial_idx_name(table, column), + func.ST_ConvexHull(column), + postgresql_using='gist', + _column_flag=True, + ) ) def dispatch(current_event, table, bind): @@ -236,6 +240,7 @@ def dispatch(current_event, table, bind): col, postgresql_using='gist', postgresql_ops=postgresql_ops, + _column_flag=True, ) idx.create(bind=bind) diff --git a/geoalchemy2/alembic_helpers.py b/geoalchemy2/alembic_helpers.py index 38a73a2a..8a74cf38 100644 --- a/geoalchemy2/alembic_helpers.py +++ b/geoalchemy2/alembic_helpers.py @@ -113,6 +113,7 @@ def add_geospatial_column(operations, operation): geospatial_core_type.srid, geospatial_core_type.geometry_type )) + # TODO: Add Index if geospatial_core_type.spatial_index is True elif "postgresql" in dialect.name: operations.add_column( table_name, @@ -121,6 +122,7 @@ def add_geospatial_column(operations, operation): operation.column ) ) + # TODO: Add Index if geospatial_core_type.spatial_index is True @Operations.implementation_for(DropGeospatialColumn) @@ -149,6 +151,7 @@ def drop_geospatial_column(operations, operation): sqlite_version = conn.execute(text("SELECT sqlite_version();")).scalar() if parse_version(sqlite_version) >= parse_version("3.35"): operations.drop_column(table_name, column_name) + # TODO: Drop Index? elif "postgresql" in dialect.name: operations.drop_column(table_name, column_name) diff --git a/setup.cfg b/setup.cfg index 472fcb5b..6e566900 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ setenv= EXPECTED_COV = 93 pypy3: EXPECTED_COV = 92 deps= + alembic sqla11: SQLAlchemy==1.1.2 sqlalatest: SQLAlchemy !pypy3: psycopg2 diff --git a/tests/alembic_config/alembic.ini b/tests/alembic_config/alembic.ini new file mode 100644 index 00000000..80741b25 --- /dev/null +++ b/tests/alembic_config/alembic.ini @@ -0,0 +1,102 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. Valid values are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # default: use os.pathsep + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# The URL will be set dynamicaly during the test initialization +; sqlalchemy.url = postgresql://gis:gis@localhost/gis +; sqlalchemy.url = sqlite:///spatialdb + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = DEBUG +handlers = console +qualname = + +[logger_sqlalchemy] +level = DEBUG +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/tests/test_alembic_migrations.py b/tests/test_alembic_migrations.py new file mode 100644 index 00000000..9b8c66aa --- /dev/null +++ b/tests/test_alembic_migrations.py @@ -0,0 +1,254 @@ +"""Test alembic migrations of spatial columns.""" +import re + +import sqlalchemy as sa # noqa (This import is only used in the migration scripts) +from alembic.autogenerate import compare_metadata +from alembic.autogenerate import produce_migrations +from alembic.autogenerate import render_python_code +from alembic.migration import MigrationContext +from alembic.operations import Operations +from sqlalchemy import Column +from sqlalchemy import Integer +from sqlalchemy import MetaData +from sqlalchemy import Table +from sqlalchemy import text + +from geoalchemy2 import Geometry +from geoalchemy2 import alembic_helpers + + +def filter_tables(name, type_, parent_names): + """Filter tables that we don't care about.""" + return type_ != "table" or name in ["lake", "alembic_table"] + + +class TestAutogenerate: + def test_no_diff(self, conn, Lake, setup_tables): + """Check that the autogeneration detects spatial types properly.""" + metadata = MetaData() + + Table( + "lake", + metadata, + Column("id", Integer, primary_key=True), + Column( + "geom", + Geometry( + geometry_type="LINESTRING", + srid=4326, + management=Lake.__table__.c.geom.type.management, + ), + ), + schema=Lake.__table__.schema, + ) + + mc = MigrationContext.configure( + conn, + opts={ + "include_object": alembic_helpers.include_object, + "include_name": filter_tables, + }, + ) + + diff = compare_metadata(mc, metadata) + + assert diff == [] + + def test_diff(self, conn, Lake, setup_tables): + """Check that the autogeneration detects spatial types properly.""" + metadata = MetaData() + + Table( + "lake", + metadata, + Column("id", Integer, primary_key=True), + Column("new_col", Integer, primary_key=True), + Column( + "geom", + Geometry( + geometry_type="LINESTRING", + srid=4326, + management=Lake.__table__.c.geom.type.management, + ), + ), + Column( + "new_geom_col", + Geometry( + geometry_type="LINESTRING", + srid=4326, + management=Lake.__table__.c.geom.type.management, + ), + ), + schema=Lake.__table__.schema, + ) + + mc = MigrationContext.configure( + conn, + opts={ + "include_object": alembic_helpers.include_object, + "include_name": filter_tables, + }, + ) + + diff = compare_metadata(mc, metadata) + + # Check column of type Integer + add_new_col = diff[0] + assert add_new_col[0] == "add_column" + assert add_new_col[1] is None + assert add_new_col[2] == "lake" + assert add_new_col[3].name == "new_col" + assert isinstance(add_new_col[3].type, Integer) + assert add_new_col[3].primary_key is True + assert add_new_col[3].nullable is False + + # Check column of type Geometry + add_new_geom_col = diff[1] + assert add_new_geom_col[0] == "add_column" + assert add_new_geom_col[1] is None + assert add_new_geom_col[2] == "lake" + assert add_new_geom_col[3].name == "new_geom_col" + assert isinstance(add_new_geom_col[3].type, Geometry) + assert add_new_geom_col[3].primary_key is False + assert add_new_geom_col[3].nullable is True + assert add_new_geom_col[3].type.srid == 4326 + assert add_new_geom_col[3].type.geometry_type == "LINESTRING" + assert add_new_geom_col[3].type.name == "geometry" + assert add_new_geom_col[3].type.dimension == 2 + + +def test_migration(conn, metadata): + """Test the actual migration of spatial types.""" + Table( + "alembic_table", + metadata, + Column("id", Integer, primary_key=True), + Column("int_col", Integer, index=True), + Column( + "geom", + Geometry( + geometry_type="POINT", + srid=4326, + management=False, + ), + ), + # The managed column does not work for now + # Column( + # "managed_geom", + # Geometry( + # geometry_type="POINT", + # srid=4326, + # management=True, + # ), + # ), + Column( + "geom_no_idx", + Geometry( + geometry_type="POINT", + srid=4326, + spatial_index=False, + ), + ), + ) + + mc = MigrationContext.configure( + conn, + opts={ + "include_object": alembic_helpers.include_object, + "include_name": filter_tables, + "user_module_prefix": "geoalchemy2.types.", + }, + ) + + migration_script = produce_migrations(mc, metadata) + upgrade_script = render_python_code( + migration_script.upgrade_ops, render_item=alembic_helpers.render_item + ) + downgrade_script = render_python_code( + migration_script.downgrade_ops, render_item=alembic_helpers.render_item + ) + + op = Operations(mc) # noqa (This variable is only used in the migration scripts) + + # Compile and execute the upgrade part of the migration script + eval(compile(upgrade_script.replace(" ", ""), "upgrade_script.py", "exec")) + + if conn.dialect.name == "postgresql": + # Postgresql dialect + + # Query to check the indexes + index_query = text( + """SELECT indexname, indexdef + FROM pg_indexes + WHERE + tablename = 'alembic_table' + ORDER BY indexname;""" + ) + indexes = conn.execute(index_query).fetchall() + + expected_indices = [ + ( + "alembic_table_pkey", + """CREATE UNIQUE INDEX alembic_table_pkey + ON gis.alembic_table + USING btree (id)""", + ), + ( + "idx_alembic_table_geom", + """CREATE INDEX idx_alembic_table_geom + ON gis.alembic_table + USING gist (geom)""", + ), + ( + "ix_alembic_table_int_col", + """CREATE INDEX ix_alembic_table_int_col + ON gis.alembic_table + USING btree (int_col)""", + ), + ] + + assert len(indexes) == 3 + + for idx, expected_idx in zip(indexes, expected_indices): + assert idx[0] == expected_idx[0] + assert idx[1] == re.sub("\n *", " ", expected_idx[1]) + + elif conn.dialect.name == "sqlite": + # SQLite dialect + + # Query to check the indexes + query_indexes = text( + """SELECT * + FROM geometry_columns + WHERE f_table_name = 'alembic_table' + ORDER BY f_table_name, f_geometry_column;""" + ) + + # Check the actual properties + geom_cols = conn.execute(query_indexes).fetchall() + assert geom_cols == [ + ("alembic_table", "geom", 1, 2, 4326, 1), + ("alembic_table", "geom_no_idx", 1, 2, 4326, 0), + ] + + # Compile and execute the downgrade part of the migration script + eval(compile(downgrade_script.replace(" ", ""), "downgrade_script.py", "exec")) + + if conn.dialect.name == "postgresql": + # Postgresql dialect + # Here the indexes are attached to the table so if the DROP TABLE works it's ok + pass + elif conn.dialect.name == "sqlite": + # SQLite dialect + + # Query to check the indexes + query_indexes = text( + """SELECT * + FROM geometry_columns + WHERE f_table_name = 'alembic_table' + ORDER BY f_table_name, f_geometry_column;""" + ) + + # Now the indexes should have been dropped + geom_cols = conn.execute(query_indexes).fetchall() + assert geom_cols == [] diff --git a/tests/test_functional.py b/tests/test_functional.py index 4df9d0c1..54dcaca3 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -28,6 +28,7 @@ from sqlalchemy.exc import OperationalError from sqlalchemy.exc import ProgrammingError from sqlalchemy.sql import func +from sqlalchemy.testing.assertions import ComparesTables from geoalchemy2 import Geometry from geoalchemy2 import Raster @@ -663,13 +664,13 @@ def test_raster_reflection(self, conn, Ocean, setup_tables): assert isinstance(type_, Raster) -class TestToMetadata: +class TestToMetadata(ComparesTables): def test_to_metadata(self, Lake): new_meta = MetaData() new_Lake = Lake.__table__.to_metadata(new_meta) - assert Lake != new_Lake + self.assert_tables_equal(Lake.__table__, new_Lake) # Check that the spatial index was not duplicated assert len(new_Lake.indexes) == 1