Models
Models should take care of the data model and not much else.
Base model
It's a good idea to define a BaseModel, that you can inherit.
Usually, fields like created_at and updated_at are perfect candidates to go into a BaseModel.
Defining a primary key can also go there. Potential candidate for that is the UUID
Here's an example BaseModel:
import sqlalchemy as sa
class BaseModel(Base):
__abstract__ = True
created_at = sa.Column(sa.DateTime, server_default=sa.func.now())
updated_at = sa.Column(sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.utc_timestamp())
Then, whenever you need a new model, just inherit from BaseModel:
class SomeModel(BaseModel):
pass
Validation
Let's take a look at an example model:
from sqlalchemy.orm import validates
import sqlalchemy as sa
class EmailAddress(Base):
__tablename__ = 'address'
id = sa.Column(sa.Integer, primary_key=True)
email = sa.Column(sa.String)
@validates('email')
def validate_email(self, key, address):
if '@' not in address:
raise ValueError("failed simple email validation")
return address
We are defining the model's validate method, because we want to make sure we get correct data in our database.
Now, the validate method is called, when someone puts data into an instance of our model, before saving.
We have few general rules of thumb for when to add validation in the model's validation method:
- If we are validating based on multiple, non-relational fields, of the model.
- If the validation itself is simple enough.
Validation should be moved to the service layer if:
- The validation logic is more complex.
- We want to validate multiple fields at once.
- Spanning relations & fetching additional data is required.
Info
It's OK to have validation both in validate and in the service, but we tend to move things in the service, if that's the case.
Properties
Model properties are a great way to quickly access a derived value from a model's instance.
For example, let's look at the has_started and has_finished properties of our Course model:
import datetime
import sqlalchemy as sa
class Course(Base):
__tablename__ = 'course'
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String(255), unique=True)
start_date = sa.Column(sa.DateTime)
end_date = sa.Column(sa.DateTime)
@property
def has_started(self) -> bool:
now = datetime.datetime.now()
return self.start_date <= now.date()
@property
def has_finished(self) -> bool:
now = datetime.datetime.now()
return self.end_date <= now.date()
Those properties are handy, because we can now refer to them in serializers or use them in validation.
We have few general rules of thumb, for when to add properties to the model:
- If we need a simple derived value, based on non-relational model fields, add a
@propertyfor that. - If the calculation of the derived value is simple enough.
Properties should be something else (service, selector, utility) in the following cases:
- If we need to span multiple relations or fetch additional data.
- If the calculation is more complex.
Keep in mind that those rules are vague, because context is quite often important. Use your best judgement!
Methods
Model methods are also very powerful tool, that can build on top of properties.
Let's see an example with the is_within(self, x) method:
import datetime
import sqlalchemy as sa
class Course(Base):
__tablename__ = 'course'
id = sa.Column(sa.Integer, primary_key=True)
name = sa.Column(sa.String(255), unique=True)
start_date = sa.Column(sa.DateTime)
end_date = sa.Column(sa.DateTime)
@property
def has_started(self) -> bool:
now = datetime.datetime.now()
return self.start_date <= now.date()
@property
def has_finished(self) -> bool:
now = datetime.datetime.now()
return self.end_date <= now.date()
def is_within(self, x: datetime.date) -> bool:
return self.start_date <= x <= self.end_date
is_within cannot be a property, because it requires an argument. So it's a method instead.
Another great way for using methods in models is using them for attribute setting, when setting one attribute must always be followed by setting another attribute with a derived value.
An example:
import datetime
import sqlalchemy as sa
import uuid
class Token(Base):
__tablename__ = 'token'
id = sa.Column(sa.Integer, primary_key=True)
secret = sa.Column(sa.String(36), unique=True)
expiry = sa.Column(sa.DateTime, nullable=True)
def set_new_secret(self):
now = datetime.datetime.now()
self.secret = str(uuid.uuid4())
self.expiry = now + config.TOKEN_EXPIRY_TIMEDELTA
return self
Now, we can safely call set_new_secret, that'll produce correct values for both secret and expiry.
We have few general rules of thumb, for when to add methods to the model:
- If we need a simple derived value, that requires arguments, based on non-relational model fields, add a method for that.
- If the calculation of the derived value is simple enough.
- If setting one attribute always requires setting values to other attributes, use a method for that.
Models should be something else (service, selector, utility) in the following cases:
- If we need to span multiple relations or fetch additional data.
- If the calculation is more complex.
Keep in mind that those rules are vague, because context is quite often important. Use your best judgement!
Testing
Models need to be tested only if there's something additional to them - like validation, properties or methods.
Here's an example:
import pytest
from project.some_app.models import EmailAddress
class TestEmailAddress:
def test_email_cannot_be_without_at(self):
with pytest.raises(ValueError):
EmailAddress(email='test_email.com')
A few things to note here:
- We assert that a validation error is going to be raised if we pass invalid value.
- We are not hitting the database at all, since there's no need for that. This can speed up certain tests.
Factories
The purpose of a factory is to provide a default way of getting a new instance, while still being able to override some fields on a per-call basis.
An example:
import factory
from project.some_app.models import EmailAddress
class EmailAddressFactory(factory.alchemy.SQLAlchemyModelFactory):
email = factory.Sequence(lambda n: f"test{n}@example.com")
class Meta:
model = EmailAddress
sqlalchemy_session_persistence = "commit"
Now, we can safely call EmailAddressFactory, that'll save new email and return a model.
>> EmailAddressFactory() # Creates a new email
<EmailAddress: test{0}@example.com>
>> EmailAddress.query.all()
[<EmailAddress: test{0}@example.com>]
A few things to note here:
- During tests the
sqlalchemy_sessioninclass Metais set manually in db_session pytest fixture because we are using dynamically created sessions in testing. sqlalchemy_session_persistenceset to commit allows us to automatically save a model. If we skip this setting, a factory will create a model or models that we have to save manually.- Database model factories are only for testing purposes. Main aim is to speed up the process of table population with many relationships.
- For factory creation we use Factory boy