Implement BaseModel class, TimestampMixin, TzDateTime type; cleanup Task model

This commit is contained in:
Lexi / Zoe 2022-04-15 21:45:10 +02:00
parent 7ce43a2bfe
commit 252c15acbd
Signed by: binaryDiv
GPG Key ID: F8D4956E224DA232
15 changed files with 223 additions and 42 deletions

View File

@ -26,16 +26,22 @@ def process_revision_directives(_context, _revision, directives):
directives[:] = [] directives[:] = []
context_parameters = {
'target_metadata': BaseModel.metadata,
'process_revision_directives': process_revision_directives,
'user_module_prefix': 'tofu_types.',
}
def run_migrations_offline(): def run_migrations_offline():
""" """
Run migrations in 'offline' mode, which does not require an actual database engine and can be used to generate SQL scripts. Run migrations in 'offline' mode, which does not require an actual database engine and can be used to generate SQL scripts.
""" """
context.configure( context.configure(
url=app.config.sqlalchemy_database_uri, url=app.config.sqlalchemy_database_uri,
target_metadata=BaseModel.metadata,
process_revision_directives=process_revision_directives,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
**context_parameters,
) )
with context.begin_transaction(): with context.begin_transaction():
@ -49,8 +55,7 @@ def run_migrations_online():
with db.engine.connect() as connection: with db.engine.connect() as connection:
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=BaseModel.metadata, **context_parameters,
process_revision_directives=process_revision_directives,
) )
with context.begin_transaction(): with context.begin_transaction():

View File

@ -8,6 +8,8 @@ Create Date: ${create_date}
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import tofu_api.common.database.types as tofu_types
${imports if imports else ""} ${imports if imports else ""}
# Revision identifiers, used by Alembic. # Revision identifiers, used by Alembic.

View File

@ -1,16 +1,18 @@
""" """
Initial revision Initial revision
Revision ID: 716726b4a1fe Revision ID: 044f3afd19b0
Revises: Revises:
Create Date: 2022-04-15 16:05:29.358429+02:00 Create Date: 2022-04-15 21:41:39.962542+02:00
""" """
from alembic import op from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
import tofu_api.common.database.types as tofu_types
# Revision identifiers, used by Alembic. # Revision identifiers, used by Alembic.
revision = '716726b4a1fe' revision = '044f3afd19b0'
down_revision = None down_revision = None
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@ -19,16 +21,18 @@ depends_on = None
def upgrade(): def upgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table( op.create_table(
'tasks', 'task',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', tofu_types.TzDateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('modified_at', tofu_types.TzDateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False), sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=False), sa.Column('description', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tasks')) sa.PrimaryKeyConstraint('id', name=op.f('pk_task'))
) )
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade(): def downgrade():
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.drop_table('tasks') op.drop_table('task')
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@ -6,6 +6,7 @@ from flask import Flask
from tofu_api.api import TofuApiBlueprint from tofu_api.api import TofuApiBlueprint
from tofu_api.common.config import Config from tofu_api.common.config import Config
from tofu_api.common.json import JSONEncoder
from tofu_api.dependencies import Dependencies from tofu_api.dependencies import Dependencies
# Enable deprecation warnings in dev environment # Enable deprecation warnings in dev environment
@ -19,6 +20,7 @@ class App(Flask):
""" """
# Override Flask classes # Override Flask classes
config_class = Config config_class = Config
json_encoder = JSONEncoder
# Set type hint for config # Set type hint for config
config: Config config: Config
@ -57,7 +59,7 @@ class App(Flask):
db = self.dependencies.get_sqlalchemy() db = self.dependencies.get_sqlalchemy()
db.init_database(self) db.init_database(self)
# Import models to fill the metadata object # Import models to populate the database metadata
import tofu_api.models # noqa (unused import) import tofu_api.models # noqa (unused import)

View File

@ -1 +1,3 @@
from .metadata import MetaData
from .sqlalchemy import SQLAlchemy from .sqlalchemy import SQLAlchemy
from .typing import Col, Rel

View File

