diff --git a/migrations/env.py b/migrations/env.py index 15c79e3..4c62bc9 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -21,7 +21,7 @@ log_config.fileConfig(context.config.config_file_name) def include_object(obj, _name, type_, _reflected, _compare_to): """Only include the clean schema into --autogenerate migrations.""" - if type_ in {'table', 'column'} and obj.schema != umd_config.DATABASE_SCHEMA: + if type_ in {'table', 'column'} and obj.schema != umd_config.CLEAN_SCHEMA: return False return True diff --git a/migrations/versions/rev_20210102_18_888e352d7526_add_pixel_grid.py b/migrations/versions/rev_20210102_18_888e352d7526_add_pixel_grid.py new file mode 100644 index 0000000..d1a9d34 --- /dev/null +++ b/migrations/versions/rev_20210102_18_888e352d7526_add_pixel_grid.py @@ -0,0 +1,163 @@ +"""Add pixel grid. + +Revision: #888e352d7526 at 2021-01-02 18:11:02 +Revises: #f11cd76d2f45 +""" + +import os + +import sqlalchemy as sa +from alembic import op + +from urban_meal_delivery import configuration + + +revision = '888e352d7526' +down_revision = 'f11cd76d2f45' +branch_labels = None +depends_on = None + + +config = configuration.make_config('testing' if os.getenv('TESTING') else 'production') + + +def upgrade(): + """Upgrade to revision 888e352d7526.""" + op.create_table( + 'grids', + sa.Column('id', sa.SmallInteger(), autoincrement=True, nullable=False), + sa.Column('city_id', sa.SmallInteger(), nullable=False), + sa.Column('side_length', sa.SmallInteger(), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_grids')), + sa.ForeignKeyConstraint( + ['city_id'], + [f'{config.CLEAN_SCHEMA}.cities.id'], + name=op.f('fk_grids_to_cities_via_city_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.UniqueConstraint('side_length', name=op.f('uq_grids_on_side_length')), + schema=config.CLEAN_SCHEMA, + ) + + op.create_table( + 'pixels', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('grid_id', sa.SmallInteger(), nullable=False), + sa.Column('n_x', sa.SmallInteger(), nullable=False), + sa.Column('n_y', sa.SmallInteger(), nullable=False), + sa.CheckConstraint('0 <= n_x', name=op.f('ck_pixels_on_n_x_is_positive')), + sa.CheckConstraint('0 <= n_y', name=op.f('ck_pixels_on_n_y_is_positive')), + sa.ForeignKeyConstraint( + ['grid_id'], + [f'{config.CLEAN_SCHEMA}.grids.id'], + name=op.f('fk_pixels_to_grids_via_grid_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.PrimaryKeyConstraint('id', name=op.f('pk_pixels')), + sa.UniqueConstraint( + 'grid_id', 'n_x', 'n_y', name=op.f('uq_pixels_on_grid_id_n_x_n_y'), + ), + sa.UniqueConstraint('id', 'grid_id', name=op.f('uq_pixels_on_id_grid_id')), + schema=config.CLEAN_SCHEMA, + ) + + op.create_index( + op.f('ix_pixels_on_grid_id'), + 'pixels', + ['grid_id'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_pixels_on_n_x'), + 'pixels', + ['n_x'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + op.create_index( + op.f('ix_pixels_on_n_y'), + 'pixels', + ['n_y'], + unique=False, + schema=config.CLEAN_SCHEMA, + ) + + # These `UniqueConstraints`s are needed by the `addresses_pixels` table below. + op.create_unique_constraint( + 'uq_addresses_on_id_city_id', + 'addresses', + ['id', 'city_id'], + schema=config.CLEAN_SCHEMA, + ) + op.create_unique_constraint( + 'uq_grids_on_id_city_id', + 'grids', + ['id', 'city_id'], + schema=config.CLEAN_SCHEMA, + ) + + op.create_table( + 'addresses_pixels', + sa.Column('address_id', sa.Integer(), nullable=False), + sa.Column('city_id', sa.SmallInteger(), nullable=False), + sa.Column('grid_id', sa.SmallInteger(), nullable=False), + sa.Column('pixel_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ['address_id', 'city_id'], + [ + f'{config.CLEAN_SCHEMA}.addresses.id', + f'{config.CLEAN_SCHEMA}.addresses.city_id', + ], + name=op.f('fk_addresses_pixels_to_addresses_via_address_id_city_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['grid_id', 'city_id'], + [ + f'{config.CLEAN_SCHEMA}.grids.id', + f'{config.CLEAN_SCHEMA}.grids.city_id', + ], + name=op.f('fk_addresses_pixels_to_grids_via_grid_id_city_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.ForeignKeyConstraint( + ['pixel_id', 'grid_id'], + [ + f'{config.CLEAN_SCHEMA}.pixels.id', + f'{config.CLEAN_SCHEMA}.pixels.grid_id', + ], + name=op.f('fk_addresses_pixels_to_pixels_via_pixel_id_grid_id'), + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + sa.PrimaryKeyConstraint( + 'address_id', 'pixel_id', name=op.f('pk_addresses_pixels'), + ), + sa.UniqueConstraint( + 'address_id', + 'grid_id', + name=op.f('uq_addresses_pixels_on_address_id_grid_id'), + ), + schema=config.CLEAN_SCHEMA, + ) + + +def downgrade(): + """Downgrade to revision f11cd76d2f45.""" + op.drop_table('addresses_pixels', schema=config.CLEAN_SCHEMA) + op.drop_index( + op.f('ix_pixels_on_n_y'), table_name='pixels', schema=config.CLEAN_SCHEMA, + ) + op.drop_index( + op.f('ix_pixels_on_n_x'), table_name='pixels', schema=config.CLEAN_SCHEMA, + ) + op.drop_index( + op.f('ix_pixels_on_grid_id'), table_name='pixels', schema=config.CLEAN_SCHEMA, + ) + op.drop_table('pixels', schema=config.CLEAN_SCHEMA) + op.drop_table('grids', schema=config.CLEAN_SCHEMA) diff --git a/setup.cfg b/setup.cfg index 7443dc5..e68c2da 100644 --- a/setup.cfg +++ b/setup.cfg @@ -89,6 +89,8 @@ extend-ignore = # Comply with black's style. # Source: https://github.com/psf/black/blob/master/docs/compatible_configs.md#flake8 E203, W503, WPS348, + # String constant over-use is checked visually by the programmer. + WPS226, # Allow underscores in numbers. WPS303, # f-strings are ok. @@ -114,8 +116,6 @@ per-file-ignores = WPS114,WPS118, # Revisions may have too many expressions. WPS204,WPS213, - # No overuse of string constants (e.g., 'RESTRICT'). - WPS226, # Too many noqa's are ok. WPS402, noxfile.py: @@ -125,8 +125,6 @@ per-file-ignores = WPS202, # TODO (isort): Remove after simplifying the nox session "lint". WPS213, - # No overuse of string constants (e.g., '--version'). - WPS226, # The noxfile is rather long => allow many noqa's. WPS402, src/urban_meal_delivery/configuration.py: @@ -134,13 +132,9 @@ per-file-ignores = WPS115, # Numbers are normal in config files. WPS432, - # No real string constant over-use. - src/urban_meal_delivery/db/addresses.py: - WPS226, - src/urban_meal_delivery/db/cities.py: - WPS226, - src/urban_meal_delivery/db/orders.py: - WPS226, + src/urban_meal_delivery/db/__init__.py: + # Top-level of a sub-packages is intended to import a lot. + F401, tests/*.py: # Type annotations are not strictly enforced. ANN0, ANN2, @@ -158,14 +152,15 @@ per-file-ignores = WPS202,WPS204,WPS214, # Do not check for Jones complexity in the test suite. WPS221, - # No overuse of string constants (e.g., '__version__'). - WPS226, # We do not care about the number of "# noqa"s in the test suite. WPS402, # Allow closures. WPS430, # Numbers are normal in test cases as expected results. WPS432, + tests/db/fake_data/__init__.py: + # Top-level of a sub-packages is intended to import a lot. + F401,WPS201, # Explicitly set mccabe's maximum complexity to 10 as recommended by # Thomas McCabe, the inventor of the McCabe complexity, and the NIST. diff --git a/src/urban_meal_delivery/db/__init__.py b/src/urban_meal_delivery/db/__init__.py index 8b9f0b4..a73f40e 100644 --- a/src/urban_meal_delivery/db/__init__.py +++ b/src/urban_meal_delivery/db/__init__.py @@ -1,11 +1,14 @@ """Provide the ORM models and a connection to the database.""" -from urban_meal_delivery.db.addresses import Address # noqa:F401 -from urban_meal_delivery.db.cities import City # noqa:F401 -from urban_meal_delivery.db.connection import make_engine # noqa:F401 -from urban_meal_delivery.db.connection import make_session_factory # noqa:F401 -from urban_meal_delivery.db.couriers import Courier # noqa:F401 -from urban_meal_delivery.db.customers import Customer # noqa:F401 -from urban_meal_delivery.db.meta import Base # noqa:F401 -from urban_meal_delivery.db.orders import Order # noqa:F401 -from urban_meal_delivery.db.restaurants import Restaurant # noqa:F401 +from urban_meal_delivery.db.addresses import Address +from urban_meal_delivery.db.addresses_pixels import AddressPixelAssociation +from urban_meal_delivery.db.cities import City +from urban_meal_delivery.db.connection import make_engine +from urban_meal_delivery.db.connection import make_session_factory +from urban_meal_delivery.db.couriers import Courier +from urban_meal_delivery.db.customers import Customer +from urban_meal_delivery.db.grids import Grid +from urban_meal_delivery.db.meta import Base +from urban_meal_delivery.db.orders import Order +from urban_meal_delivery.db.pixels import Pixel +from urban_meal_delivery.db.restaurants import Restaurant diff --git a/src/urban_meal_delivery/db/addresses.py b/src/urban_meal_delivery/db/addresses.py index bfb848c..f392207 100644 --- a/src/urban_meal_delivery/db/addresses.py +++ b/src/urban_meal_delivery/db/addresses.py @@ -46,6 +46,8 @@ class Address(meta.Base): '-180 <= longitude AND longitude <= 180', name='longitude_between_180_degrees', ), + # Needed by a `ForeignKeyConstraint` in `AddressPixelAssociation`. + sa.UniqueConstraint('id', 'city_id'), sa.CheckConstraint( '30000 <= zip_code AND zip_code <= 99999', name='valid_zip_code', ), @@ -60,12 +62,12 @@ class Address(meta.Base): back_populates='pickup_address', foreign_keys='[Order._pickup_address_id]', ) - orders_delivered = orm.relationship( 'Order', back_populates='delivery_address', foreign_keys='[Order._delivery_address_id]', ) + pixels = orm.relationship('AddressPixelAssociation', back_populates='address') def __init__(self, *args: Any, **kwargs: Any) -> None: """Create a new address.""" diff --git a/src/urban_meal_delivery/db/addresses_pixels.py b/src/urban_meal_delivery/db/addresses_pixels.py new file mode 100644 index 0000000..3ba198f --- /dev/null +++ b/src/urban_meal_delivery/db/addresses_pixels.py @@ -0,0 +1,56 @@ +"""Model for the many-to-many relationship between `Address` and `Pixel` objects.""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from urban_meal_delivery.db import meta + + +class AddressPixelAssociation(meta.Base): + """Association pattern between `Address` and `Pixel`. + + This approach is needed here mainly because it implicitly + updates the `_city_id` and `_grid_id` columns. + + Further info: + https://docs.sqlalchemy.org/en/stable/orm/basic_relationships.html#association-object # noqa:E501 + """ + + __tablename__ = 'addresses_pixels' + + # Columns + _address_id = sa.Column('address_id', sa.Integer, primary_key=True) + _city_id = sa.Column('city_id', sa.SmallInteger, nullable=False) + _grid_id = sa.Column('grid_id', sa.SmallInteger, nullable=False) + _pixel_id = sa.Column('pixel_id', sa.Integer, primary_key=True) + + # Constraints + __table_args__ = ( + # An `Address` can only be on a `Grid` ... + sa.ForeignKeyConstraint( + ['address_id', 'city_id'], + ['addresses.id', 'addresses.city_id'], + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + # ... if their `.city` attributes match. + sa.ForeignKeyConstraint( + ['grid_id', 'city_id'], + ['grids.id', 'grids.city_id'], + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + # Each `Address` can only be on a `Grid` once. + sa.UniqueConstraint('address_id', 'grid_id'), + # An association must reference an existing `Grid`-`Pixel` pair. + sa.ForeignKeyConstraint( + ['pixel_id', 'grid_id'], + ['pixels.id', 'pixels.grid_id'], + onupdate='RESTRICT', + ondelete='RESTRICT', + ), + ) + + # Relationships + address = orm.relationship('Address', back_populates='pixels') + pixel = orm.relationship('Pixel', back_populates='addresses') diff --git a/src/urban_meal_delivery/db/cities.py b/src/urban_meal_delivery/db/cities.py index a8e1360..d0d7422 100644 --- a/src/urban_meal_delivery/db/cities.py +++ b/src/urban_meal_delivery/db/cities.py @@ -45,6 +45,7 @@ class City(meta.Base): # Relationships addresses = orm.relationship('Address', back_populates='city') + grids = orm.relationship('Grid', back_populates='city') def __init__(self, *args: Any, **kwargs: Any) -> None: """Create a new city.""" diff --git a/src/urban_meal_delivery/db/grids.py b/src/urban_meal_delivery/db/grids.py new file mode 100644 index 0000000..26a7cea --- /dev/null +++ b/src/urban_meal_delivery/db/grids.py @@ -0,0 +1,48 @@ +"""Provide the ORM's `Grid` model.""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from urban_meal_delivery.db import meta + + +class Grid(meta.Base): + """A grid of `Pixel`s to partition a `City`. + + A grid is characterized by the uniform size of the `Pixel`s it contains. + That is configures via the `Grid.side_length` attribute. + """ + + __tablename__ = 'grids' + + # Columns + id = sa.Column( # noqa:WPS125 + sa.SmallInteger, primary_key=True, autoincrement=True, + ) + _city_id = sa.Column('city_id', sa.SmallInteger, nullable=False) + side_length = sa.Column(sa.SmallInteger, nullable=False, unique=True) + + # Constraints + __table_args__ = ( + sa.ForeignKeyConstraint( + ['city_id'], ['cities.id'], onupdate='RESTRICT', ondelete='RESTRICT', + ), + # Needed by a `ForeignKeyConstraint` in `address_pixel_association`. + sa.UniqueConstraint('id', 'city_id'), + ) + + # Relationships + city = orm.relationship('City', back_populates='grids') + pixels = orm.relationship('Pixel', back_populates='grid') + + def __repr__(self) -> str: + """Non-literal text representation.""" + return '<{cls}: {area}>'.format( + cls=self.__class__.__name__, area=self.pixel_area, + ) + + # Convenience properties + @property + def pixel_area(self) -> float: + """The area of a `Pixel` on the grid in square kilometers.""" + return (self.side_length ** 2) / 1_000_000 # noqa:WPS432 diff --git a/src/urban_meal_delivery/db/pixels.py b/src/urban_meal_delivery/db/pixels.py new file mode 100644 index 0000000..6d28227 --- /dev/null +++ b/src/urban_meal_delivery/db/pixels.py @@ -0,0 +1,59 @@ +"""Provide the ORM's `Pixel` model.""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from urban_meal_delivery.db import meta + + +class Pixel(meta.Base): + """A pixel in a `Grid`. + + Square pixels aggregate `Address` objects within a `City`. + Every `Address` belongs to exactly one `Pixel` in a `Grid`. + + Every `Pixel` has a unique "coordinate" within the `Grid`. + """ + + __tablename__ = 'pixels' + + # Columns + id = sa.Column(sa.Integer, primary_key=True, autoincrement=True) # noqa:WPS125 + _grid_id = sa.Column('grid_id', sa.SmallInteger, nullable=False, index=True) + n_x = sa.Column(sa.SmallInteger, nullable=False, index=True) + n_y = sa.Column(sa.SmallInteger, nullable=False, index=True) + + # Constraints + __table_args__ = ( + sa.ForeignKeyConstraint( + ['grid_id'], ['grids.id'], onupdate='RESTRICT', ondelete='RESTRICT', + ), + sa.CheckConstraint('0 <= n_x', name='n_x_is_positive'), + sa.CheckConstraint('0 <= n_y', name='n_y_is_positive'), + # Needed by a `ForeignKeyConstraint` in `AddressPixelAssociation`. + sa.UniqueConstraint('id', 'grid_id'), + # Each coordinate within the same `grid` is used at most once. + sa.UniqueConstraint('grid_id', 'n_x', 'n_y'), + ) + + # Relationships + grid = orm.relationship('Grid', back_populates='pixels') + addresses = orm.relationship('AddressPixelAssociation', back_populates='pixel') + + def __repr__(self) -> str: + """Non-literal text representation.""" + return '<{cls}: ({x}, {y})>'.format( + cls=self.__class__.__name__, x=self.n_x, y=self.n_y, + ) + + # Convenience properties + + @property + def side_length(self) -> int: + """The length of one side of a pixel in meters.""" + return self.grid.side_length + + @property + def area(self) -> float: + """The area of a pixel in square kilometers.""" + return self.grid.pixel_area diff --git a/tests/db/conftest.py b/tests/db/conftest.py index fcacfe7..8d2e3d1 100644 --- a/tests/db/conftest.py +++ b/tests/db/conftest.py @@ -85,3 +85,5 @@ courier = fake_data.courier customer = fake_data.customer order = fake_data.order restaurant = fake_data.restaurant +grid = fake_data.grid +pixel = fake_data.pixel diff --git a/tests/db/fake_data/__init__.py b/tests/db/fake_data/__init__.py index f6b879c..80a7be3 100644 --- a/tests/db/fake_data/__init__.py +++ b/tests/db/fake_data/__init__.py @@ -1,14 +1,16 @@ """Fixtures for testing the ORM layer with fake data.""" -from tests.db.fake_data.fixture_makers import make_address # noqa:F401 -from tests.db.fake_data.fixture_makers import make_courier # noqa:F401 -from tests.db.fake_data.fixture_makers import make_customer # noqa:F401 -from tests.db.fake_data.fixture_makers import make_order # noqa:F401 -from tests.db.fake_data.fixture_makers import make_restaurant # noqa:F401 -from tests.db.fake_data.static_fixtures import address # noqa:F401 -from tests.db.fake_data.static_fixtures import city # noqa:F401 -from tests.db.fake_data.static_fixtures import city_data # noqa:F401 -from tests.db.fake_data.static_fixtures import courier # noqa:F401 -from tests.db.fake_data.static_fixtures import customer # noqa:F401 -from tests.db.fake_data.static_fixtures import order # noqa:F401 -from tests.db.fake_data.static_fixtures import restaurant # noqa:F401 +from tests.db.fake_data.fixture_makers import make_address +from tests.db.fake_data.fixture_makers import make_courier +from tests.db.fake_data.fixture_makers import make_customer +from tests.db.fake_data.fixture_makers import make_order +from tests.db.fake_data.fixture_makers import make_restaurant +from tests.db.fake_data.static_fixtures import address +from tests.db.fake_data.static_fixtures import city +from tests.db.fake_data.static_fixtures import city_data +from tests.db.fake_data.static_fixtures import courier +from tests.db.fake_data.static_fixtures import customer +from tests.db.fake_data.static_fixtures import grid +from tests.db.fake_data.static_fixtures import order +from tests.db.fake_data.static_fixtures import pixel +from tests.db.fake_data.static_fixtures import restaurant diff --git a/tests/db/fake_data/static_fixtures.py b/tests/db/fake_data/static_fixtures.py index df7d5b7..ee6682d 100644 --- a/tests/db/fake_data/static_fixtures.py +++ b/tests/db/fake_data/static_fixtures.py @@ -56,3 +56,15 @@ def restaurant(address, make_restaurant): def order(make_order, restaurant): """An `Order` object for the `restaurant`.""" return make_order(restaurant=restaurant) + + +@pytest.fixture +def grid(city): + """A `Grid` with a pixel area of 1 square kilometer.""" + return db.Grid(city=city, side_length=1000) + + +@pytest.fixture +def pixel(grid): + """The `Pixel` in the lower-left corner of the `grid`.""" + return db.Pixel(grid=grid, n_x=0, n_y=0) diff --git a/tests/db/test_addresses_pixels.py b/tests/db/test_addresses_pixels.py new file mode 100644 index 0000000..40e41f8 --- /dev/null +++ b/tests/db/test_addresses_pixels.py @@ -0,0 +1,136 @@ +"""Test the ORM's `AddressPixelAssociation` model. + +Implementation notes: + The test suite has 100% coverage without the test cases in this module. + That is so as the `AddressPixelAssociation` model is imported into the + `urban_meal_delivery.db` namespace so that the `Address` and `Pixel` models + can find it upon initialization. Yet, none of the other unit tests run any + code associated with it. Therefore, we test it here as non-e2e tests and do + not measure its coverage. +""" +# pylint:disable=no-self-use + +import pytest +import sqlalchemy as sqla +from sqlalchemy import exc as sa_exc + +from urban_meal_delivery import db + + +@pytest.fixture +def assoc(address, pixel): + """An association between `address` and `pixel`.""" + return db.AddressPixelAssociation(address=address, pixel=pixel) + + +@pytest.mark.no_cover +class TestSpecialMethods: + """Test special methods in `Pixel`.""" + + def test_create_an_address_pixel_association(self, assoc): + """Test instantiation of a new `AddressPixelAssociation` object.""" + assert assoc is not None + + +@pytest.mark.db +@pytest.mark.no_cover +class TestConstraints: + """Test the database constraints defined in `AddressPixelAssociation`. + + The foreign keys to `City` and `Grid` are tested via INSERT and not + DELETE statements as the latter would already fail because of foreign + keys defined in `Address` and `Pixel`. + """ + + def test_insert_into_database(self, db_session, assoc): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.AddressPixelAssociation).count() == 0 + + db_session.add(assoc) + db_session.commit() + + assert db_session.query(db.AddressPixelAssociation).count() == 1 + + def test_delete_a_referenced_address(self, db_session, assoc): + """Remove a record that is referenced with a FK.""" + db_session.add(assoc) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Address).where(db.Address.id == assoc.address.id) + + with pytest.raises( + sa_exc.IntegrityError, + match='fk_addresses_pixels_to_addresses_via_address_id_city_id', + ): + db_session.execute(stmt) + + def test_reference_an_invalid_city(self, db_session, address, pixel): + """Insert a record with an invalid foreign key.""" + db_session.add(address) + db_session.add(pixel) + db_session.commit() + + # Must insert without ORM as otherwise SQLAlchemy figures out + # that something is wrong before any query is sent to the database. + stmt = sqla.insert(db.AddressPixelAssociation).values( + address_id=address.id, + city_id=999, + grid_id=pixel.grid.id, + pixel_id=pixel.id, + ) + + with pytest.raises( + sa_exc.IntegrityError, + match='fk_addresses_pixels_to_addresses_via_address_id_city_id', + ): + db_session.execute(stmt) + + def test_reference_an_invalid_grid(self, db_session, address, pixel): + """Insert a record with an invalid foreign key.""" + db_session.add(address) + db_session.add(pixel) + db_session.commit() + + # Must insert without ORM as otherwise SQLAlchemy figures out + # that something is wrong before any query is sent to the database. + stmt = sqla.insert(db.AddressPixelAssociation).values( + address_id=address.id, + city_id=address.city.id, + grid_id=999, + pixel_id=pixel.id, + ) + + with pytest.raises( + sa_exc.IntegrityError, + match='fk_addresses_pixels_to_grids_via_grid_id_city_id', + ): + db_session.execute(stmt) + + def test_delete_a_referenced_pixel(self, db_session, assoc): + """Remove a record that is referenced with a FK.""" + db_session.add(assoc) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Pixel).where(db.Pixel.id == assoc.pixel.id) + + with pytest.raises( + sa_exc.IntegrityError, + match='fk_addresses_pixels_to_pixels_via_pixel_id_grid_id', + ): + db_session.execute(stmt) + + def test_put_an_address_on_a_grid_twice(self, db_session, address, assoc, pixel): + """Insert a record that violates a unique constraint.""" + db_session.add(assoc) + db_session.commit() + + # Create a neighboring `Pixel` and put the same `address` as in `pixel` in it. + neighbor = db.Pixel(grid=pixel.grid, n_x=pixel.n_x, n_y=pixel.n_y + 1) + another_assoc = db.AddressPixelAssociation(address=address, pixel=neighbor) + + db_session.add(another_assoc) + + with pytest.raises(sa_exc.IntegrityError, match='duplicate key value'): + db_session.commit() diff --git a/tests/db/test_grids.py b/tests/db/test_grids.py new file mode 100644 index 0000000..0333b64 --- /dev/null +++ b/tests/db/test_grids.py @@ -0,0 +1,60 @@ +"""Test the ORM's `Grid` model.""" +# pylint:disable=no-self-use + +import pytest +import sqlalchemy as sqla +from sqlalchemy import exc as sa_exc + +from urban_meal_delivery import db + + +class TestSpecialMethods: + """Test special methods in `Grid`.""" + + def test_create_grid(self, grid): + """Test instantiation of a new `Grid` object.""" + assert grid is not None + + def test_text_representation(self, grid): + """`Grid` has a non-literal text representation.""" + result = repr(grid) + + assert result == f'' + + +@pytest.mark.db +@pytest.mark.no_cover +class TestConstraints: + """Test the database constraints defined in `Grid`.""" + + def test_insert_into_database(self, db_session, grid): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.Grid).count() == 0 + + db_session.add(grid) + db_session.commit() + + assert db_session.query(db.Grid).count() == 1 + + def test_delete_a_referenced_city(self, db_session, grid): + """Remove a record that is referenced with a FK.""" + db_session.add(grid) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.City).where(db.City.id == grid.city.id) + + with pytest.raises( + sa_exc.IntegrityError, match='fk_grids_to_cities_via_city_id', + ): + db_session.execute(stmt) + + +class TestProperties: + """Test properties in `Grid`.""" + + def test_pixel_area(self, grid): + """Test `Grid.pixel_area` property.""" + result = grid.pixel_area + + assert result == 1.0 diff --git a/tests/db/test_pixels.py b/tests/db/test_pixels.py new file mode 100644 index 0000000..878d6cc --- /dev/null +++ b/tests/db/test_pixels.py @@ -0,0 +1,90 @@ +"""Test the ORM's `Pixel` model.""" +# pylint:disable=no-self-use + +import pytest +import sqlalchemy as sqla +from sqlalchemy import exc as sa_exc + +from urban_meal_delivery import db + + +class TestSpecialMethods: + """Test special methods in `Pixel`.""" + + def test_create_pixel(self, pixel): + """Test instantiation of a new `Pixel` object.""" + assert pixel is not None + + def test_text_representation(self, pixel): + """`Pixel` has a non-literal text representation.""" + result = repr(pixel) + + assert result == f'' + + +@pytest.mark.db +@pytest.mark.no_cover +class TestConstraints: + """Test the database constraints defined in `Pixel`.""" + + def test_insert_into_database(self, db_session, pixel): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.Pixel).count() == 0 + + db_session.add(pixel) + db_session.commit() + + assert db_session.query(db.Pixel).count() == 1 + + def test_delete_a_referenced_grid(self, db_session, pixel): + """Remove a record that is referenced with a FK.""" + db_session.add(pixel) + db_session.commit() + + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Grid).where(db.Grid.id == pixel.grid.id) + + with pytest.raises( + sa_exc.IntegrityError, match='fk_pixels_to_grids_via_grid_id', + ): + db_session.execute(stmt) + + def test_negative_n_x(self, db_session, pixel): + """Insert an instance with invalid data.""" + pixel.n_x = -1 + db_session.add(pixel) + + with pytest.raises(sa_exc.IntegrityError, match='n_x_is_positive'): + db_session.commit() + + def test_negative_n_y(self, db_session, pixel): + """Insert an instance with invalid data.""" + pixel.n_y = -1 + db_session.add(pixel) + + with pytest.raises(sa_exc.IntegrityError, match='n_y_is_positive'): + db_session.commit() + + def test_non_unique_coordinates_within_a_grid(self, db_session, pixel): + """Insert an instance with invalid data.""" + another_pixel = db.Pixel(grid=pixel.grid, n_x=pixel.n_x, n_y=pixel.n_y) + db_session.add(another_pixel) + + with pytest.raises(sa_exc.IntegrityError, match='duplicate key value'): + db_session.commit() + + +class TestProperties: + """Test properties in `Pixel`.""" + + def test_side_length(self, pixel): + """Test `Pixel.side_length` property.""" + result = pixel.side_length + + assert result == 1_000 + + def test_area(self, pixel): + """Test `Pixel.area` property.""" + result = pixel.area + + assert result == 1.0