Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify CRUD definitions, make a clearer distinction between schemas and models #23

Merged
merged 33 commits into from
Jan 19, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7abb977
removed postgres_password from alembic.ini, read it from env var instead
Apr 12, 2019
c23eb50
:twisted_rightwards_arrows: Merge remote
tiangolo Apr 20, 2019
14fe548
:recycle: use f-strings for PostgreSQL URL
tiangolo Apr 20, 2019
059046b
Merge pull request #1 from tiangolo/master
ebreton Apr 27, 2019
900a278
Merge pull request #2 from tiangolo/master
ebreton May 3, 2019
d18d065
Add CrudBase along with SubItem for the showcase
May 3, 2019
c0123bb
Merge pull request #3 from tiangolo/master
ebreton Jun 18, 2019
8033e6a
Add subitem
Sep 5, 2019
7b2ceb9
Merge pull request #4 from tiangolo/master
ebreton Sep 9, 2019
5e93adc
merged master in
Sep 9, 2019
c10da2f
Add orm_mode
Sep 9, 2019
5efdecc
Follow comments on PR
Sep 9, 2019
9db15d8
Renamed models into schemas
Sep 9, 2019
f6a5bf6
Rename db_models into models
Sep 9, 2019
9ce0921
Rename db_models to models
Sep 9, 2019
5f8a300
Forward args passed to test.sh down to test-start.sh
Sep 10, 2019
3acade8
ignore cache, Pilfile.lock and docker-stack.yml
Sep 10, 2019
e464bd3
Fix tests
Sep 10, 2019
8b2f559
Update tests
Sep 19, 2019
fa7adb9
Rename test-backend.sh to test-again.sh, improve doc
Sep 19, 2019
efa4d85
Fix typo and missing argument in CrudBase docstring
Dec 4, 2019
92ad76c
:wrench: Update testing scripts
tiangolo Jan 19, 2020
470661f
:recycle: Refactor CRUD utils to use generics and types
tiangolo Jan 19, 2020
359581f
:rewind: Revert model changes, to have the minimum changes
tiangolo Jan 19, 2020
4d6de8c
:rewind: Revert DB base and changes, separate CRUD from DB models
tiangolo Jan 19, 2020
cc2a769
:rewind: Revert changes in code line order
tiangolo Jan 19, 2020
f4f7d71
:recycle: Refactor Pydantic models, revert changes not related to the…
tiangolo Jan 19, 2020
f7615dd
:sparkles: Use new CRUD utils, revert changes not related to PR
tiangolo Jan 19, 2020
79f0169
:sparkles: Use new CRUD utils in security utils
tiangolo Jan 19, 2020
2a45871
:white_check_mark: Use new CRUD utils in tests
tiangolo Jan 19, 2020
e6f6a86
:arrow_up: Upgrade FastAPI and Uvicorn version
tiangolo Jan 19, 2020
43129b8
:twisted_rightwards_arrows: Merge master
tiangolo Jan 19, 2020
a4b8c89
:recycle: Update files, refactor, simplify
tiangolo Jan 19, 2020
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def upgrade():
op.create_index(op.f('ix_user_email'), 'user', ['email'], unique=True)
op.create_index(op.f('ix_user_full_name'), 'user', ['full_name'], unique=False)
op.create_index(op.f('ix_user_id'), 'user', ['id'], unique=False)

op.create_table('item',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=True),
Expand All @@ -41,11 +42,24 @@ def upgrade():
op.create_index(op.f('ix_item_description'), 'item', ['description'], unique=False)
op.create_index(op.f('ix_item_id'), 'item', ['id'], unique=False)
op.create_index(op.f('ix_item_title'), 'item', ['title'], unique=False)