@ -0,0 +1,25 @@
from sqlalchemy import MetaData as _MetaData
__all__ = [
'MetaData',
]
class MetaData(_MetaData):
"""
App specific subclass of the SQLAlchemy MetaData class.
"""
# Define naming convention for constraints
_naming_convention = {
"ix": 'ix_%(column_0_label)s',
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
def __init__(self, *args, naming_convention=None, **kwargs):
if not naming_convention:
naming_convention = self._naming_convention
super().__init__(*args, naming_convention=naming_convention, **kwargs)

View File

@ -0,0 +1,23 @@
from datetime import datetime
from sqlalchemy import Column, func
from sqlalchemy.orm import declarative_mixin
from tofu_api.common.database import Col
from tofu_api.common.database.types import TzDateTime
__all__ = [
'TimestampMixin'
]
@declarative_mixin
class TimestampMixin:
"""
Mixin for database models that provides the "created_at" and "modified_at" columns.
"""
# Created timestamp (automatically set to NOW() once on object creation)
created_at: Col[datetime] = Column(TzDateTime, nullable=False, server_default=func.now())
# Modified timestamp (automatically set to NOW() on each update)
modified_at: Col[datetime] = Column(TzDateTime, nullable=False, server_default=func.now(), onupdate=func.now())

View File

@ -0,0 +1 @@
from .tz_date_time import TzDateTime

View File

@ -0,0 +1,39 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, TypeDecorator
__all__ = [
'TzDateTime',
]
class TzDateTime(TypeDecorator):
"""
Custom SQLAlchemy data type for timezone aware datetimes.
"""
impl = DateTime
cache_ok = True
@property
def python_type(self):
return datetime
def process_bind_param(self, value: datetime, dialect):
"""
Convert a datetime object that is bound to a query parameter.
"""
if value is not None and value.tzinfo:
value = value.astimezone(timezone.utc).replace(tzinfo=None)
return value
def process_result_value(self, value: datetime, dialect):
"""
Convert a datetime object from a query result.
"""
return value.replace(tzinfo=timezone.utc) if value is not None else None
def process_literal_param(self, value: datetime, dialect):
"""
Convert a literal parameter value to be rendered inline within a statement.
"""
return self.process_bind_param(value, dialect)

View File

@ -0,0 +1,14 @@
from typing import TypeVar, Union
from sqlalchemy import Column
from sqlalchemy.orm import RelationshipProperty
__all__ = [
'Col',
'Rel',
]
# Define type aliases for SQLAlchemy columns and relationships in declarative models
_T = TypeVar('_T')
Col = Union[Column, _T]
Rel = Union[RelationshipProperty, _T]

View File

@ -0,0 +1 @@
from .json_encoder import JSONEncoder

View File

@ -0,0 +1,29 @@
from datetime import datetime
from typing import Any
from flask.json import JSONEncoder as _FlaskJSONEncoder
__all__ = [
'JSONEncoder',
]
class JSONEncoder(_FlaskJSONEncoder):
"""
Custom JSON encoder built on top of the Flask JSONEncoder class.
"""
def default(self, obj: Any) -> Any:
"""
Convert any object to a JSON serializable type.
"""
# Convert datetimes to ISO format without microseconds (e.g. '2022-01-02T10:20:30+00:00')
if isinstance(obj, datetime):
return obj.isoformat(timespec='seconds')
# Use to_dict() method on objects that have it
if hasattr(obj, 'to_dict'):
return obj.to_dict()
# Fallback to the Flask JSONEncoder
return super().default(obj)

View File

@ -1,5 +1,5 @@
# Base model first # Base model first
from .base import metadata, BaseModel from .base import BaseModel
# Data models # Data models
from .task import Task from .task import Task

View File

@ -1,22 +1,62 @@
from sqlalchemy import MetaData from typing import Any, Iterable, Optional
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, Integer, inspect
from sqlalchemy.orm import InstanceState, as_declarative
from tofu_api.common.database import Col, MetaData
__all__ = [ __all__ = [
'metadata',
'BaseModel', 'BaseModel',
] ]
# Define naming convention for constraints
_naming_convention = {
"ix": 'ix_%(column_0_label)s',
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
# Create metadata object for database schemas @as_declarative(name='BaseModel', metadata=MetaData())
metadata = MetaData(naming_convention=_naming_convention) class BaseModel:
"""
Declarative base class for database models.
"""
# Generate declarative base class for database models # Default primary key
BaseModel = declarative_base(name='BaseModel', metadata=metadata) id: Col[int] = Column(Integer, nullable=False, primary_key=True)
def __repr__(self) -> str:
"""
Return a string representation of this object.
"""
return self._repr(id=self.id) if hasattr(self, 'id') else self._repr()
def _repr(self, **fields) -> str:
"""
Helper method for implementing __repr__.
"""
state: InstanceState = inspect(self)
state_str = f' [transient {id(self)}]' if state.transient \
else f' [pending {id(self)}]' if state.pending \
else ' [deleted]' if state.deleted \
else ' [detached]' if state.detached else ''
param_str = ', '.join([f'{key}={value!r}' for key, value in fields.items()] if fields else state.identity or [])
return f'<{type(self).__name__}({param_str}){state_str}>'
def to_dict(
self,
*,
fields: Optional[Iterable[str]] = None,
exclude: Optional[Iterable[str]] = None,
) -> dict[str, Any]:
"""
Return the object's data as a dictionary.
By default, the dictionary will contain all table columns (with their column name as key) defined in the model. This can be
overridden by setting the `fields` and/or `exclude` parameters, in which case only fields that are listed in `fields` will be
included in the dictionary, except for fields listed in `exclude`.
"""
# Determine fields to include in dictionary (starting will all table columns)
included_fields = set(column.name for column in self.__table__.columns)
if fields is not None:
included_fields.intersection_update(fields)
if exclude is not None:
included_fields.difference_update(exclude)
return {
field: getattr(self, field) for field in included_fields
}

View File

@ -1,25 +1,19 @@
from sqlalchemy import Column, Integer, String, Text from sqlalchemy import Column, String, Text
from tofu_api.common.database import Col
from tofu_api.common.database.mixins import TimestampMixin
from .base import BaseModel from .base import BaseModel
class Task(BaseModel): class Task(TimestampMixin, BaseModel):
""" """
Database model for tasks. Database model for tasks.
""" """
__tablename__ = 'tasks' __tablename__ = 'task'
id: int = Column(Integer, nullable=False, primary_key=True) title: Col[str] = Column(String(255), nullable=False)
# TODO: created_at, modified_at description: Col[str] = Column(Text, nullable=False, default='')
title: str = Column(String(255), nullable=False) def __repr__(self):
description: str = Column(Text, nullable=False, default='') return self._repr(id=self.id, title=self.title)
def to_dict(self) -> dict:
# TODO: Implement a generic to_dict() in the base model
return {
'id': self.id,
'title': self.title,
'description': self.description,
}