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[:] = []
context_parameters = {
'target_metadata': BaseModel.metadata,
'process_revision_directives': process_revision_directives,
'user_module_prefix': 'tofu_types.',
}
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.
"""
context.configure(
url=app.config.sqlalchemy_database_uri,
target_metadata=BaseModel.metadata,
process_revision_directives=process_revision_directives,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
**context_parameters,
)
with context.begin_transaction():
@ -49,8 +55,7 @@ def run_migrations_online():
with db.engine.connect() as connection:
context.configure(
connection=connection,
target_metadata=BaseModel.metadata,
process_revision_directives=process_revision_directives,
**context_parameters,
)
with context.begin_transaction():

View File

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

View File

@ -1,16 +1,18 @@
"""
Initial revision
Revision ID: 716726b4a1fe
Revision ID: 044f3afd19b0
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
import sqlalchemy as sa
import tofu_api.common.database.types as tofu_types
# Revision identifiers, used by Alembic.
revision = '716726b4a1fe'
revision = '044f3afd19b0'
down_revision = None
branch_labels = None
depends_on = None
@ -19,16 +21,18 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'tasks',
'task',
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('description', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tasks'))
sa.PrimaryKeyConstraint('id', name=op.f('pk_task'))
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('tasks')
op.drop_table('task')
# ### end Alembic commands ###

View File

@ -6,6 +6,7 @@ from flask import Flask
from tofu_api.api import TofuApiBlueprint
from tofu_api.common.config import Config
from tofu_api.common.json import JSONEncoder
from tofu_api.dependencies import Dependencies
# Enable deprecation warnings in dev environment
@ -19,6 +20,7 @@ class App(Flask):
"""
# Override Flask classes
config_class = Config
json_encoder = JSONEncoder
# Set type hint for config
config: Config
@ -57,7 +59,7 @@ class App(Flask):
db = self.dependencies.get_sqlalchemy()
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)

View File

@ -1 +1,3 @@
from .metadata import MetaData
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
from .base import metadata, BaseModel
from .base import BaseModel
# Data models
from .task import Task

View File

@ -1,22 +1,62 @@
from sqlalchemy import MetaData
from sqlalchemy.orm import declarative_base
from typing import Any, Iterable, Optional
from sqlalchemy import Column, Integer, inspect
from sqlalchemy.orm import InstanceState, as_declarative
from tofu_api.common.database import Col, MetaData
__all__ = [
'metadata',
'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
metadata = MetaData(naming_convention=_naming_convention)
@as_declarative(name='BaseModel', metadata=MetaData())
class BaseModel:
"""
Declarative base class for database models.
"""
# Generate declarative base class for database models
BaseModel = declarative_base(name='BaseModel', metadata=metadata)
# Default primary key
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
class Task(BaseModel):
class Task(TimestampMixin, BaseModel):
"""
Database model for tasks.
"""
__tablename__ = 'tasks'
__tablename__ = 'task'
id: int = Column(Integer, nullable=False, primary_key=True)
# TODO: created_at, modified_at
title: Col[str] = Column(String(255), nullable=False)
description: Col[str] = Column(Text, nullable=False, default='')
title: str = Column(String(255), nullable=False)
description: str = Column(Text, nullable=False, default='')
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,
}
def __repr__(self):
return self._repr(id=self.id, title=self.title)