From 91ec80b8944acfe5c43bd85bd4c8de44b3faf268 Mon Sep 17 00:00:00 2001 From: Rob Young Date: Wed, 6 Mar 2019 16:09:52 +0000 Subject: [PATCH] Add custom SQLAlchemy column options This change allows the client to pass through custom SQLAlchemy column options which will be added to the Column object on the SQLAlchemy model. The specific need that motivated this change was to create `created_at` and `updated_at` datetime fields. As such, this is what has been used in the tests. --- Dockerfile | 2 -- bookshop.py | 10 ++++++ bookshop/domain/Book.py | 1 + bookshop/resources/Book.py | 4 +++ bookshop/sqlalchemy/model/Author.py | 4 +++ bookshop/sqlalchemy/model/Book.py | 7 +++- bookshop/sqlalchemy/model/BookGenre.py | 4 +++ bookshop/sqlalchemy/model/Genre.py | 4 +++ bookshop/sqlalchemy/model/RelatedBook.py | 4 +++ bookshop/sqlalchemy/model/Review.py | 4 +++ genyrator/entities/Column.py | 36 +++++++++++-------- .../sqlalchemy/model/sqlalchemy_model.j2 | 9 +++++ .../sqlalchemy_column_options.feature | 19 ++++++++++ test/e2e/steps/bookshop_steps.py | 35 +++++++++++++----- 14 files changed, 118 insertions(+), 25 deletions(-) create mode 100644 test/e2e/features/sqlalchemy_column_options.feature diff --git a/Dockerfile b/Dockerfile index 5368c9f..d6effd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,4 @@ RUN pip install pipenv && \ pipenv install --system && \ pipenv sync --dev -ENV PATH /root/.local/share/virtualenvs/genyrator-oIyKmRQj/bin:$PATH - CMD ["make", "test"] diff --git a/bookshop.py b/bookshop.py index e7d9028..3eac61e 100644 --- a/bookshop.py +++ b/bookshop.py @@ -41,6 +41,16 @@ ), create_column( name='created', type_option=TypeOption.datetime, + sqlalchemy_options={ + 'server_default': 'text(\'CURRENT_TIMESTAMP\')', + }, + ), + create_column( + name='updated', type_option=TypeOption.datetime, + sqlalchemy_options={ + 'default': 'datetime.datetime.utcnow', + 'onupdate': 'datetime.datetime.utcnow', + }, ), ], relationships=[ diff --git a/bookshop/domain/Book.py b/bookshop/domain/Book.py index baba667..af8af42 100644 --- a/bookshop/domain/Book.py +++ b/bookshop/domain/Book.py @@ -41,6 +41,7 @@ 'collaborator_id', 'published', 'created', + 'updated', ], json_translation_map={ 'book_id': 'id', diff --git a/bookshop/resources/Book.py b/bookshop/resources/Book.py index 6a9951d..383ac3b 100644 --- a/bookshop/resources/Book.py +++ b/bookshop/resources/Book.py @@ -31,6 +31,7 @@ 'collaboratorId': fields.String(), 'published': fields.Date(), 'created': fields.DateTime(), + 'updated': fields.DateTime(), 'author': fields.Raw(), 'collaborator': fields.Raw(), 'genre': fields.Raw(), @@ -152,6 +153,9 @@ def get(self): param_created = request.args.get('created') if param_created: query = query.filter_by(created=param_created) + param_updated = request.args.get('updated') + if param_updated: + query = query.filter_by(updated=param_updated) result = query.all() return python_dict_to_json_dict({"data": [model_to_dict(r) for r in result]}) diff --git a/bookshop/sqlalchemy/model/Author.py b/bookshop/sqlalchemy/model/Author.py index 65cf674..846dd7c 100644 --- a/bookshop/sqlalchemy/model/Author.py +++ b/bookshop/sqlalchemy/model/Author.py @@ -2,6 +2,10 @@ from sqlalchemy import UniqueConstraint from sqlalchemy.types import JSON as JSONType +# Available for custom sqlalchemy_options +import datetime +from sqlalchemy import text + from bookshop.sqlalchemy import db from bookshop.sqlalchemy.model.types import BigIntegerVariantType diff --git a/bookshop/sqlalchemy/model/Book.py b/bookshop/sqlalchemy/model/Book.py index b5cf7c1..98f9a24 100644 --- a/bookshop/sqlalchemy/model/Book.py +++ b/bookshop/sqlalchemy/model/Book.py @@ -2,6 +2,10 @@ from sqlalchemy import UniqueConstraint from sqlalchemy.types import JSON as JSONType +# Available for custom sqlalchemy_options +import datetime +from sqlalchemy import text + from bookshop.sqlalchemy import db from bookshop.sqlalchemy.model.types import BigIntegerVariantType @@ -15,7 +19,8 @@ class Book(db.Model): # type: ignore author_id = db.Column(db.BigInteger, db.ForeignKey('author.id'), nullable=True) # noqa: E501 collaborator_id = db.Column(db.BigInteger, db.ForeignKey('author.id'), nullable=True) # noqa: E501 published = db.Column(db.Date, nullable=True) # noqa: E501 - created = db.Column(db.DateTime, nullable=True) # noqa: E501 + created = db.Column(db.DateTime, server_default=text('CURRENT_TIMESTAMP'), nullable=True) # noqa: E501 + updated = db.Column(db.DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow, nullable=True) # noqa: E501 # Relationships author = db.relationship( diff --git a/bookshop/sqlalchemy/model/BookGenre.py b/bookshop/sqlalchemy/model/BookGenre.py index 5699a9b..28d81ab 100644 --- a/bookshop/sqlalchemy/model/BookGenre.py +++ b/bookshop/sqlalchemy/model/BookGenre.py @@ -2,6 +2,10 @@ from sqlalchemy import UniqueConstraint from sqlalchemy.types import JSON as JSONType +# Available for custom sqlalchemy_options +import datetime +from sqlalchemy import text + from bookshop.sqlalchemy import db from bookshop.sqlalchemy.model.types import BigIntegerVariantType diff --git a/bookshop/sqlalchemy/model/Genre.py b/bookshop/sqlalchemy/model/Genre.py index 9c70394..c6120f8 100644 --- a/bookshop/sqlalchemy/model/Genre.py +++ b/bookshop/sqlalchemy/model/Genre.py @@ -2,6 +2,10 @@ from sqlalchemy import UniqueConstraint from sqlalchemy.types import JSON as JSONType +# Available for custom sqlalchemy_options +import datetime +from sqlalchemy import text + from bookshop.sqlalchemy import db from bookshop.sqlalchemy.model.types import BigIntegerVariantType diff --git a/bookshop/sqlalchemy/model/RelatedBook.py b/bookshop/sqlalchemy/model/RelatedBook.py index 9b18dd1..0241fb8 100644 --- a/bookshop/sqlalchemy/model/RelatedBook.py +++ b/bookshop/sqlalchemy/model/RelatedBook.py @@ -2,6 +2,10 @@ from sqlalchemy import UniqueConstraint from sqlalchemy.types import JSON as JSONType +# Available for custom sqlalchemy_options +import datetime +from sqlalchemy import text + from bookshop.sqlalchemy import db from bookshop.sqlalchemy.model.types import BigIntegerVariantType diff --git a/bookshop/sqlalchemy/model/Review.py b/bookshop/sqlalchemy/model/Review.py index 2e37ea8..1ce7b4d 100644 --- a/bookshop/sqlalchemy/model/Review.py +++ b/bookshop/sqlalchemy/model/Review.py @@ -2,6 +2,10 @@ from sqlalchemy import UniqueConstraint from sqlalchemy.types import JSON as JSONType +# Available for custom sqlalchemy_options +import datetime +from sqlalchemy import text + from bookshop.sqlalchemy import db from bookshop.sqlalchemy.model.types import BigIntegerVariantType diff --git a/genyrator/entities/Column.py b/genyrator/entities/Column.py index aa5f01a..42384db 100644 --- a/genyrator/entities/Column.py +++ b/genyrator/entities/Column.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, NamedTuple +from typing import Optional, Union, NamedTuple, List, Tuple, Dict import attr from genyrator.inflector import pythonize, to_class_name, to_json_case, humanize from genyrator.types import ( @@ -16,19 +16,20 @@ @attr.s class Column(object): - python_name: str = attr.ib() - class_name: str = attr.ib() - display_name: str = attr.ib() - alias: str = attr.ib() - json_property_name: str = attr.ib() - type_option: TypeOption = attr.ib() - faker_method: str = attr.ib() - sqlalchemy_type: SqlAlchemyTypeOption = attr.ib() - python_type: PythonTypeOption = attr.ib() - restplus_type: RestplusTypeOption = attr.ib() - default: str = attr.ib() - index: bool = attr.ib() - nullable: bool = attr.ib() + python_name: str = attr.ib() + class_name: str = attr.ib() + display_name: str = attr.ib() + alias: str = attr.ib() + json_property_name: str = attr.ib() + type_option: TypeOption = attr.ib() + faker_method: str = attr.ib() + sqlalchemy_type: SqlAlchemyTypeOption = attr.ib() + python_type: PythonTypeOption = attr.ib() + restplus_type: RestplusTypeOption = attr.ib() + default: str = attr.ib() + index: bool = attr.ib() + nullable: bool = attr.ib() + sqlalchemy_options: List[Tuple[str, str]] = attr.ib() @attr.s @@ -52,6 +53,7 @@ def create_column( alias: Optional[str] = None, foreign_key_relationship: Optional[ForeignKeyRelationship] = None, faker_method: Optional[str] = None, + sqlalchemy_options: Optional[Dict[str, str]] = None, ) -> Union[Column, ForeignKey]: """Return a column to be attached to an entity @@ -77,6 +79,8 @@ def create_column( faker_method: The method to pass to Faker to provide fixture data for this column. If this column is not nullable, defaults to the constructor for the type of this column. + + sqlalchemy_options: Pass additional keyword arguments to the SQLAlchemy column object. """ if identifier is True: constructor = IdentifierColumn @@ -88,6 +92,9 @@ def create_column( if faker_method is None and nullable is False: faker_method = type_option_to_faker_method(type_option) + if sqlalchemy_options is None: + sqlalchemy_options = {} + args = { "python_name": pythonize(name), "class_name": to_class_name(name), @@ -102,6 +109,7 @@ def create_column( "nullable": nullable, "alias": alias, "faker_method": faker_method, + "sqlalchemy_options": [(key, value) for key, value in sqlalchemy_options.items()], } if foreign_key_relationship is not None: args['relationship'] = '{}.{}'.format( diff --git a/genyrator/templates/sqlalchemy/model/sqlalchemy_model.j2 b/genyrator/templates/sqlalchemy/model/sqlalchemy_model.j2 index 74165c7..1af40d0 100644 --- a/genyrator/templates/sqlalchemy/model/sqlalchemy_model.j2 +++ b/genyrator/templates/sqlalchemy/model/sqlalchemy_model.j2 @@ -2,6 +2,10 @@ from sqlalchemy_utils import UUIDType from sqlalchemy import UniqueConstraint from sqlalchemy.types import JSON as JSONType +# Available for custom sqlalchemy_options +import datetime +from sqlalchemy import text + from {{ template.db_import_path }} import db from {{ template.module_name }}.sqlalchemy.model.types import BigIntegerVariantType @@ -28,6 +32,11 @@ class {{ template.entity.class_name }}(db.Model): # type: ignore {{ column.sqlalchemy_type.value }}{# -#} {%- if column.relationship is defined %}, db.ForeignKey('{{ column.relationship }}'){% endif %} {%- if column.index %}, index=True{% endif %}{# -#} + {%- if column.sqlalchemy_options -%} + {%- for option, value in column.sqlalchemy_options -%} + , {{ option }}={{ value }} + {%- endfor -%} + {%- endif -%} , nullable={{ column.nullable | string }}) # noqa: E501 {%- endfor %} diff --git a/test/e2e/features/sqlalchemy_column_options.feature b/test/e2e/features/sqlalchemy_column_options.feature new file mode 100644 index 0000000..53e4a71 --- /dev/null +++ b/test/e2e/features/sqlalchemy_column_options.feature @@ -0,0 +1,19 @@ +Feature: Custom SQLAlchemy column options + + Background: + Given I have the example "bookshop" application + + Scenario: Column defaults are correctly applied + Given I put an example "book" entity + When I get that "book" SQLAlchemy model + Then "sql_book.created" should be a recent timestamp + And "sql_book.updated" should be a recent timestamp + + Scenario: Column onupdate rules are correctly applied + Given I put an example "book" entity + When I get that "book" SQLAlchemy model + And I save the value of "sql_book.updated" as "original_updated" + And I patch that "book" entity to set "name" to "cave" + And I get that "book" SQLAlchemy model + Then "sql_book.updated" should not equal saved value "original_updated" + diff --git a/test/e2e/steps/bookshop_steps.py b/test/e2e/steps/bookshop_steps.py index 750d2fc..de04542 100644 --- a/test/e2e/steps/bookshop_steps.py +++ b/test/e2e/steps/bookshop_steps.py @@ -4,7 +4,7 @@ import json from behave import given, when, then -from hamcrest import assert_that, equal_to, instance_of, none +from hamcrest import assert_that, equal_to, instance_of, none, less_than, is_not from test.e2e.steps.common import make_request from bookshop.sqlalchemy.model.Author import Author @@ -12,8 +12,7 @@ def generate_example_book() -> Mapping[str, str]: - return {"id": str(uuid.uuid4()), "name": "the outsider", "rating": 4.96, "published": "1967-04-24", - "created": str(datetime.datetime.now())} + return {"id": str(uuid.uuid4()), "name": "the outsider", "rating": 4.96, "published": "1967-04-24"} def generate_example_genre() -> Mapping[str, str]: @@ -182,7 +181,7 @@ def step_impl(context): @step("I put a book entity with a relationship to that author") def step_impl(context): book = generate_example_book() - context.created_book = book = { + context.book_entity = book = { **book, **{'authorId': context.author_entity['id']} } @@ -193,8 +192,8 @@ def step_impl(context): @step("I also put a collaborator relationship to that author") def step_impl(context): - context.created_book['collaboratorId'] = context.author_entity['id'] - book = context.created_book + context.book_entity['collaboratorId'] = context.author_entity['id'] + book = context.book_entity response = make_request(client=context.client, endpoint=f'book/{book["id"]}', method='put', data=book) assert_that(response.status_code, equal_to(201)) @@ -219,7 +218,7 @@ def step_impl(context, author_name, relationship): @when("I get that book entity") def step_impl(context): - book = context.created_book + book = context.book_entity url = f'/book/{book["id"]}' response = make_request(client=context.client, endpoint=url, method='get') assert_that(response.status_code, equal_to(200)) @@ -235,7 +234,7 @@ def step_impl(context): @when('I get that "{entity_type}" SQLAlchemy model') def step_impl(context, entity_type: str): - entity = context.author_entity if entity_type == 'author' else context.created_book + entity = context.author_entity if entity_type == 'author' else context.book_entity _get_sqlalchemy_model(context, entity_type, entity) @@ -293,6 +292,26 @@ def step_impl(context, target): assert_that(_extract_target(context, target), none()) +@then('"{target}" should be a recent timestamp') +def step_impl(context, target): + expected = datetime.datetime.utcnow() + delta = datetime.timedelta(seconds=2) + found = _extract_target(context, target) + + assert_that(abs(found - expected).seconds, less_than(delta.seconds)) + + +@when('I save the value of "{target}" as "{save_name}"') +def step_impl(context, target, save_name): + found = _extract_target(context, target) + context.saved_values = getattr(context, 'saved_values', {}) + context.saved_values[save_name] = found + + +@then('"{target}" should not equal saved value "{save_name}"') +def step_impl(context, target, save_name): + assert_that(_extract_target(context, target), is_not(equal_to(context.saved_values[save_name]))) + def _extract_target(context, target): if isinstance(target, str): target = target.split('.')