Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
10 changes: 10 additions & 0 deletions bookshop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[
Expand Down
1 change: 1 addition & 0 deletions bookshop/domain/Book.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
'collaborator_id',
'published',
'created',
'updated',
],
json_translation_map={
'book_id': 'id',
Expand Down
4 changes: 4 additions & 0 deletions bookshop/resources/Book.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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]})

Expand Down
4 changes: 4 additions & 0 deletions bookshop/sqlalchemy/model/Author.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion bookshop/sqlalchemy/model/Book.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions bookshop/sqlalchemy/model/BookGenre.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions bookshop/sqlalchemy/model/Genre.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions bookshop/sqlalchemy/model/RelatedBook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions bookshop/sqlalchemy/model/Review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 22 additions & 14 deletions genyrator/entities/Column.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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),
Expand All @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions genyrator/templates/sqlalchemy/model/sqlalchemy_model.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 %}

Expand Down
19 changes: 19 additions & 0 deletions test/e2e/features/sqlalchemy_column_options.feature
Original file line number Diff line number Diff line change
@@ -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"

35 changes: 27 additions & 8 deletions test/e2e/steps/bookshop_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@
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
from bookshop.sqlalchemy.model.Book import Book


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]:
Expand Down Expand Up @@ -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']}
}
Expand All @@ -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))
Expand All @@ -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))
Expand All @@ -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)

Expand Down Expand Up @@ -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('.')
Expand Down