diff --git a/src/urban_meal_delivery/db/__init__.py b/src/urban_meal_delivery/db/__init__.py index 5f57bf9..6161657 100644 --- a/src/urban_meal_delivery/db/__init__.py +++ b/src/urban_meal_delivery/db/__init__.py @@ -1,7 +1,7 @@ """Provide the ORM models and a connection to the database.""" from urban_meal_delivery.db.addresses import Address -from urban_meal_delivery.db.addresses_addresses import DistanceMatrix +from urban_meal_delivery.db.addresses_addresses import Path from urban_meal_delivery.db.addresses_pixels import AddressPixelAssociation from urban_meal_delivery.db.cities import City from urban_meal_delivery.db.connection import connection diff --git a/src/urban_meal_delivery/db/addresses_addresses.py b/src/urban_meal_delivery/db/addresses_addresses.py index 6bf7769..2f91e2c 100644 --- a/src/urban_meal_delivery/db/addresses_addresses.py +++ b/src/urban_meal_delivery/db/addresses_addresses.py @@ -1,4 +1,4 @@ -"""Model for the relationship between two `Address` objects (= distance matrix).""" +"""Model for the `Path` relationship between two `Address` objects.""" from __future__ import annotations @@ -20,14 +20,14 @@ from urban_meal_delivery.db import meta from urban_meal_delivery.db import utils -class DistanceMatrix(meta.Base): - """Distance matrix between `Address` objects. +class Path(meta.Base): + """Path between two `Address` objects. - Models the pairwise distances between two `Address` objects, - including directions for a `Courier` to get from one `Address` to another. + Models the path between two `Address` objects, including directions + for a `Courier` to get from one `Address` to another. - As the couriers are on bicycles, we model the distance matrix - as a symmetric graph (i.e., same distance in both directions). + As the couriers are on bicycles, we model the paths as + a symmetric graph (i.e., same distance in both directions). Implements an association pattern between `Address` and `Address`. @@ -88,37 +88,36 @@ class DistanceMatrix(meta.Base): # Relationships first_address = orm.relationship( - 'Address', - foreign_keys='[DistanceMatrix.first_address_id, DistanceMatrix.city_id]', + 'Address', foreign_keys='[Path.first_address_id, Path.city_id]', ) second_address = orm.relationship( 'Address', - foreign_keys='[DistanceMatrix.second_address_id, DistanceMatrix.city_id]', + foreign_keys='[Path.second_address_id, Path.city_id]', overlaps='first_address', ) @classmethod def from_addresses( cls, *addresses: db.Address, google_maps: bool = False, - ) -> List[DistanceMatrix]: - """Calculate pair-wise distances for `Address` objects. + ) -> List[Path]: + """Calculate pair-wise paths for `Address` objects. This is the main constructor method for the class. It handles the "sorting" of the `Address` objects by `.id`, which is - the logic that enforces the symmetric graph behind the distances. + the logic that enforces the symmetric graph behind the paths. Args: - *addresses: to calculate the pair-wise distances for; + *addresses: to calculate the pair-wise paths for; must contain at least two `Address` objects google_maps: if `.bicycle_distance` and `._directions` should be populated with a query to the Google Maps Directions API; by default, only the `.air_distance` is calculated with `geopy` Returns: - distances + paths """ - distances = [] + paths = [] # We consider all 2-tuples of `Address`es. The symmetric graph is ... for first, second in itertools.combinations(addresses, 2): @@ -127,35 +126,35 @@ class DistanceMatrix(meta.Base): (first, second) if first.id < second.id else (second, first) ) - # If there is no `DistanceMatrix` object in the database ... - distance = ( - db.session.query(db.DistanceMatrix) - .filter(db.DistanceMatrix.first_address == first) - .filter(db.DistanceMatrix.second_address == second) + # If there is no `Path` object in the database ... + path = ( + db.session.query(db.Path) + .filter(db.Path.first_address == first) + .filter(db.Path.second_address == second) .first() ) # ... create a new one. - if distance is None: + if path is None: air_distance = geo_distance.great_circle( first.location.lat_lng, second.location.lat_lng, ) - distance = cls( + path = cls( first_address=first, second_address=second, air_distance=round(air_distance.meters), ) - db.session.add(distance) + db.session.add(path) db.session.commit() - distances.append(distance) + paths.append(path) if google_maps: - for distance in distances: # noqa:WPS440 - distance.sync_with_google_maps() + for path in paths: # noqa:WPS440 + path.sync_with_google_maps() - return distances + return paths def sync_with_google_maps(self) -> None: """Fill in `.bicycle_distance` and `._directions` with Google Maps. @@ -210,18 +209,16 @@ class DistanceMatrix(meta.Base): db.session.commit() @functools.cached_property - def path(self) -> List[utils.Location]: - """The couriers' path from `.first_address` to `.second_address`. + def waypoints(self) -> List[utils.Location]: + """The couriers' route from `.first_address` to `.second_address`. - The returned `Location`s all relates to `.first_address.city.southwest`. + The returned `Location`s all relate to `.first_address.city.southwest`. Implementation detail: This property is cached as none of the underlying attributes (i.e., `._directions`) are to be changed. """ - inner_points = [ - utils.Location(*point) for point in json.loads(self._directions) - ] - for point in inner_points: + points = [utils.Location(*point) for point in json.loads(self._directions)] + for point in points: point.relate_to(self.first_address.city.southwest) - return inner_points + return points diff --git a/tests/db/test_addresses_addresses.py b/tests/db/test_addresses_addresses.py index 6d21a12..2404eb4 100644 --- a/tests/db/test_addresses_addresses.py +++ b/tests/db/test_addresses_addresses.py @@ -1,4 +1,4 @@ -"""Test the ORM's `DistanceMatrix` model.""" +"""Test the ORM's `Path` model.""" import json @@ -19,8 +19,8 @@ def another_address(make_address): @pytest.fixture -def assoc(address, another_address, make_address): - """An association between `address` and `another_address`.""" +def path(address, another_address, make_address): + """A `Path` from `address` to `another_address`.""" air_distance = distance.great_circle( # noqa:WPS317 address.location.lat_lng, another_address.location.lat_lng, ).meters @@ -34,7 +34,7 @@ def assoc(address, another_address, make_address): ], ) - return db.DistanceMatrix( + return db.Path( first_address=address, second_address=another_address, air_distance=round(air_distance), @@ -45,34 +45,34 @@ def assoc(address, another_address, make_address): class TestSpecialMethods: - """Test special methods in `DistanceMatrix`.""" + """Test special methods in `Path`.""" - def test_create_an_address_address_association(self, assoc): - """Test instantiation of a new `DistanceMatrix` object.""" - assert assoc is not None + def test_create_an_address_address_association(self, path): + """Test instantiation of a new `Path` object.""" + assert path is not None @pytest.mark.db @pytest.mark.no_cover class TestConstraints: - """Test the database constraints defined in `DistanceMatrix`.""" + """Test the database constraints defined in `Path`.""" - def test_insert_into_database(self, db_session, assoc): + def test_insert_into_database(self, db_session, path): """Insert an instance into the (empty) database.""" - assert db_session.query(db.DistanceMatrix).count() == 0 + assert db_session.query(db.Path).count() == 0 - db_session.add(assoc) + db_session.add(path) db_session.commit() - assert db_session.query(db.DistanceMatrix).count() == 1 + assert db_session.query(db.Path).count() == 1 - def test_delete_a_referenced_first_address(self, db_session, assoc): + def test_delete_a_referenced_first_address(self, db_session, path): """Remove a record that is referenced with a FK.""" - db_session.add(assoc) + db_session.add(path) db_session.commit() # Must delete without ORM as otherwise an UPDATE statement is emitted. - stmt = sqla.delete(db.Address).where(db.Address.id == assoc.first_address.id) + stmt = sqla.delete(db.Address).where(db.Address.id == path.first_address.id) with pytest.raises( sa_exc.IntegrityError, @@ -80,13 +80,13 @@ class TestConstraints: ): db_session.execute(stmt) - def test_delete_a_referenced_second_address(self, db_session, assoc): + def test_delete_a_referenced_second_address(self, db_session, path): """Remove a record that is referenced with a FK.""" - db_session.add(assoc) + db_session.add(path) db_session.commit() # Must delete without ORM as otherwise an UPDATE statement is emitted. - stmt = sqla.delete(db.Address).where(db.Address.id == assoc.second_address.id) + stmt = sqla.delete(db.Address).where(db.Address.id == path.second_address.id) with pytest.raises( sa_exc.IntegrityError, @@ -102,7 +102,7 @@ class TestConstraints: # 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.DistanceMatrix).values( + stmt = sqla.insert(db.Path).values( first_address_id=address.id, second_address_id=another_address.id, city_id=999, @@ -115,34 +115,34 @@ class TestConstraints: ): db_session.execute(stmt) - def test_redundant_addresses(self, db_session, assoc): + def test_redundant_addresses(self, db_session, path): """Insert a record that violates a unique constraint.""" - db_session.add(assoc) + db_session.add(path) 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.DistanceMatrix).values( - first_address_id=assoc.first_address.id, - second_address_id=assoc.second_address.id, - city_id=assoc.city_id, - air_distance=assoc.air_distance, + stmt = sqla.insert(db.Path).values( + first_address_id=path.first_address.id, + second_address_id=path.second_address.id, + city_id=path.city_id, + air_distance=path.air_distance, ) with pytest.raises(sa_exc.IntegrityError, match='duplicate key value'): db_session.execute(stmt) - def test_symmetric_addresses(self, db_session, assoc): + def test_symmetric_addresses(self, db_session, path): """Insert a record that violates a check constraint.""" - db_session.add(assoc) + db_session.add(path) db_session.commit() - another_assoc = db.DistanceMatrix( - first_address=assoc.second_address, - second_address=assoc.first_address, - air_distance=assoc.air_distance, + another_path = db.Path( + first_address=path.second_address, + second_address=path.first_address, + air_distance=path.air_distance, ) - db_session.add(another_assoc) + db_session.add(another_path) with pytest.raises( sa_exc.IntegrityError, @@ -150,44 +150,44 @@ class TestConstraints: ): db_session.commit() - def test_negative_air_distance(self, db_session, assoc): + def test_negative_air_distance(self, db_session, path): """Insert an instance with invalid data.""" - assoc.air_distance = -1 - db_session.add(assoc) + path.air_distance = -1 + db_session.add(path) with pytest.raises(sa_exc.IntegrityError, match='realistic_air_distance'): db_session.commit() - def test_air_distance_too_large(self, db_session, assoc): + def test_air_distance_too_large(self, db_session, path): """Insert an instance with invalid data.""" - assoc.air_distance = 20_000 - assoc.bicycle_distance = 21_000 - db_session.add(assoc) + path.air_distance = 20_000 + path.bicycle_distance = 21_000 + db_session.add(path) with pytest.raises(sa_exc.IntegrityError, match='realistic_air_distance'): db_session.commit() - def test_bicycle_distance_too_large(self, db_session, assoc): + def test_bicycle_distance_too_large(self, db_session, path): """Insert an instance with invalid data.""" - assoc.bicycle_distance = 25_000 - db_session.add(assoc) + path.bicycle_distance = 25_000 + db_session.add(path) with pytest.raises(sa_exc.IntegrityError, match='realistic_bicycle_distance'): db_session.commit() - def test_air_distance_shorter_than_bicycle_distance(self, db_session, assoc): + def test_air_distance_shorter_than_bicycle_distance(self, db_session, path): """Insert an instance with invalid data.""" - assoc.bicycle_distance = round(0.75 * assoc.air_distance) - db_session.add(assoc) + path.bicycle_distance = round(0.75 * path.air_distance) + db_session.add(path) with pytest.raises(sa_exc.IntegrityError, match='air_distance_is_shortest'): db_session.commit() @pytest.mark.parametrize('duration', [-1, 3601]) - def test_unrealistic_bicycle_travel_time(self, db_session, assoc, duration): + def test_unrealistic_bicycle_travel_time(self, db_session, path, duration): """Insert an instance with invalid data.""" - assoc.bicycle_duration = duration - db_session.add(assoc) + path.bicycle_duration = duration + db_session.add(path) with pytest.raises( sa_exc.IntegrityError, match='realistic_bicycle_travel_time', @@ -197,47 +197,47 @@ class TestConstraints: @pytest.mark.db class TestFromAddresses: - """Test the alternative constructor `DistanceMatrix.from_addresses()`.""" + """Test the alternative constructor `Path.from_addresses()`.""" @pytest.fixture def _prepare_db(self, db_session, address): """Put the `address` into the database. `Address`es must be in the database as otherwise the `.city_id` column - cannot be resolved in `DistanceMatrix.from_addresses()`. + cannot be resolved in `Path.from_addresses()`. """ db_session.add(address) @pytest.mark.usefixtures('_prepare_db') - def test_make_distance_matrix_instance( + def test_make_path_instance( self, db_session, address, another_address, ): - """Test instantiation of a new `DistanceMatrix` instance.""" - assert db_session.query(db.DistanceMatrix).count() == 0 + """Test instantiation of a new `Path` instance.""" + assert db_session.query(db.Path).count() == 0 - db.DistanceMatrix.from_addresses(address, another_address) + db.Path.from_addresses(address, another_address) - assert db_session.query(db.DistanceMatrix).count() == 1 + assert db_session.query(db.Path).count() == 1 @pytest.mark.usefixtures('_prepare_db') - def test_make_the_same_distance_matrix_instance_twice( + def test_make_the_same_path_instance_twice( self, db_session, address, another_address, ): - """Test instantiation of a new `DistanceMatrix` instance.""" - assert db_session.query(db.DistanceMatrix).count() == 0 + """Test instantiation of a new `Path` instance.""" + assert db_session.query(db.Path).count() == 0 - db.DistanceMatrix.from_addresses(address, another_address) + db.Path.from_addresses(address, another_address) - assert db_session.query(db.DistanceMatrix).count() == 1 + assert db_session.query(db.Path).count() == 1 - db.DistanceMatrix.from_addresses(another_address, address) + db.Path.from_addresses(another_address, address) - assert db_session.query(db.DistanceMatrix).count() == 1 + assert db_session.query(db.Path).count() == 1 @pytest.mark.usefixtures('_prepare_db') def test_structure_of_return_value(self, db_session, address, another_address): - """Test instantiation of a new `DistanceMatrix` instance.""" - results = db.DistanceMatrix.from_addresses(address, another_address) + """Test instantiation of a new `Path` instance.""" + results = db.Path.from_addresses(address, another_address) assert isinstance(results, list) @@ -245,10 +245,10 @@ class TestFromAddresses: def test_instances_must_have_air_distance( self, db_session, address, another_address, ): - """Test instantiation of a new `DistanceMatrix` instance.""" - distances = db.DistanceMatrix.from_addresses(address, another_address) + """Test instantiation of a new `Path` instance.""" + paths = db.Path.from_addresses(address, another_address) - result = distances[0] + result = paths[0] assert result.air_distance is not None @@ -256,10 +256,10 @@ class TestFromAddresses: def test_do_not_sync_instances_with_google_maps( self, db_session, address, another_address, ): - """Test instantiation of a new `DistanceMatrix` instance.""" - distances = db.DistanceMatrix.from_addresses(address, another_address) + """Test instantiation of a new `Path` instance.""" + paths = db.Path.from_addresses(address, another_address) - result = distances[0] + result = paths[0] assert result.bicycle_distance is None assert result.bicycle_duration is None @@ -268,52 +268,46 @@ class TestFromAddresses: def test_sync_instances_with_google_maps( self, db_session, address, another_address, monkeypatch, ): - """Test instantiation of a new `DistanceMatrix` instance.""" + """Test instantiation of a new `Path` instance.""" def sync(self): self.bicycle_distance = 1.25 * self.air_distance self.bicycle_duration = 300 - monkeypatch.setattr(db.DistanceMatrix, 'sync_with_google_maps', sync) + monkeypatch.setattr(db.Path, 'sync_with_google_maps', sync) - distances = db.DistanceMatrix.from_addresses( - address, another_address, google_maps=True, - ) + paths = db.Path.from_addresses(address, another_address, google_maps=True) - result = distances[0] + result = paths[0] assert result.bicycle_distance is not None assert result.bicycle_duration is not None @pytest.mark.usefixtures('_prepare_db') - def test_one_distance_for_two_addresses(self, db_session, address, another_address): - """Test instantiation of a new `DistanceMatrix` instance.""" - result = len(db.DistanceMatrix.from_addresses(address, another_address)) + def test_one_path_for_two_addresses(self, db_session, address, another_address): + """Test instantiation of a new `Path` instance.""" + result = len(db.Path.from_addresses(address, another_address)) assert result == 1 @pytest.mark.usefixtures('_prepare_db') - def test_two_distances_for_three_addresses(self, db_session, make_address): - """Test instantiation of a new `DistanceMatrix` instance.""" - result = len( - db.DistanceMatrix.from_addresses(*[make_address() for _ in range(3)]), - ) + def test_two_paths_for_three_addresses(self, db_session, make_address): + """Test instantiation of a new `Path` instance.""" + result = len(db.Path.from_addresses(*[make_address() for _ in range(3)])) assert result == 3 @pytest.mark.usefixtures('_prepare_db') - def test_six_distances_for_four_addresses(self, db_session, make_address): - """Test instantiation of a new `DistanceMatrix` instance.""" - result = len( - db.DistanceMatrix.from_addresses(*[make_address() for _ in range(4)]), - ) + def test_six_paths_for_four_addresses(self, db_session, make_address): + """Test instantiation of a new `Path` instance.""" + result = len(db.Path.from_addresses(*[make_address() for _ in range(4)])) assert result == 6 @pytest.mark.db class TestSyncWithGoogleMaps: - """Test the `DistanceMatrix.sync_with_google_maps()` method.""" + """Test the `Path.sync_with_google_maps()` method.""" @pytest.fixture def api_response(self): @@ -575,56 +569,58 @@ class TestSyncWithGoogleMaps: monkeypatch.setattr(googlemaps.Client, 'directions', directions) @pytest.mark.usefixtures('_fake_google_api') - def test_sync_instances_with_google_maps(self, db_session, assoc): - """Call the method for a `DistanceMatrix` object without Google data.""" - assoc.bicycle_distance = None - assoc.bicycle_duration = None - assoc._directions = None + def test_sync_instances_with_google_maps(self, db_session, path): + """Call the method for a `Path` object without Google data.""" + path.bicycle_distance = None + path.bicycle_duration = None + path._directions = None - assoc.sync_with_google_maps() + path.sync_with_google_maps() - assert assoc.bicycle_distance == 2_999 - assert assoc.bicycle_duration == 596 - assert assoc._directions is not None + assert path.bicycle_distance == 2_999 + assert path.bicycle_duration == 596 + assert path._directions is not None @pytest.mark.usefixtures('_fake_google_api') - def test_repeated_sync_instances_with_google_maps(self, db_session, assoc): - """Call the method for a `DistanceMatrix` object with Google data. + def test_repeated_sync_instances_with_google_maps(self, db_session, path): + """Call the method for a `Path` object with Google data. That call should immediately return without changing any data. - We use the `assoc`'s "Google" data from above. + We use the `path`'s "Google" data from above. """ - old_distance = assoc.bicycle_distance - old_duration = assoc.bicycle_duration - old_directions = assoc._directions + old_distance = path.bicycle_distance + old_duration = path.bicycle_duration + old_directions = path._directions - assoc.sync_with_google_maps() + path.sync_with_google_maps() - assert assoc.bicycle_distance is old_distance - assert assoc.bicycle_duration is old_duration - assert assoc._directions is old_directions + assert path.bicycle_distance is old_distance + assert path.bicycle_duration is old_duration + assert path._directions is old_directions class TestProperties: - """Test properties in `DistanceMatrix`.""" + """Test properties in `Path`.""" - def test_path_structure(self, assoc): - """Test `DistanceMatrix.path` property.""" - result = assoc.path + def test_waypoints_structure(self, path): + """Test `Path.waypoints` property.""" + result = path.waypoints assert isinstance(result, list) assert isinstance(result[0], utils.Location) - def test_path_content(self, assoc): - """Test `DistanceMatrix.path` property.""" - result = assoc.path + def test_waypoints_content(self, path): + """Test `Path.waypoints` property.""" + result = path.waypoints - assert len(result) == 5 # = 5 inner points, excluding start and end + # There are 5 inner points, excluding start and end, + # i.e., the `.first_address` and `second_address`. + assert len(result) == 5 - def test_path_is_cached(self, assoc): - """Test `DistanceMatrix.path` property.""" - result1 = assoc.path - result2 = assoc.path + def test_waypoints_is_cached(self, path): + """Test `Path.waypoints` property.""" + result1 = path.waypoints + result2 = path.waypoints assert result1 is result2