Add ORM models for the pixel grids
- add Grid, Pixel, and AddressPixelAssociation ORM models - each Grid belongs to a City an is characterized by the side_length of all the square Pixels contained in it - Pixels aggregate Addresses => many-to-many relationship (that is modeled with SQLAlchemy's Association Pattern to implement a couple of constraints)
This commit is contained in:
parent
6cb4be80f6
commit
f996376b13
15 changed files with 665 additions and 36 deletions
|
@ -21,7 +21,7 @@ log_config.fileConfig(context.config.config_file_name)
|
||||||
|
|
||||||
def include_object(obj, _name, type_, _reflected, _compare_to):
|
def include_object(obj, _name, type_, _reflected, _compare_to):
|
||||||
"""Only include the clean schema into --autogenerate migrations."""
|
"""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 False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -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)
|
21
setup.cfg
21
setup.cfg
|
@ -89,6 +89,8 @@ extend-ignore =
|
||||||
# Comply with black's style.
|
# Comply with black's style.
|
||||||
# Source: https://github.com/psf/black/blob/master/docs/compatible_configs.md#flake8
|
# Source: https://github.com/psf/black/blob/master/docs/compatible_configs.md#flake8
|
||||||
E203, W503, WPS348,
|
E203, W503, WPS348,
|
||||||
|
# String constant over-use is checked visually by the programmer.
|
||||||
|
WPS226,
|
||||||
# Allow underscores in numbers.
|
# Allow underscores in numbers.
|
||||||
WPS303,
|
WPS303,
|
||||||
# f-strings are ok.
|
# f-strings are ok.
|
||||||
|
@ -114,8 +116,6 @@ per-file-ignores =
|
||||||
WPS114,WPS118,
|
WPS114,WPS118,
|
||||||
# Revisions may have too many expressions.
|
# Revisions may have too many expressions.
|
||||||
WPS204,WPS213,
|
WPS204,WPS213,
|
||||||
# No overuse of string constants (e.g., 'RESTRICT').
|
|
||||||
WPS226,
|
|
||||||
# Too many noqa's are ok.
|
# Too many noqa's are ok.
|
||||||
WPS402,
|
WPS402,
|
||||||
noxfile.py:
|
noxfile.py:
|
||||||
|
@ -125,8 +125,6 @@ per-file-ignores =
|
||||||
WPS202,
|
WPS202,
|
||||||
# TODO (isort): Remove after simplifying the nox session "lint".
|
# TODO (isort): Remove after simplifying the nox session "lint".
|
||||||
WPS213,
|
WPS213,
|
||||||
# No overuse of string constants (e.g., '--version').
|
|
||||||
WPS226,
|
|
||||||
# The noxfile is rather long => allow many noqa's.
|
# The noxfile is rather long => allow many noqa's.
|
||||||
WPS402,
|
WPS402,
|
||||||
src/urban_meal_delivery/configuration.py:
|
src/urban_meal_delivery/configuration.py:
|
||||||
|
@ -134,13 +132,9 @@ per-file-ignores =
|
||||||
WPS115,
|
WPS115,
|
||||||
# Numbers are normal in config files.
|
# Numbers are normal in config files.
|
||||||
WPS432,
|
WPS432,
|
||||||
# No real string constant over-use.
|
src/urban_meal_delivery/db/__init__.py:
|
||||||
src/urban_meal_delivery/db/addresses.py:
|
# Top-level of a sub-packages is intended to import a lot.
|
||||||
WPS226,
|
F401,
|
||||||
src/urban_meal_delivery/db/cities.py:
|
|
||||||
WPS226,
|
|
||||||
src/urban_meal_delivery/db/orders.py:
|
|
||||||
WPS226,
|
|
||||||
tests/*.py:
|
tests/*.py:
|
||||||
# Type annotations are not strictly enforced.
|
# Type annotations are not strictly enforced.
|
||||||
ANN0, ANN2,
|
ANN0, ANN2,
|
||||||
|
@ -158,14 +152,15 @@ per-file-ignores =
|
||||||
WPS202,WPS204,WPS214,
|
WPS202,WPS204,WPS214,
|
||||||
# Do not check for Jones complexity in the test suite.
|
# Do not check for Jones complexity in the test suite.
|
||||||
WPS221,
|
WPS221,
|
||||||
# No overuse of string constants (e.g., '__version__').
|
|
||||||
WPS226,
|
|
||||||
# We do not care about the number of "# noqa"s in the test suite.
|
# We do not care about the number of "# noqa"s in the test suite.
|
||||||
WPS402,
|
WPS402,
|
||||||
# Allow closures.
|
# Allow closures.
|
||||||
WPS430,
|
WPS430,
|
||||||
# Numbers are normal in test cases as expected results.
|
# Numbers are normal in test cases as expected results.
|
||||||
WPS432,
|
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
|
# Explicitly set mccabe's maximum complexity to 10 as recommended by
|
||||||
# Thomas McCabe, the inventor of the McCabe complexity, and the NIST.
|
# Thomas McCabe, the inventor of the McCabe complexity, and the NIST.
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
"""Provide the ORM models and a connection to the database."""
|
"""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.addresses import Address
|
||||||
from urban_meal_delivery.db.cities import City # noqa:F401
|
from urban_meal_delivery.db.addresses_pixels import AddressPixelAssociation
|
||||||
from urban_meal_delivery.db.connection import make_engine # noqa:F401
|
from urban_meal_delivery.db.cities import City
|
||||||
from urban_meal_delivery.db.connection import make_session_factory # noqa:F401
|
from urban_meal_delivery.db.connection import make_engine
|
||||||
from urban_meal_delivery.db.couriers import Courier # noqa:F401
|
from urban_meal_delivery.db.connection import make_session_factory
|
||||||
from urban_meal_delivery.db.customers import Customer # noqa:F401
|
from urban_meal_delivery.db.couriers import Courier
|
||||||
from urban_meal_delivery.db.meta import Base # noqa:F401
|
from urban_meal_delivery.db.customers import Customer
|
||||||
from urban_meal_delivery.db.orders import Order # noqa:F401
|
from urban_meal_delivery.db.grids import Grid
|
||||||
from urban_meal_delivery.db.restaurants import Restaurant # noqa:F401
|
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
|
||||||
|
|
|
@ -46,6 +46,8 @@ class Address(meta.Base):
|
||||||
'-180 <= longitude AND longitude <= 180',
|
'-180 <= longitude AND longitude <= 180',
|
||||||
name='longitude_between_180_degrees',
|
name='longitude_between_180_degrees',
|
||||||
),
|
),
|
||||||
|
# Needed by a `ForeignKeyConstraint` in `AddressPixelAssociation`.
|
||||||
|
sa.UniqueConstraint('id', 'city_id'),
|
||||||
sa.CheckConstraint(
|
sa.CheckConstraint(
|
||||||
'30000 <= zip_code AND zip_code <= 99999', name='valid_zip_code',
|
'30000 <= zip_code AND zip_code <= 99999', name='valid_zip_code',
|
||||||
),
|
),
|
||||||
|
@ -60,12 +62,12 @@ class Address(meta.Base):
|
||||||
back_populates='pickup_address',
|
back_populates='pickup_address',
|
||||||
foreign_keys='[Order._pickup_address_id]',
|
foreign_keys='[Order._pickup_address_id]',
|
||||||
)
|
)
|
||||||
|
|
||||||
orders_delivered = orm.relationship(
|
orders_delivered = orm.relationship(
|
||||||
'Order',
|
'Order',
|
||||||
back_populates='delivery_address',
|
back_populates='delivery_address',
|
||||||
foreign_keys='[Order._delivery_address_id]',
|
foreign_keys='[Order._delivery_address_id]',
|
||||||
)
|
)
|
||||||
|
pixels = orm.relationship('AddressPixelAssociation', back_populates='address')
|
||||||
|
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
"""Create a new address."""
|
"""Create a new address."""
|
||||||
|
|
56
src/urban_meal_delivery/db/addresses_pixels.py
Normal file
56
src/urban_meal_delivery/db/addresses_pixels.py
Normal file
|
@ -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')
|
|
@ -45,6 +45,7 @@ class City(meta.Base):
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
addresses = orm.relationship('Address', back_populates='city')
|
addresses = orm.relationship('Address', back_populates='city')
|
||||||
|
grids = orm.relationship('Grid', back_populates='city')
|
||||||
|
|
||||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
"""Create a new city."""
|
"""Create a new city."""
|
||||||
|
|
48
src/urban_meal_delivery/db/grids.py
Normal file
48
src/urban_meal_delivery/db/grids.py
Normal file
|
@ -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
|
59
src/urban_meal_delivery/db/pixels.py
Normal file
59
src/urban_meal_delivery/db/pixels.py
Normal file
|
@ -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
|
|
@ -85,3 +85,5 @@ courier = fake_data.courier
|
||||||
customer = fake_data.customer
|
customer = fake_data.customer
|
||||||
order = fake_data.order
|
order = fake_data.order
|
||||||
restaurant = fake_data.restaurant
|
restaurant = fake_data.restaurant
|
||||||
|
grid = fake_data.grid
|
||||||
|
pixel = fake_data.pixel
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
"""Fixtures for testing the ORM layer with fake data."""
|
"""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_address
|
||||||
from tests.db.fake_data.fixture_makers import make_courier # noqa:F401
|
from tests.db.fake_data.fixture_makers import make_courier
|
||||||
from tests.db.fake_data.fixture_makers import make_customer # noqa:F401
|
from tests.db.fake_data.fixture_makers import make_customer
|
||||||
from tests.db.fake_data.fixture_makers import make_order # noqa:F401
|
from tests.db.fake_data.fixture_makers import make_order
|
||||||
from tests.db.fake_data.fixture_makers import make_restaurant # noqa:F401
|
from tests.db.fake_data.fixture_makers import make_restaurant
|
||||||
from tests.db.fake_data.static_fixtures import address # noqa:F401
|
from tests.db.fake_data.static_fixtures import address
|
||||||
from tests.db.fake_data.static_fixtures import city # noqa:F401
|
from tests.db.fake_data.static_fixtures import city
|
||||||
from tests.db.fake_data.static_fixtures import city_data # noqa:F401
|
from tests.db.fake_data.static_fixtures import city_data
|
||||||
from tests.db.fake_data.static_fixtures import courier # noqa:F401
|
from tests.db.fake_data.static_fixtures import courier
|
||||||
from tests.db.fake_data.static_fixtures import customer # noqa:F401
|
from tests.db.fake_data.static_fixtures import customer
|
||||||
from tests.db.fake_data.static_fixtures import order # noqa:F401
|
from tests.db.fake_data.static_fixtures import grid
|
||||||
from tests.db.fake_data.static_fixtures import restaurant # noqa:F401
|
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
|
||||||
|
|
|
@ -56,3 +56,15 @@ def restaurant(address, make_restaurant):
|
||||||
def order(make_order, restaurant):
|
def order(make_order, restaurant):
|
||||||
"""An `Order` object for the `restaurant`."""
|
"""An `Order` object for the `restaurant`."""
|
||||||
return make_order(restaurant=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)
|
||||||
|
|
136
tests/db/test_addresses_pixels.py
Normal file
136
tests/db/test_addresses_pixels.py
Normal file
|
@ -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()
|
60
tests/db/test_grids.py
Normal file
60
tests/db/test_grids.py
Normal file
|
@ -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'<Grid: {grid.pixel_area}>'
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
90
tests/db/test_pixels.py
Normal file
90
tests/db/test_pixels.py
Normal file
|
@ -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'<Pixel: ({pixel.n_x}, {pixel.n_y})>'
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
Loading…
Reference in a new issue