From 394de2d11cf13b8be5ac06de8d418634d61f6c62 Mon Sep 17 00:00:00 2001 From: collerek Date: Thu, 22 Oct 2020 12:48:40 +0200 Subject: [PATCH] fix bug in bulk_update, update documentation, update readme, bump version --- .coverage | Bin 53248 -> 53248 bytes README.md | 33 ++++++-- docs/fastapi.md | 145 +++++++++++++++--------------------- docs/index.md | 34 +++++++-- docs/models.md | 52 ++++++++++++- docs/relations.md | 8 +- docs/releases.md | 45 +++++++++++ docs/testing.md | 0 docs_src/fastapi/docs001.py | 77 +++++++++++++++++++ docs_src/models/docs008.py | 19 +++++ docs_src/models/docs009.py | 9 +++ docs_src/models/docs010.py | 18 +++++ mkdocs.yml | 1 + ormar/__init__.py | 2 +- ormar/queryset/queryset.py | 4 +- tests/test_aliases.py | 4 + 16 files changed, 347 insertions(+), 104 deletions(-) create mode 100644 docs/releases.md delete mode 100644 docs/testing.md create mode 100644 docs_src/fastapi/docs001.py create mode 100644 docs_src/models/docs008.py create mode 100644 docs_src/models/docs009.py create mode 100644 docs_src/models/docs010.py diff --git a/.coverage b/.coverage index 55690d1026e0b04eb51cb77f7ea3a791d917dc8f..e491190b6ca1563327e0366abff0a37c93180b7a 100644 GIT binary patch delta 183 zcmV;o07(CUpaX!Q1F$_W3Nj!uF*-CkIyEq}M=!rn0SS`_fF?DA4q*;V4l@oa4jm2) z4f+k{4bu(A4Wtc+4Sx-A4Pp%}4GawK46qEE41^4K3`qXXvvk?%d3$qH0uK^DC z{W1KZ-+u4)zdhfWcaz7By*D)z1OW+b6S`lp|9^d4*Xw@YZr``x-GBAH{j>Ye{pYu3GvKX-TU`*&B{zJB|U^?Bci0kgV}JplrB@3Z-j2SD%)PJjRa delta 182 zcmV;n07?IVpaX!Q1F$_W3NautGCD9hIxsY|M=!rn0Sc1`fF?DB4q^^X4m1uc4jv8+ z4f_q}4b%gdxvk?%e3$qD~uK^By ze++-y*4xx1OW+Z6S`lp|9^d4*Xw@YZri^7?*6Oq?VsI$?mzGS kSwHJP|GB$+-@m)s_VwF;tk3&nv$~Ew0Rr_7v-yt)KnXNVuK)l5 diff --git a/README.md b/README.md index 5098d19..d6ff26c 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,9 @@ await Track.objects.create(album=malibu, title="The Bird", position=1) await Track.objects.create(album=malibu, title="Heart don't stand a chance", position=2) await Track.objects.create(album=malibu, title="The Waters", position=3) -fantasies = await Album.objects.create(name="Fantasies") +# alternative creation of object divided into 2 steps +fantasies = Album.objects.create(name="Fantasies") +await fantasies.save() await Track.objects.create(album=fantasies, title="Help I'm Alive", position=1) await Track.objects.create(album=fantasies, title="Sick Muse", position=2) @@ -137,12 +139,33 @@ tracks = await Track.objects.limit(1).all() assert len(tracks) == 1 ``` -## Data types +## Ormar Specification + +### QuerySet methods + +* `create(**kwargs): -> Model` +* `get(**kwargs): -> Model` +* `get_or_create(**kwargs) -> Model` +* `update(each: bool = False, **kwargs) -> int` +* `update_or_create(**kwargs) -> Model` +* `bulk_create(objects: List[Model]) -> None` +* `bulk_update(objects: List[Model], columns: List[str] = None) -> None` +* `delete(each: bool = False, **kwargs) -> int` +* `all(self, **kwargs) -> List[Optional[Model]]` +* `filter(**kwargs) -> QuerySet` +* `exclude(**kwargs) -> QuerySet` +* `select_related(related: Union[List, str]) -> QuerySet` +* `limit(limit_count: int) -> QuerySet` +* `offset(offset: int) -> QuerySet` +* `count() -> int` +* `exists() -> bool` +* `fields(columns: Union[List, str]) -> QuerySet` + #### Relation types -* One to many - with `ForeignKey` -* Many to many - with `Many2Many` +* One to many - with `ForeignKey(to: Model)` +* Many to many - with `ManyToMany(to: Model, through: Model)` #### Model fields types @@ -161,7 +184,7 @@ Available Model Fields (with required args - optional ones in docs): * `Decimal(scale, precision)` * `UUID()` * `ForeignKey(to)` -* `Many2Many(to, through)` +* `ManyToMany(to, through)` ### Available fields options The following keyword arguments are supported on all field types. diff --git a/docs/fastapi.md b/docs/fastapi.md index 399a2c3..fbb6f11 100644 --- a/docs/fastapi.md +++ b/docs/fastapi.md @@ -6,97 +6,66 @@ you need to do is substitute pydantic models with ormar models. Here you can find a very simple sample application code. +## Imports and initialization + +First take care of the imports and initialization +```python hl_lines="1-12" +--8<-- "../docs_src/fastapi/docs001.py" +``` + +## Database connection + +Next define startup and shutdown events (or use middleware) +- note that this is `databases` specific setting not the ormar one +```python hl_lines="15-26" +--8<-- "../docs_src/fastapi/docs001.py" +``` + +!!!info + You can read more on connecting to databases in [fastapi][fastapi] documentation + +## Models definition + +Define ormar models with appropriate fields. + +Those models will be used insted of pydantic ones. +```python hl_lines="29-47" +--8<-- "../docs_src/fastapi/docs001.py" +``` + +!!!tip + You can read more on defining `Models` in [models][models] section. + +## Fastapi endpoints definition + +Define your desired endpoints, note how `ormar` models are used both +as `response_model` and as a requests parameters. + +```python hl_lines="50-77" +--8<-- "../docs_src/fastapi/docs001.py" +``` + +!!!note + Note how ormar `Model` methods like save() are available straight out of the box after fastapi initializes it for you. + +!!!note + Note that you can return a `Model` (or list of `Models`) directly - fastapi will jsonize it for you + +## Test the application + +Here you have a sample test that will prove that everything works as intended. + ```python -from typing import List - -import databases -import pytest -import sqlalchemy -from fastapi import FastAPI -from starlette.testclient import TestClient - -import ormar -from tests.settings import DATABASE_URL - -app = FastAPI() -metadata = sqlalchemy.MetaData() -database = databases.Database(DATABASE_URL, force_rollback=True) -app.state.database = database - -# define startup and shutdown events -@app.on_event("startup") -async def startup() -> None: - database_ = app.state.database - if not database_.is_connected: - await database_.connect() - - -@app.on_event("shutdown") -async def shutdown() -> None: - database_ = app.state.database - if database_.is_connected: - await database_.disconnect() - -# define ormar models -class Category(ormar.Model): - class Meta: - tablename = "categories" - metadata = metadata - database = database - - id: ormar.Integer(primary_key=True) - name: ormar.String(max_length=100) - - -class Item(ormar.Model): - class Meta: - tablename = "items" - metadata = metadata - database = database - - id: ormar.Integer(primary_key=True) - name: ormar.String(max_length=100) - category: ormar.ForeignKey(Category, nullable=True) - -# define endpoints in fastapi -@app.get("/items/", response_model=List[Item]) -async def get_items(): - items = await Item.objects.select_related("category").all() - # not that you can return a model directly - fastapi will json-ize it - return items - - -@app.post("/items/", response_model=Item) -async def create_item(item: Item): - # note how ormar methods like save() are available streight out of the box - await item.save() - return item - - -@app.post("/categories/", response_model=Category) -async def create_category(category: Category): - await category.save() - return category - - -@app.put("/items/{item_id}") -async def get_item(item_id: int, item: Item): - # you can work both with item_id or item - item_db = await Item.objects.get(pk=item_id) - return await item_db.update(**item.dict()) - - -@app.delete("/items/{item_id}") -async def delete_item(item_id: int, item: Item): - item_db = await Item.objects.get(pk=item_id) - return {"deleted_rows": await item_db.delete()} # here is a sample test to check the working of the ormar with fastapi + +from starlette.testclient import TestClient + def test_all_endpoints(): # note that TestClient is only sync, don't use asyns here client = TestClient(app) # note that you need to connect to database manually - # or use client as contextmanager + # or use client as contextmanager during tests with client as client: response = client.post("/categories/", json={"name": "test cat"}) category = response.json() @@ -123,4 +92,10 @@ def test_all_endpoints(): response = client.get("/items/") items = response.json() assert len(items) == 0 -``` \ No newline at end of file +``` + +!!!info + You can read more on testing fastapi in [fastapi][fastapi] docs. + +[fastapi]: https://fastapi.tiangolo.com/ +[models]: ./models.md \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 5098d19..ca77b54 100644 --- a/docs/index.md +++ b/docs/index.md @@ -97,7 +97,9 @@ await Track.objects.create(album=malibu, title="The Bird", position=1) await Track.objects.create(album=malibu, title="Heart don't stand a chance", position=2) await Track.objects.create(album=malibu, title="The Waters", position=3) -fantasies = await Album.objects.create(name="Fantasies") +# alternative creation of object divided into 2 steps +fantasies = Album.objects.create(name="Fantasies") +await fantasies.save() await Track.objects.create(album=fantasies, title="Help I'm Alive", position=1) await Track.objects.create(album=fantasies, title="Sick Muse", position=2) @@ -137,12 +139,33 @@ tracks = await Track.objects.limit(1).all() assert len(tracks) == 1 ``` -## Data types +## Ormar Specification + +### QuerySet methods + +* `create(**kwargs): -> Model` +* `get(**kwargs): -> Model` +* `get_or_create(**kwargs) -> Model` +* `update(each: bool = False, **kwargs) -> int` +* `update_or_create(**kwargs) -> Model` +* `bulk_create(objects: List[Model]) -> None` +* `bulk_update(objects: List[Model], columns: List[str] = None) -> None` +* `delete(each: bool = False, **kwargs) -> int` +* `all(self, **kwargs) -> List[Optional[Model]]` +* `filter(**kwargs) -> QuerySet` +* `exclude(**kwargs) -> QuerySet` +* `select_related(related: Union[List, str]) -> QuerySet` +* `limit(limit_count: int) -> QuerySet` +* `offset(offset: int) -> QuerySet` +* `count() -> int` +* `exists() -> bool` +* `fields(columns: Union[List, str]) -> QuerySet` + #### Relation types -* One to many - with `ForeignKey` -* Many to many - with `Many2Many` +* One to many - with `ForeignKey(to: Model)` +* Many to many - with `ManyToMany(to: Model, through: Model)` #### Model fields types @@ -161,7 +184,7 @@ Available Model Fields (with required args - optional ones in docs): * `Decimal(scale, precision)` * `UUID()` * `ForeignKey(to)` -* `Many2Many(to, through)` +* `ManyToMany(to, through)` ### Available fields options The following keyword arguments are supported on all field types. @@ -173,6 +196,7 @@ The following keyword arguments are supported on all field types. * `index: bool` * `unique: bool` * `choices: typing.Sequence` + * `name: str` All fields are required unless one of the following is set: diff --git a/docs/models.md b/docs/models.md index 57b7998..6e2c73b 100644 --- a/docs/models.md +++ b/docs/models.md @@ -37,7 +37,27 @@ You can disable by passing `autoincremant=False`. id: ormar.Integer(primary_key=True, autoincrement=False) ``` -Names of the fields will be used for both the underlying `pydantic` model and `sqlalchemy` table. +### Fields names vs Column names + +By default names of the fields will be used for both the underlying `pydantic` model and `sqlalchemy` table. + +If for whatever reason you prefer to change the name in the database but keep the name in the model you can do this +with specifying `name` parameter during Field declaration + +Here you have a sample model with changed names +```Python hl_lines="16-19" +--8<-- "../docs_src/models/docs008.py" +``` + +Note that you can also change the ForeignKey column name +```Python hl_lines="9" +--8<-- "../docs_src/models/docs009.py" +``` + +But for now you cannot change the ManyToMany column names as they go through other Model anyway. +```Python hl_lines="18" +--8<-- "../docs_src/models/docs010.py" +``` ### Dependencies @@ -128,7 +148,9 @@ Each model has a `QuerySet` initialised as `objects` parameter ### load By default when you query a table without prefetching related models, the ormar will still construct -your related models, but populate them only with the pk value. +your related models, but populate them only with the pk value. You can load the related model by calling `load()` method. + +`load()` can also be used to refresh the model from the database (if it was changed by some other process). ```python track = await Track.objects.get(name='The Bird') @@ -142,10 +164,36 @@ track.album.name # will return 'Malibu' ### save +You can create new models by using `QuerySet.create()` method or by initializing your model as a normal pydantic model +and later calling `save()` method. + +`save()` can also be used to persist changes that you made to the model. + +```python +track = Track(name='The Bird') +await track.save() # will persist the model in database +``` + ### delete +You can delete models by using `QuerySet.delete()` method or by using your model and calling `delete()` method. + +```python +track = await Track.objects.get(name='The Bird') +await track.delete() # will delete the model from database +``` + +!!!tip + Note that that `track` object stays the same, only record in the database is removed. + ### update +You can delete models by using `QuerySet.update()` method or by using your model and calling `update()` method. + +```python +track = await Track.objects.get(name='The Bird') +await track.update(name='The Bird Strikes Again') +``` ## Internals diff --git a/docs/relations.md b/docs/relations.md index 0d227e9..fd6a3e1 100644 --- a/docs/relations.md +++ b/docs/relations.md @@ -85,9 +85,9 @@ Finally you can explicitly set it to None (default behavior if no value passed). Otherwise an IntegrityError will be raised by your database driver library. -### Many2Many +### ManyToMany -`Many2Many(to, through)` has required parameters `to` and `through` that takes target and relation `Model` classes. +`ManyToMany(to, through)` has required parameters `to` and `through` that takes target and relation `Model` classes. Sqlalchemy column and Type are automatically taken from target `Model`. @@ -131,7 +131,7 @@ assert len(await post.categories.all()) == 2 ``` !!!note - Note that when accessing QuerySet API methods through Many2Many relation you don't + Note that when accessing QuerySet API methods through ManyToMany relation you don't need to use objects attribute like in normal queries. To learn more about available QuerySet methods visit [queries][queries] @@ -146,7 +146,7 @@ await news.posts.clear() #### All other queryset methods -When access directly the related `Many2Many` field returns the list of related models. +When access directly the related `ManyToMany` field returns the list of related models. But at the same time it exposes full QuerySet API, so you can filter, create, select related etc. diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..c674fdb --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,45 @@ +# 0.3.8 + +* Added possibility to provide alternative database column names with name parameter to all fields. +* Fix bug with selecting related ManyToMany fields with `fields()` if they are empty. +* Updated documentation + +# 0.3.7 + +* Publish documentation and update readme + +# 0.3.6 + +* Add fields() method to limit the selected columns from database - only nullable columns can be excluded. +* Added UniqueColumns and constraints list in model Meta to build unique constraints on list of columns. +* Added UUID field type based on Char(32) column type. + +# 0.3.5 + +* Added bulk_create and bulk_update for operations on multiple objects. + +# 0.3.4 + +Add queryset level methods +* delete +* update +* get_or_create +* update_or_create + +# 0.3.3 + +* Add additional filters - startswith and endswith + +# 0.3.2 + +* Add choices parameter to all fields - limiting the accepted values to ones provided + +# 0.3.1 + +* Added exclude to filter where not conditions. +* Added tests for mysql and postgres with fixes for postgres. +* Rafactors and cleanup. + +# 0.3.0 + +* Added ManyToMany field and support for many to many relations \ No newline at end of file diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs_src/fastapi/docs001.py b/docs_src/fastapi/docs001.py new file mode 100644 index 0000000..a1d13c5 --- /dev/null +++ b/docs_src/fastapi/docs001.py @@ -0,0 +1,77 @@ +from typing import List + +import databases +import sqlalchemy +from fastapi import FastAPI + +import ormar + +app = FastAPI() +metadata = sqlalchemy.MetaData() +database = databases.Database("sqlite:///test.db", force_rollback=True) +app.state.database = database + + +@app.on_event("startup") +async def startup() -> None: + database_ = app.state.database + if not database_.is_connected: + await database_.connect() + + +@app.on_event("shutdown") +async def shutdown() -> None: + database_ = app.state.database + if database_.is_connected: + await database_.disconnect() + + +class Category(ormar.Model): + class Meta: + tablename = "categories" + metadata = metadata + database = database + + id: ormar.Integer(primary_key=True) + name: ormar.String(max_length=100) + + +class Item(ormar.Model): + class Meta: + tablename = "items" + metadata = metadata + database = database + + id: ormar.Integer(primary_key=True) + name: ormar.String(max_length=100) + category: ormar.ForeignKey(Category, nullable=True) + + +@app.get("/items/", response_model=List[Item]) +async def get_items(): + items = await Item.objects.select_related("category").all() + return items + + +@app.post("/items/", response_model=Item) +async def create_item(item: Item): + await item.save() + return item + + +@app.post("/categories/", response_model=Category) +async def create_category(category: Category): + await category.save() + return category + + +@app.put("/items/{item_id}") +async def get_item(item_id: int, item: Item): + item_db = await Item.objects.get(pk=item_id) + return await item_db.update(**item.dict()) + + +@app.delete("/items/{item_id}") +async def delete_item(item_id: int, item: Item): + item_db = await Item.objects.get(pk=item_id) + return {"deleted_rows": await item_db.delete()} diff --git a/docs_src/models/docs008.py b/docs_src/models/docs008.py new file mode 100644 index 0000000..9a3d063 --- /dev/null +++ b/docs_src/models/docs008.py @@ -0,0 +1,19 @@ +import databases +import sqlalchemy + +import ormar + +database = databases.Database("sqlite:///test.db", force_rollback=True) +metadata = sqlalchemy.MetaData() + + +class Child(ormar.Model): + class Meta: + tablename = "children" + metadata = metadata + database = database + + id: ormar.Integer(name='child_id', primary_key=True) + first_name: ormar.String(name='fname', max_length=100) + last_name: ormar.String(name='lname', max_length=100) + born_year: ormar.Integer(name='year_born', nullable=True) diff --git a/docs_src/models/docs009.py b/docs_src/models/docs009.py new file mode 100644 index 0000000..0204feb --- /dev/null +++ b/docs_src/models/docs009.py @@ -0,0 +1,9 @@ +class Album(ormar.Model): + class Meta: + tablename = "music_albums" + metadata = metadata + database = database + + id: ormar.Integer(name='album_id', primary_key=True) + name: ormar.String(name='album_name', max_length=100) + artist: ormar.ForeignKey(Artist, name='artist_id') diff --git a/docs_src/models/docs010.py b/docs_src/models/docs010.py new file mode 100644 index 0000000..57febef --- /dev/null +++ b/docs_src/models/docs010.py @@ -0,0 +1,18 @@ +class ArtistChildren(ormar.Model): + class Meta: + tablename = "children_x_artists" + metadata = metadata + database = database + + +class Artist(ormar.Model): + class Meta: + tablename = "artists" + metadata = metadata + database = database + + id: ormar.Integer(name='artist_id', primary_key=True) + first_name: ormar.String(name='fname', max_length=100) + last_name: ormar.String(name='lname', max_length=100) + born_year: ormar.Integer(name='year') + children: ormar.ManyToMany(Child, through=ArtistChildren) diff --git a/mkdocs.yml b/mkdocs.yml index 26a8ed4..a7b99e3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - Queries: queries.md - Use with Fastapi: fastapi.md - Contributing: contributing.md + - Release Notes: releases.md repo_name: collerek/ormar repo_url: https://github.com/collerek/ormar google_analytics: diff --git a/ormar/__init__.py b/ormar/__init__.py index 16db570..159fda5 100644 --- a/ormar/__init__.py +++ b/ormar/__init__.py @@ -28,7 +28,7 @@ class UndefinedType: # pragma no cover Undefined = UndefinedType() -__version__ = "0.3.7" +__version__ = "0.3.8" __all__ = [ "Integer", "BigInteger", diff --git a/ormar/queryset/queryset.py b/ormar/queryset/queryset.py index 4d421dd..df1bc01 100644 --- a/ormar/queryset/queryset.py +++ b/ormar/queryset/queryset.py @@ -357,7 +357,7 @@ class QuerySet: new_kwargs = {"new_" + k: v for k, v in new_kwargs.items() if k in columns} ready_objects.append(new_kwargs) - pk_column = self.model_meta.table.c.get(pk_name) + pk_column = self.model_meta.table.c.get(self.model.get_column_alias(pk_name)) pk_column_name = self.model.get_column_alias(pk_name) table_columns = [c.name for c in self.model_meta.table.c] expr = self.table.update().where( @@ -371,6 +371,6 @@ class QuerySet: } ) # databases bind params only where query is passed as string - # otherwise it just pases all data to values and results in unconsumed columns + # otherwise it just passes all data to values and results in unconsumed columns expr = str(expr) await self.database.execute_many(expr, ready_objects) diff --git a/tests/test_aliases.py b/tests/test_aliases.py index ab0ecda..8ca4136 100644 --- a/tests/test_aliases.py +++ b/tests/test_aliases.py @@ -121,6 +121,10 @@ async def test_bulk_operations_and_fields(): await Child.objects.bulk_update(children) + children = await Child.objects.filter(first_name='Daughter').all() + assert len(children) == 2 + assert children[0].born_year == 1890 + children = await Child.objects.fields(['first_name', 'last_name']).all() assert len(children) == 2 for child in children: