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('.')