op.create_table('subitem',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('item_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['item_id'], ['item.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_subitem_id'), 'subitem', ['id'], unique=False)
op.create_index(op.f('ix_subitem_name'), 'subitem', ['name'], unique=False)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_subitem_name'), table_name='subitem')
op.drop_index(op.f('ix_subitem_id'), table_name='subitem')
op.drop_table('subitem')
op.drop_index(op.f('ix_item_title'), table_name='item')
op.drop_index(op.f('ix_item_id'), table_name='item')
op.drop_index(op.f('ix_item_description'), table_name='item')
Expand Down
13 changes: 12 additions & 1 deletion {{cookiecutter.project_slug}}/backend/app/app/crud/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,12 @@
from . import item, user
from . import user

from .item import item
from .sub_item import sub_item


# For a new basic set of CRUD operations, on a new object, let's say 'Group',
# you could also simply add the following lines:

# from app.crud.base import CrudBase
# from app.db_models.group import Group
# group = CrudBase(Group)
161 changes: 161 additions & 0 deletions {{cookiecutter.project_slug}}/backend/app/app/crud/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
from typing import List, Optional

from fastapi.encoders import jsonable_encoder
from sqlalchemy.orm import Session

from app.db.base_class import Base
from pydantic import BaseModel


class CrudBase:

def __init__(self, db_model: Base):
"""
CrudBase instances are used to provide the basic CRUD methods for a given object type (get, get_multi, update, create and delete).

In order to use it, follow this steps when you define a new DB model:
- create a class that inherites from CrudBase
- override basic methods with proper types (to get better completion in your IDE)
- create an instance of your newly created class, providing the DB model as an argument

E.g.:

# model definition in app/models/item.py
class ItemCreate(...)
...

class ItemUpdate(...)
...

# model definition in app/db_models/item.py
class Item(Base):
id: int
...

# crud definition in app/crud/item.py
from app.db_models.item import Item
from app.models.item import ItemUpdate, ItemCreate
from app.crud.base import CrudBase


class CrudItem(CrudBase):

def get(self, db_session: Session, obj_id: int) -> Optional[Item]:
return super(CrudItem, self).get(db_session, obj_id=obj_id)

def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]:
return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit)

def create(self, db_session: Session, *, obj_in: ItemCreate) -> Item:
return super(CrudItem, self).create(db_session, obj_in=obj_in)

def update(self, db_session: Session, *, obj: Base, obj_in: ItemUpdate) -> Item:
return super(CrudItem, self).update(db_session, obj=obj, obj_in=obj_in)


crud_item = CrudItem(Item)

Arguments:
db_model {Base} -- Class of the DB model which CRUD methods will be provided for
""" # noqa
self.db_model = db_model

def get(self, db_session: Session, obj_id: int) -> Optional[Base]:
ebreton marked this conversation as resolved.
Show resolved Hide resolved
"""
get returns the object from the Database that matches the given obj_id

Arguments:
db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes.
obj_id {int} -- ID of the object in the Database. It must be defined by a PrimaryKey on the 'id' column.

Returns:
Optional[Base] -- Returns an instance of self.db_model class if an object is found in the Database for the given obj_id. Returns None if there is no match found.
""" # noqa
return db_session.query(self.db_model).get(obj_id)

def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Base]]:
"""
get_multi queries all Database rows, without any filters, but with offset and limit options (for pagination purpose)

Arguments:
db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes.

Keyword Arguments:
skip {int} -- Number of rows to skip from the results (default: {0})
limit {int} -- Maximum number of rows to return (default: {100})

Returns:
List[Optional[Base]] -- Array of DB instances according given parameters. Might be empty if no objets are found.
ebreton marked this conversation as resolved.
Show resolved Hide resolved
""" # noqa
return db_session.query(self.db_model).offset(skip).limit(limit).all()

def create(self, db_session: Session, *, obj_in: BaseModel) -> Base:
ebreton marked this conversation as resolved.
Show resolved Hide resolved
"""
create adds a new row in the Database in the table defined by self.db_model. The column values are populated from the 'obj_in' pydantic object

Arguments:
db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes.
obj_in {BaseModel} -- A pydantic object that contains all mandatory values needed to create the Database row.

Returns:
Base -- The object inserted in the Database
""" # noqa
obj_in_data = jsonable_encoder(obj_in)
obj = self.db_model(**obj_in_data)
db_session.add(obj)
db_session.commit()
db_session.refresh(obj)
return obj

def update(self, db_session: Session, *, obj: Base, obj_in: BaseModel) -> Base:
"""
update modifies an existing row (fetched from given obj) in the Database with values from given obj_in

Arguments:
db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes.
obj {Base} -- A DB instance of the object to update
obj_in {BaseModel} -- A pydantic object that contains all values to update.

Returns:
Base -- The updated DB object, with all its attributes
""" # noqa
obj_data = jsonable_encoder(obj)
update_data = obj_in.dict(skip_defaults=True)
for field in obj_data:
if field in update_data:
setattr(obj, field, update_data[field])
db_session.add(obj)
db_session.commit()
db_session.refresh(obj)
return obj

def delete(self, db_session: Session, obj_id: int) -> int:
"""
delete removes the row from the database with the obj_id ID

Arguments:
db_session {Session} -- Dependency injection of the Database session, which will be used to commit/rollback changes.
obj_id {int} -- ID of the row to remove from the Database. It must be defined by a PrimaryKey on the 'id' column.

Returns:
int -- number of rows deleted, i.e. 1 if the object has been found and deleted, 0 otherwise
""" # noqa
queried = db_session.query(self.db_model).filter(self.db_model.id == obj_id)
counted = queried.count()
if counted > 0:
queried.delete()
db_session.commit()
return counted

def remove(self, db_session: Session, *, obj_id: int) -> Optional[Base]:
"""
remove does the same job as delete, with a different return valie
ebreton marked this conversation as resolved.
Show resolved Hide resolved

Returns:
deleted object, if the deletion was successfull
None if the object was already deleted from the Database
""" # noqa
obj = db_session.query(self.db_model).get(obj_id)
db_session.delete(obj)
db_session.commit()
return obj
60 changes: 20 additions & 40 deletions {{cookiecutter.project_slug}}/backend/app/app/crud/item.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,51 +5,31 @@

