From d7cd220774035e6f7d11c3299299c30baf2bb485 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Mon, 20 Sep 2021 15:04:44 +0430 Subject: [PATCH 1/6] add queryset delete method --- docs/making_queries.md | 153 ++++++++++++++++++++++----------------- orm/models.py | 21 ++++-- tests/test_columns.py | 11 +-- tests/test_foreignkey.py | 12 +++ tests/test_models.py | 27 +++++-- 5 files changed, 140 insertions(+), 84 deletions(-) diff --git a/docs/making_queries.md b/docs/making_queries.md index 367a557..5a56bdc 100644 --- a/docs/making_queries.md +++ b/docs/making_queries.md @@ -1,7 +1,3 @@ -## Queryset methods - -ORM supports a range of query methods which can be chained together. - Let's say you have the following model defined: ```python @@ -22,54 +18,30 @@ class Note(orm.Model): } ``` -You can use the following queryset methods: - -### Creating instances - -You need to pass the required model attributes and values to the `.create()` method: +ORM supports two types of queryset methods. +Some queryset methods return another queryset and can be chianed together like `.filter()`: ```python -await Note.objects.create(text="Buy the groceries.", completed=False) -await Note.objects.create(text="Call Mum.", completed=True) -await Note.objects.create(text="Send invoices.", completed=True) +Note.objects.filter().filter() ``` -### Querying instances - -#### .all() - -To retrieve all the instances: +Other queryset methods return results and should be used as final method on the queryset like `.all()`: ```python -notes = await Note.objects.all() +Note.objects.filter().all() ``` -#### .get() +## Returning Querysets -To get only one instance: - -```python -note = await Note.objects.get(id=1) -``` - -**Note**: `.get()` expects to find only one instance. This can raise `NoMatch` or `MultipleMatches`. - -#### .first() - -This will return the first instance or `None`: - -```python -note = await Note.objects.filter(completed=True).first() -``` +### .exclude() -`pk` always refers to the model's primary key field: +To exclude instances: ```python -note = await Note.objects.get(pk=2) -note.pk # 2 +notes = await Note.objects.exclude(completed=False).all() ``` -#### .filter() +### .filter() To filter instances: @@ -97,15 +69,31 @@ notes = await Note.objects.filter(text__icontains="mum").all() notes = await Note.objects.filter(id__in=[1, 2, 3]).all() ``` -#### .exclude() +### .limit() -To exclude instances: +To limit number of results: ```python -notes = await Note.objects.exclude(completed=False).all() +await Note.objects.limit(1).all() +``` + +### .offset() + +To apply offset to query results: + +```python +await Note.objects.offset(1).all() +``` + +As mentioned before, you can chain multiple queryset methods together to form a query. +As an exmaple: + +```python +await Note.objects.order_by("id").limit(1).offset(1).all() +await Note.objects.filter(text__icontains="mum").limit(2).all() ``` -#### .order_by() +### .order_by() To order query results: @@ -113,33 +101,52 @@ To order query results: notes = await Note.objects.order_by("text", "-id").all() ``` -**Note**: This will sort by ascending `text` and descending `id`. +!!! note + This will sort by ascending `text` and descending `id`. -#### .limit() +## Returning results -To limit number of results: +### .all() + +To retrieve all the instances: ```python -await Note.objects.limit(1).all() +notes = await Note.objects.all() ``` -#### .offset() +### .create() -To apply offset to query results: +You need to pass the required model attributes and values to the `.create()` method: ```python -await Note.objects.offset(1).all() +await Note.objects.create(text="Buy the groceries.", completed=False) +await Note.objects.create(text="Call Mum.", completed=True) +await Note.objects.create(text="Send invoices.", completed=True) ``` -As mentioned before, you can chain multiple queryset methods together to form a query. -As an exmaple: +### .delete() + +You can `delete` instances by calling `.delete()` on a queryset: ```python -await Note.objects.order_by("id").limit(1).offset(1).all() -await Note.objects.filter(text__icontains="mum").limit(2).all() +await Note.objects.filter(completed=True).delete() +``` + +It's not very common, but to delete all rows in a table: + +```python +await Note.objects.delete() ``` -#### .exists() +You can also call delete on a queried instance: + +```python +note = await Note.objects.first() + +await note.delete() +``` + +### .exists() To check if any instances matching the query exist. Returns `True` or `False`. @@ -147,39 +154,50 @@ To check if any instances matching the query exist. Returns `True` or `False`. await Note.objects.filter(completed=True).exists() ``` -### Updating instances +### .first() -`.update()` method is defined on model instances. -You need to query to get a `Note` instance first: +This will return the first instance or `None`: ```python -note = await Note.objects.first() +note = await Note.objects.filter(completed=True).first() ``` -Then update the field(s): +`pk` always refers to the model's primary key field: ```python -await note.update(completed=True) +note = await Note.objects.get(pk=2) +note.pk # 2 ``` -### Deleting instances +### .get() -`.delete()` method is defined on model instances. +To get only one instance: + +```python +note = await Note.objects.get(id=1) +``` + +!!! note + `.get()` expects to find only one instance. This can raise `NoMatch` or `MultipleMatches`. + +### .update() + +`.update()` method is defined on model instances. You need to query to get a `Note` instance first: ```python note = await Note.objects.first() ``` -Then delete the instance: +Then update the field(s): ```python -await note.delete() +await note.update(completed=True) ``` -### Convenience methods +## Convenience Methods -#### get_or_create() +### .get_or_create() To get an existing instance matching the query, or create a new one. This will retuurn a tuple of `instance` and `created`. @@ -188,4 +206,5 @@ This will retuurn a tuple of `instance` and `created`. note, created = await Note.objects.get_or_create(text="Going to car wash") ``` -**Note**: Since this is doing a [get()](#get), it can raise `MultipleMatches` exception. +!!! note + Since `get_or_create()` is doing a [get()](#get), it can raise `MultipleMatches` exception. diff --git a/orm/models.py b/orm/models.py index 590d975..a25bcdc 100644 --- a/orm/models.py +++ b/orm/models.py @@ -124,7 +124,7 @@ def database(self): return self.model_cls.registry.database @property - def table(self): + def table(self) -> sqlalchemy.Table: return self.model_cls.table @property @@ -389,10 +389,9 @@ async def create(self, **kwargs): ) kwargs = validator.validate(kwargs) - # TODO: Better to implement after UUID, probably need another database - # for key, value in fields.items(): - # if value.validator.read_only and value.validator.has_default(): - # kwargs[key] = value.validator.get_default_value() + for key, value in fields.items(): + if value.validator.read_only and value.validator.has_default(): + kwargs[key] = value.validator.get_default_value() # Build the insert expression. expr = self.table.insert() @@ -403,6 +402,16 @@ async def create(self, **kwargs): instance.pk = await self.database.execute(expr) return instance + async def delete(self, **kwargs): + if kwargs: + return await self.filter(**kwargs).delete() + + expr = self.table.delete() + for filter_clause in self.filter_clauses: + expr = expr.where(filter_clause) + + return await self.database.fetch_val(expr) + async def get_or_create(self, **kwargs) -> typing.Tuple[typing.Any, bool]: try: instance = await self.get(**kwargs) @@ -449,7 +458,7 @@ def build_table(cls): return sqlalchemy.Table(tablename, metadata, *columns, extend_existing=True) @property - def table(self): + def table(self) -> sqlalchemy.Table: return self.__class__.table async def update(self, **kwargs): diff --git a/tests/test_columns.py b/tests/test_columns.py index 7faeae1..4744fcd 100644 --- a/tests/test_columns.py +++ b/tests/test_columns.py @@ -27,14 +27,14 @@ class StatusEnum(Enum): class Example(orm.Model): registry = models fields = { - "id": orm.Integer(primary_key=True), - "uuid": orm.UUID(allow_null=True), + "uuid": orm.UUID(primary_key=True, default=uuid.uuid4), "created": orm.DateTime(default=datetime.datetime.now), "created_day": orm.Date(default=datetime.date.today), "created_time": orm.Time(default=time), "data": orm.JSON(default={}), "description": orm.Text(allow_blank=True), "huge_number": orm.BigInteger(default=0), + "number": orm.Integer(allow_null=True), "price": orm.Decimal(max_digits=5, decimal_places=2, allow_null=True), "status": orm.Enum(StatusEnum, default=StatusEnum.DRAFT), "value": orm.Float(allow_null=True), @@ -64,22 +64,23 @@ async def test_model_crud(): assert example.data == {} assert example.description == "" assert example.huge_number == 0 + assert example.number is None assert example.price is None assert example.status == StatusEnum.DRAFT - assert example.uuid is None assert example.value is None + assert isinstance(example.uuid, uuid.UUID) await example.update( data={"foo": 123}, value=123.456, + number=10, status=StatusEnum.RELEASED, price=decimal.Decimal("999.99"), - uuid=uuid.UUID("01175cde-c18f-4a13-a492-21bd9e1cb01b"), ) example = await Example.objects.get() assert example.value == 123.456 + assert example.number == 10 assert example.data == {"foo": 123} assert example.status == StatusEnum.RELEASED assert example.price == decimal.Decimal("999.99") - assert example.uuid == uuid.UUID("01175cde-c18f-4a13-a492-21bd9e1cb01b") diff --git a/tests/test_foreignkey.py b/tests/test_foreignkey.py index 4815c33..083299f 100644 --- a/tests/test_foreignkey.py +++ b/tests/test_foreignkey.py @@ -166,3 +166,15 @@ async def test_multiple_fk(): assert len(members) == 4 for member in members: assert member.team.org.ident == "ACME Ltd" + + +async def test_queryset_delete_with_fk(): + malibu = await Album.objects.create(name="Malibu") + await Track.objects.create(album=malibu, title="The Bird", position=1) + + wall = await Album.objects.create(name="The Wall") + await Track.objects.create(album=wall, title="The Wall", position=1) + + await Track.objects.filter(album=malibu).delete() + assert await Track.objects.filter(album=malibu).count() == 0 + assert await Track.objects.filter(album=wall).count() == 1 diff --git a/tests/test_models.py b/tests/test_models.py index a8a72eb..2ff7b2f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -254,11 +254,26 @@ async def test_model_search(): async def test_model_get_or_create(): - async with database: - user, created = await User.objects.get_or_create(name="Tom") + user, created = await User.objects.get_or_create(name="Tom") - assert created is True - assert await User.objects.get(pk=user.id) == user + assert created is True + assert await User.objects.get(pk=user.id) == user - user, created = await User.objects.get_or_create(name="Tom") - assert created is False + user, created = await User.objects.get_or_create(name="Tom") + assert created is False + + +async def test_queryset_delete(): + shirt = await Product.objects.create(name="Shirt", rating=5) + belt = await Product.objects.create(name="Belt", rating=5) + await Product.objects.create(name="Tie", rating=5) + await Product.objects.create(name="Trousers", rating=5) + + await Product.objects.delete(pk=shirt.id) + assert await Product.objects.count() == 3 + + await Product.objects.filter(pk=belt.id).delete() + assert await Product.objects.count() == 2 + + await Product.objects.delete() + assert await Product.objects.count() == 0 From 0aa2b455ef02591c99cf4b7116ef6fe7ed7fd5b3 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Mon, 20 Sep 2021 15:25:01 +0430 Subject: [PATCH 2/6] uplift coverage --- tests/test_columns.py | 61 +++++++++++++++++++++++++------------------ 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/tests/test_columns.py b/tests/test_columns.py index 4744fcd..2df8e78 100644 --- a/tests/test_columns.py +++ b/tests/test_columns.py @@ -24,23 +24,31 @@ class StatusEnum(Enum): RELEASED = "Released" -class Example(orm.Model): +class Product(orm.Model): registry = models fields = { - "uuid": orm.UUID(primary_key=True, default=uuid.uuid4), + "id": orm.Integer(primary_key=True), + "uuid": orm.UUID(allow_null=True), "created": orm.DateTime(default=datetime.datetime.now), "created_day": orm.Date(default=datetime.date.today), "created_time": orm.Time(default=time), "data": orm.JSON(default={}), "description": orm.Text(allow_blank=True), "huge_number": orm.BigInteger(default=0), - "number": orm.Integer(allow_null=True), "price": orm.Decimal(max_digits=5, decimal_places=2, allow_null=True), "status": orm.Enum(StatusEnum, default=StatusEnum.DRAFT), "value": orm.Float(allow_null=True), } +class User(orm.Model): + registry = models + fields = { + "id": orm.UUID(primary_key=True, default=uuid.uuid4), + "name": orm.String(allow_null=True, max_length=16), + } + + @pytest.fixture(autouse=True, scope="module") def create_test_database(): models.create_all() @@ -56,31 +64,34 @@ async def rollback_transactions(): async def test_model_crud(): - await Example.objects.create() - - example = await Example.objects.get() - assert example.created.year == datetime.datetime.now().year - assert example.created_day == datetime.date.today() - assert example.data == {} - assert example.description == "" - assert example.huge_number == 0 - assert example.number is None - assert example.price is None - assert example.status == StatusEnum.DRAFT - assert example.value is None - assert isinstance(example.uuid, uuid.UUID) - - await example.update( + await Product.objects.create() + + product = await Product.objects.get() + assert product.created.year == datetime.datetime.now().year + assert product.created_day == datetime.date.today() + assert product.data == {} + assert product.description == "" + assert product.huge_number == 0 + assert product.price is None + assert product.status == StatusEnum.DRAFT + assert product.value is None + assert product.uuid is None + + await product.update( data={"foo": 123}, value=123.456, - number=10, status=StatusEnum.RELEASED, price=decimal.Decimal("999.99"), + uuid=uuid.UUID("01175cde-c18f-4a13-a492-21bd9e1cb01b"), ) - example = await Example.objects.get() - assert example.value == 123.456 - assert example.number == 10 - assert example.data == {"foo": 123} - assert example.status == StatusEnum.RELEASED - assert example.price == decimal.Decimal("999.99") + product = await Product.objects.get() + assert product.value == 123.456 + assert product.data == {"foo": 123} + assert product.status == StatusEnum.RELEASED + assert product.price == decimal.Decimal("999.99") + assert product.uuid == uuid.UUID("01175cde-c18f-4a13-a492-21bd9e1cb01b") + + await User.objects.create(name="Chris") + user = await User.objects.get(name="Chris") + assert user.name == "Chris" From c34edf4e2387978225e27fb4dd285efcf2e480c1 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Mon, 20 Sep 2021 15:28:31 +0430 Subject: [PATCH 3/6] more explicit type hints --- orm/models.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/orm/models.py b/orm/models.py index a25bcdc..6e05a99 100644 --- a/orm/models.py +++ b/orm/models.py @@ -402,7 +402,7 @@ async def create(self, **kwargs): instance.pk = await self.database.execute(expr) return instance - async def delete(self, **kwargs): + async def delete(self, **kwargs) -> None: if kwargs: return await self.filter(**kwargs).delete() @@ -410,7 +410,7 @@ async def delete(self, **kwargs): for filter_clause in self.filter_clauses: expr = expr.where(filter_clause) - return await self.database.fetch_val(expr) + return await self.database.execute(expr) async def get_or_create(self, **kwargs) -> typing.Tuple[typing.Any, bool]: try: @@ -481,12 +481,10 @@ async def update(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) - async def delete(self): - # Build the delete expression. + async def delete(self) -> None: pk_column = getattr(self.table.c, self.pkname) expr = self.table.delete().where(pk_column == self.pk) - # Perform the delete. await self.database.execute(expr) async def load(self): From 14933887b26784d546f44f928c278d1a8942932b Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Mon, 20 Sep 2021 17:39:49 +0430 Subject: [PATCH 4/6] remove filtering in delete --- orm/models.py | 3 --- tests/test_models.py | 8 ++------ 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/orm/models.py b/orm/models.py index 6e05a99..6afb945 100644 --- a/orm/models.py +++ b/orm/models.py @@ -403,9 +403,6 @@ async def create(self, **kwargs): return instance async def delete(self, **kwargs) -> None: - if kwargs: - return await self.filter(**kwargs).delete() - expr = self.table.delete() for filter_clause in self.filter_clauses: expr = expr.where(filter_clause) diff --git a/tests/test_models.py b/tests/test_models.py index 2ff7b2f..7afadf7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -265,14 +265,10 @@ async def test_model_get_or_create(): async def test_queryset_delete(): shirt = await Product.objects.create(name="Shirt", rating=5) - belt = await Product.objects.create(name="Belt", rating=5) + await Product.objects.create(name="Belt", rating=5) await Product.objects.create(name="Tie", rating=5) - await Product.objects.create(name="Trousers", rating=5) - await Product.objects.delete(pk=shirt.id) - assert await Product.objects.count() == 3 - - await Product.objects.filter(pk=belt.id).delete() + await Product.objects.filter(pk=shirt.id).delete() assert await Product.objects.count() == 2 await Product.objects.delete() From d93d168b883a53c224b6ae3166a7beae7fe6fdef Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Mon, 20 Sep 2021 17:44:55 +0430 Subject: [PATCH 5/6] update delete --- orm/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orm/models.py b/orm/models.py index 6afb945..216f535 100644 --- a/orm/models.py +++ b/orm/models.py @@ -402,7 +402,7 @@ async def create(self, **kwargs): instance.pk = await self.database.execute(expr) return instance - async def delete(self, **kwargs) -> None: + async def delete(self) -> None: expr = self.table.delete() for filter_clause in self.filter_clauses: expr = expr.where(filter_clause) From b6f579f3f7e7625fc5cd2a3fcef17639daea28a9 Mon Sep 17 00:00:00 2001 From: Amin Alaee Date: Tue, 21 Sep 2021 09:27:27 +0430 Subject: [PATCH 6/6] Update example in docs --- docs/making_queries.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/making_queries.md b/docs/making_queries.md index 5a56bdc..e0c5d17 100644 --- a/docs/making_queries.md +++ b/docs/making_queries.md @@ -19,16 +19,16 @@ class Note(orm.Model): ``` ORM supports two types of queryset methods. -Some queryset methods return another queryset and can be chianed together like `.filter()`: +Some queryset methods return another queryset and can be chianed together like `.filter()` and `order_by`: ```python -Note.objects.filter().filter() +Note.objects.filter(completed=True).order_by("id") ``` Other queryset methods return results and should be used as final method on the queryset like `.all()`: ```python -Note.objects.filter().all() +Note.objects.filter(completed=True).all() ``` ## Returning Querysets