diff --git a/migrations/versions/rev_20210102_18_888e352d7526_add_pixel_grid.py b/migrations/versions/rev_20210102_18_888e352d7526_add_pixel_grid.py index d1a9d34..fb1aa16 100644 --- a/migrations/versions/rev_20210102_18_888e352d7526_add_pixel_grid.py +++ b/migrations/versions/rev_20210102_18_888e352d7526_add_pixel_grid.py @@ -36,7 +36,11 @@ def upgrade(): onupdate='RESTRICT', ondelete='RESTRICT', ), - sa.UniqueConstraint('side_length', name=op.f('uq_grids_on_side_length')), + sa.UniqueConstraint( + 'city_id', 'side_length', name=op.f('uq_grids_on_city_id_side_length'), + ), + # This `UniqueConstraint` is needed by the `addresses_pixels` table below. + sa.UniqueConstraint('id', 'city_id', name=op.f('uq_grids_on_id_city_id')), schema=config.CLEAN_SCHEMA, ) @@ -85,19 +89,13 @@ def upgrade(): schema=config.CLEAN_SCHEMA, ) - # These `UniqueConstraints`s are needed by the `addresses_pixels` table below. + # This `UniqueConstraint` is 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', diff --git a/noxfile.py b/noxfile.py index 1557320..0567f22 100644 --- a/noxfile.py +++ b/noxfile.py @@ -470,7 +470,7 @@ def init_project(session): @nox.session(name='clean-pwd', python=PYTHON, venv_backend='none') -def clean_pwd(session): # noqa:WPS210,WPS231 +def clean_pwd(session): # noqa:WPS231 """Remove (almost) all glob patterns listed in .gitignore. The difference compared to `git clean -X` is that this task diff --git a/setup.cfg b/setup.cfg index c5aa9dd..6b3f076 100644 --- a/setup.cfg +++ b/setup.cfg @@ -170,6 +170,9 @@ per-file-ignores = # Source: https://en.wikipedia.org/wiki/Cyclomatic_complexity#Limiting_complexity_during_development max-complexity = 10 +# Allow more than wemake-python-styleguide's 5 local variables per function. +max-local-variables = 8 + # Allow more than wemake-python-styleguide's 7 methods per class. max-methods = 12 diff --git a/src/urban_meal_delivery/db/addresses.py b/src/urban_meal_delivery/db/addresses.py index f4b853c..10c07ea 100644 --- a/src/urban_meal_delivery/db/addresses.py +++ b/src/urban_meal_delivery/db/addresses.py @@ -1,6 +1,5 @@ """Provide the ORM's `Address` model.""" - import sqlalchemy as sa from sqlalchemy import orm from sqlalchemy.dialects import postgresql @@ -93,7 +92,7 @@ class Address(meta.Base): def location(self) -> utils.Location: """The location of the address. - The returned `Location` object relates to `.city.viewport.southwest`. + The returned `Location` object relates to `.city.southwest`. See also the `.x` and `.y` properties that are shortcuts for `.location.x` and `.location.y`. @@ -103,7 +102,7 @@ class Address(meta.Base): """ if not hasattr(self, '_location'): # noqa:WPS421 note:b1f68d24 self._location = utils.Location(self.latitude, self.longitude) - self._location.relate_to(self.city.as_xy_origin) + self._location.relate_to(self.city.southwest) return self._location @property diff --git a/src/urban_meal_delivery/db/cities.py b/src/urban_meal_delivery/db/cities.py index 11175ad..20367aa 100644 --- a/src/urban_meal_delivery/db/cities.py +++ b/src/urban_meal_delivery/db/cities.py @@ -1,6 +1,5 @@ """Provide the ORM's `City` model.""" -from typing import Dict import sqlalchemy as sa from sqlalchemy import orm @@ -69,30 +68,45 @@ class City(meta.Base): return self._center @property - def viewport(self) -> Dict[str, utils.Location]: - """Google Maps viewport of the city. + def northeast(self) -> utils.Location: + """The city's northeast corner of the Google Maps viewport. Implementation detail: This property is cached as none of the underlying attributes to calculate the value are to be changed. """ - if not hasattr(self, '_viewport'): # noqa:WPS421 note:d334120e - self._viewport = { - 'northeast': utils.Location( - self._northeast_latitude, self._northeast_longitude, - ), - 'southwest': utils.Location( - self._southwest_latitude, self._southwest_longitude, - ), - } + if not hasattr(self, '_northeast'): # noqa:WPS421 note:d334120e + self._northeast = utils.Location( + self._northeast_latitude, self._northeast_longitude, + ) - return self._viewport + return self._northeast @property - def as_xy_origin(self) -> utils.Location: - """The southwest corner of the `.viewport`. + def southwest(self) -> utils.Location: + """The city's southwest corner of the Google Maps viewport. - This property serves, for example, as the `other` argument to the - `Location.relate_to()` method when representing an `Address` - in the x-y plane. + Implementation detail: This property is cached as none of the + underlying attributes to calculate the value are to be changed. """ - return self.viewport['southwest'] + if not hasattr(self, '_southwest'): # noqa:WPS421 note:d334120e + self._southwest = utils.Location( + self._southwest_latitude, self._southwest_longitude, + ) + + return self._southwest + + @property + def total_x(self) -> int: + """The horizontal distance from the city's west to east end in meters. + + The city borders refer to the Google Maps viewport. + """ + return self.northeast.easting - self.southwest.easting + + @property + def total_y(self) -> int: + """The vertical distance from the city's south to north end in meters. + + The city borders refer to the Google Maps viewport. + """ + return self.northeast.northing - self.southwest.northing diff --git a/src/urban_meal_delivery/db/grids.py b/src/urban_meal_delivery/db/grids.py index 26a7cea..3f7039b 100644 --- a/src/urban_meal_delivery/db/grids.py +++ b/src/urban_meal_delivery/db/grids.py @@ -1,8 +1,11 @@ """Provide the ORM's `Grid` model.""" +from __future__ import annotations + import sqlalchemy as sa from sqlalchemy import orm +from urban_meal_delivery import db from urban_meal_delivery.db import meta @@ -27,6 +30,9 @@ class Grid(meta.Base): sa.ForeignKeyConstraint( ['city_id'], ['cities.id'], onupdate='RESTRICT', ondelete='RESTRICT', ), + # Each `Grid`, characterized by its `.side_length`, + # may only exists once for a given `.city`. + sa.UniqueConstraint('city_id', 'side_length'), # Needed by a `ForeignKeyConstraint` in `address_pixel_association`. sa.UniqueConstraint('id', 'city_id'), ) @@ -46,3 +52,45 @@ class Grid(meta.Base): 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 + + @classmethod + def gridify(cls, city: db.City, side_length: int) -> db.Grid: + """Create a fully populated `Grid` for a `city`. + + The created `Grid` contains only the `Pixel`s for which + there is at least one `Address` in it. + + Args: + city: city for which the grid is created + side_length: the length of a square `Pixel`'s side + + Returns: + grid: including `grid.pixels` with the associated `city.addresses` + """ + grid = cls(city=city, side_length=side_length) + + # Create `Pixel` objects covering the entire `city`. + # Note: `+1` so that `city.northeast` corner is on the grid. + possible_pixels = [ + db.Pixel(n_x=n_x, n_y=n_y) + for n_x in range((city.total_x // side_length) + 1) + for n_y in range((city.total_y // side_length) + 1) + ] + + # For convenient lookup by `.n_x`-`.n_y` coordinates. + pixel_map = {(pixel.n_x, pixel.n_y): pixel for pixel in possible_pixels} + + for address in city.addresses: + # Determine which `pixel` the `address` belongs to. + n_x = address.x // side_length + n_y = address.y // side_length + pixel = pixel_map[n_x, n_y] + + # Create an association between the `address` and `pixel`. + assoc = db.AddressPixelAssociation(address=address, pixel=pixel) + pixel.addresses.append(assoc) + + # Only keep `pixel`s that contain at least one `Address`. + grid.pixels = [pixel for pixel in pixel_map.values() if pixel.addresses] + + return grid diff --git a/tests/db/test_cities.py b/tests/db/test_cities.py index a0110e5..d3ae5af 100644 --- a/tests/db/test_cities.py +++ b/tests/db/test_cities.py @@ -3,7 +3,6 @@ import pytest -from tests.db.utils import test_locations as consts from urban_meal_delivery import db from urban_meal_delivery.db import utils @@ -55,33 +54,44 @@ class TestProperties: assert result1 is result2 - def test_viewport_overall(self, city): - """Test `City.viewport` property.""" - result = city.viewport - - assert isinstance(result, dict) - assert len(result) == 2 - - @pytest.mark.parametrize('corner', ['northeast', 'southwest']) - def test_viewport_corners(self, city, city_data, corner): - """Test `City.viewport` property.""" - result = city.viewport[corner] + def test_northeast(self, city, city_data): + """Test `City.northeast` property.""" + result = city.northeast assert isinstance(result, utils.Location) - assert result.latitude == pytest.approx(city_data[f'_{corner}_latitude']) - assert result.longitude == pytest.approx(city_data[f'_{corner}_longitude']) + assert result.latitude == pytest.approx(city_data['_northeast_latitude']) + assert result.longitude == pytest.approx(city_data['_northeast_longitude']) - def test_viewport_is_cached(self, city): - """Test `City.viewport` property.""" - result1 = city.viewport - result2 = city.viewport + def test_northeast_is_cached(self, city): + """Test `City.northeast` property.""" + result1 = city.northeast + result2 = city.northeast assert result1 is result2 - def test_city_as_xy_origin(self, city): - """Test `City.as_xy_origin` property.""" - result = city.as_xy_origin + def test_southwest(self, city, city_data): + """Test `City.southwest` property.""" + result = city.southwest - assert result.zone == consts.ZONE - assert consts.MIN_EASTING < result.easting < consts.MAX_EASTING - assert consts.MIN_NORTHING < result.northing < consts.MAX_NORTHING + assert isinstance(result, utils.Location) + assert result.latitude == pytest.approx(city_data['_southwest_latitude']) + assert result.longitude == pytest.approx(city_data['_southwest_longitude']) + + def test_southwest_is_cached(self, city): + """Test `City.southwest` property.""" + result1 = city.southwest + result2 = city.southwest + + assert result1 is result2 + + def test_total_x(self, city): + """Test `City.total_x` property.""" + result = city.total_x + + assert result > 18_000 + + def test_total_y(self, city): + """Test `City.total_y` property.""" + result = city.total_y + + assert result > 9_000 diff --git a/tests/db/test_grids.py b/tests/db/test_grids.py index 0333b64..bcde3f7 100644 --- a/tests/db/test_grids.py +++ b/tests/db/test_grids.py @@ -49,6 +49,18 @@ class TestConstraints: ): db_session.execute(stmt) + def test_two_grids_with_identical_side_length(self, db_session, grid): + """Insert a record that violates a unique constraint.""" + db_session.add(grid) + db_session.commit() + + # Create a `Grid` with the same `.side_length` in the same `.city`. + another_grid = db.Grid(city=grid.city, side_length=grid.side_length) + db_session.add(another_grid) + + with pytest.raises(sa_exc.IntegrityError, match='duplicate key value'): + db_session.commit() + class TestProperties: """Test properties in `Grid`.""" @@ -58,3 +70,86 @@ class TestProperties: result = grid.pixel_area assert result == 1.0 + + +class TestGridification: + """Test the `Grid.gridify()` constructor.""" + + def test_one_pixel_covering_entire_city_without_addresses(self, city): + """At the very least, there must be one `Pixel` ... + + ... if the `side_length` is greater than both the + horizontal and vertical distances of the viewport. + """ + city.addresses = [] + + # `+1` as otherwise there would be a second pixel in one direction. + side_length = max(city.total_x, city.total_y) + 1 + + result = db.Grid.gridify(city=city, side_length=side_length) + + assert isinstance(result, db.Grid) + assert len(result.pixels) == 0 # noqa:WPS507 + + def test_one_pixel_covering_entire_city_with_one_address(self, city, address): + """At the very least, there must be one `Pixel` ... + + ... if the `side_length` is greater than both the + horizontal and vertical distances of the viewport. + """ + city.addresses = [address] + + # `+1` as otherwise there would be a second pixel in one direction. + side_length = max(city.total_x, city.total_y) + 1 + + result = db.Grid.gridify(city=city, side_length=side_length) + + assert isinstance(result, db.Grid) + assert len(result.pixels) == 1 + + def test_four_pixels_with_two_addresses(self, city, make_address): + """Two `Address` objects in distinct `Pixel` objects.""" + # Create two `Address` objects in distinct `Pixel`s. + city.addresses = [ + # One `Address` in the lower-left `Pixel`, ... + make_address(latitude=48.8357377, longitude=2.2517412), + # ... and another one in the upper-right one. + make_address(latitude=48.8898312, longitude=2.4357622), + ] + + side_length = max(city.total_x // 2, city.total_y // 2) + 1 + + # By assumption of the test data. + n_pixels_x = (city.total_x // side_length) + 1 + n_pixels_y = (city.total_y // side_length) + 1 + assert n_pixels_x * n_pixels_y == 4 + + # Create a `Grid` with at most four `Pixel`s. + result = db.Grid.gridify(city=city, side_length=side_length) + + assert isinstance(result, db.Grid) + assert len(result.pixels) == 2 + + @pytest.mark.db + @pytest.mark.no_cover + @pytest.mark.parametrize('side_length', [250, 500, 1_000, 2_000, 4_000, 8_000]) + def test_make_random_grids(self, db_session, city, make_address, side_length): + """With 100 random `Address` objects, a grid must have ... + + ... between 1 and a deterministic number of `Pixel` objects. + + This test creates confidence that the created `Grid` + objects adhere to the database constraints. + """ + city.addresses = [make_address() for _ in range(100)] + + n_pixels_x = (city.total_x // side_length) + 1 + n_pixels_y = (city.total_y // side_length) + 1 + + result = db.Grid.gridify(city=city, side_length=side_length) + + assert isinstance(result, db.Grid) + assert 1 <= len(result.pixels) <= n_pixels_x * n_pixels_y + + db_session.add(result) + db_session.commit() diff --git a/tests/db/test_orders.py b/tests/db/test_orders.py index f23e9bb..9fdde79 100644 --- a/tests/db/test_orders.py +++ b/tests/db/test_orders.py @@ -425,7 +425,7 @@ class TestProperties: @pytest.mark.db @pytest.mark.no_cover -def test_make_random_orders( # noqa:C901,WPS211,WPS210,WPS213,WPS231 +def test_make_random_orders( # noqa:C901,WPS211,WPS213,WPS231 db_session, make_address, make_courier, make_restaurant, make_order, ): """Sanity check the all the `make_*` fixtures. diff --git a/tests/db/utils/test_locations.py b/tests/db/utils/test_locations.py index 1ee4ddf..fff43d2 100644 --- a/tests/db/utils/test_locations.py +++ b/tests/db/utils/test_locations.py @@ -35,7 +35,7 @@ def faraway_location(): @pytest.fixture def origin(city): """A `Location` object based off the one and only `city`.""" - obj = city.as_xy_origin + obj = city.southwest assert obj.zone == ZONE # sanity check