from app.db_models.item import Item
from app.models.item import ItemCreate, ItemUpdate
from app.crud.base import CrudBase


def get(db_session: Session, *, id: int) -> Optional[Item]:
return db_session.query(Item).filter(Item.id == id).first()
class CrudItem(CrudBase):
"""
This is provided as a showcase of which methods to override, with the benefit to adjusting
both the types of the arguments and of the returned objects to the proper 'Item*' classes
"""

def get(self, db_session: Session, id: int) -> Optional[Item]:
return super(CrudItem, self).get(db_session, obj_id=id)

def get_multi(db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]:
return db_session.query(Item).offset(skip).limit(limit).all()
def get_multi(self, db_session: Session, *, skip=0, limit=100) -> List[Optional[Item]]:
return super(CrudItem, self).get_multi(db_session, skip=skip, limit=limit)

def create(self, db_session: Session, *, item_in: ItemCreate, owner_id: int) -> Item:
item_in_data = jsonable_encoder(item_in)
item = Item(**item_in_data, owner_id=owner_id)
db_session.add(item)
db_session.commit()
db_session.refresh(item)
return item

def get_multi_by_owner(
ebreton marked this conversation as resolved.
Show resolved Hide resolved
db_session: Session, *, owner_id: int, skip=0, limit=100
) -> List[Optional[Item]]:
return (
db_session.query(Item)
.filter(Item.owner_id == owner_id)
.offset(skip)
.limit(limit)
.all()
)
def update(self, db_session: Session, *, item: Item, item_in: ItemUpdate) -> Item:
return super(CrudItem, self).update(db_session, obj=item, obj_in=item_in)


def create(db_session: Session, *, item_in: ItemCreate, owner_id: int) -> Item:
item_in_data = jsonable_encoder(item_in)
item = Item(**item_in_data, owner_id=owner_id)
db_session.add(item)
db_session.commit()
db_session.refresh(item)
return item


def update(db_session: Session, *, item: Item, item_in: ItemUpdate) -> Item:
item_data = jsonable_encoder(item)
update_data = item_in.dict(skip_defaults=True)
for field in item_data:
if field in update_data:
setattr(item, field, update_data[field])
db_session.add(item)
db_session.commit()
db_session.refresh(item)
return item


def remove(db_session: Session, *, id: int):
item = db_session.query(Item).filter(Item.id == id).first()
db_session.delete(item)
db_session.commit()
return item
item = CrudItem(Item)
21 changes: 21 additions & 0 deletions {{cookiecutter.project_slug}}/backend/app/app/crud/sub_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from typing import Optional
from sqlalchemy.orm import Session, subqueryload

from app.db_models.sub_item import SubItem
from app.crud.base import CrudBase


class CrudSubItem(CrudBase):
"""
This example shows how to change the behaviour of a default GET operation (by returning the foreign objects with all its attribute, instead of solely its id)
"""

def get(self, db_session: Session, obj_id: int) -> Optional[SubItem]:
return (
db_session.query(SubItem)
.options(subqueryload(SubItem.item))
.get(obj_id)
)


sub_item = CrudSubItem(SubItem)
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ class Item(Base):
description = Column(String, index=True)
owner_id = Column(Integer, ForeignKey("user.id"))
owner = relationship("User", back_populates="items")
sub_items = relationship("SubItem", back_populates="item")
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from sqlalchemy import Column, ForeignKey, Integer, String
from sqlalchemy.orm import relationship

from app.db.base_class import Base


class SubItem(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
item_id = Column(Integer, ForeignKey("item.id"))
item = relationship("Item", back_populates="sub_items")
35 changes: 35 additions & 0 deletions {{cookiecutter.project_slug}}/backend/app/app/models/sub_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from pydantic import BaseModel

from .item import Item


# Shared properties
class SubItemBase(BaseModel):
name: str = None
item_id: int


# Properties to receive on item creation
class SubItemCreate(SubItemBase):
name: str


# Properties to receive on item update
class SubItemUpdate(SubItemBase):
item_id: int = None


# Properties shared by models stored in DB
class SubItemInDBBase(SubItemBase):
id: int
name: str


# Properties to return to client
class SubItem(SubItemInDBBase):
item : Item


# Properties properties stored in DB
class SubItemInDB(SubItemInDBBase):
pass
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def test_delete_item():
item_in = ItemCreate(title=title, description=description)
user = create_random_user()
item = crud.item.create(db_session=db_session, item_in=item_in, owner_id=user.id)
item2 = crud.item.remove(db_session=db_session, id=item.id)
item2 = crud.item.remove(db_session=db_session, obj_id=item.id)
item3 = crud.item.get(db_session=db_session, id=item.id)
assert item3 is None
assert item2.id == item.id
Expand Down
Loading