From 67904980ce3cf7e96da65b967c15eb6f830a50b2 Mon Sep 17 00:00:00 2001 From: collerek Date: Mon, 15 Mar 2021 18:45:46 +0100 Subject: [PATCH] add docs --- docs/models/index.md | 45 ++++++ docs/queries/aggregations.md | 233 +++++++++++++++++++++++++++++- docs/queries/filter-and-sort.md | 34 ++++- docs/relations/index.md | 52 +++++++ docs/signals.md | 41 ++++++ docs_src/aggregations/__init__.py | 0 docs_src/aggregations/docs001.py | 36 +++++ ormar/queryset/query.py | 13 +- 8 files changed, 449 insertions(+), 5 deletions(-) create mode 100644 docs_src/aggregations/__init__.py create mode 100644 docs_src/aggregations/docs001.py diff --git a/docs/models/index.md b/docs/models/index.md index 35ab50c..0094dbb 100644 --- a/docs/models/index.md +++ b/docs/models/index.md @@ -368,6 +368,51 @@ You can set this parameter by providing `Meta` class `constraints` argument. --8<-- "../docs_src/models/docs006.py" ``` +## Model sort order + +When querying the database with given model by default the Model is ordered by the `primary_key` +column ascending. If you wish to change the default behaviour you can do it by providing `orders_by` +parameter to model `Meta` class. + +Sample default ordering: +```python +database = databases.Database(DATABASE_URL) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ormar.ModelMeta): + metadata = metadata + database = database + +# default sort by column id ascending +class Author(ormar.Model): + class Meta(BaseMeta): + tablename = "authors" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) +``` +Modified +```python + +database = databases.Database(DATABASE_URL) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ormar.ModelMeta): + metadata = metadata + database = database + +# now default sort by name descending +class Author(ormar.Model): + class Meta(BaseMeta): + tablename = "authors" + orders_by = ["-name"] + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) +``` + ## Model Initialization There are two ways to create and persist the `Model` instance in the database. diff --git a/docs/queries/aggregations.md b/docs/queries/aggregations.md index 25f5512..53426de 100644 --- a/docs/queries/aggregations.md +++ b/docs/queries/aggregations.md @@ -1,15 +1,23 @@ # Aggregation functions -Currently 2 aggregation functions are supported. +Currently 6 aggregation functions are supported. * `count() -> int` * `exists() -> bool` +* `sum(columns) -> Any` +* `avg(columns) -> Any` +* `min(columns) -> Any` +* `max(columns) -> Any` * `QuerysetProxy` * `QuerysetProxy.count()` method * `QuerysetProxy.exists()` method + * `QuerysetProxy.sum(columns)` method + * `QuerysetProxy.avg(columns)` method + * `QuerysetProxy.min(column)` method + * `QuerysetProxy.max(columns)` method ## count @@ -68,6 +76,209 @@ class Book(ormar.Model): has_sample = await Book.objects.filter(title='Sample').exists() ``` +## sum + +`sum(columns) -> Any` + +Returns sum value of columns for rows matching the given criteria (applied with `filter` and `exclude` if set before). + +You can pass one or many column names including related columns. + +As of now each column passed is aggregated separately (so `sum(col1+col2)` is not possible, +you can have `sum(col1, col2)` and later add 2 returned sums in python) + +You cannot `sum` non numeric columns. + +If you aggregate on one column, the single value is directly returned as a result +If you aggregate on multiple columns a dictionary with column: result pairs is returned + +Given models like follows + +```Python +--8<-- "../docs_src/aggregations/docs001.py" +``` + +A sample usage might look like following + +```python +author = await Author(name="Author 1").save() +await Book(title="Book 1", year=1920, ranking=3, author=author).save() +await Book(title="Book 2", year=1930, ranking=1, author=author).save() +await Book(title="Book 3", year=1923, ranking=5, author=author).save() + +assert await Book.objects.sum("year") == 5773 +result = await Book.objects.sum(["year", "ranking"]) +assert result == dict(year=5773, ranking=9) + +try: + # cannot sum string column + await Book.objects.sum("title") +except ormar.QueryDefinitionError: + pass + +assert await Author.objects.select_related("books").sum("books__year") == 5773 +result = await Author.objects.select_related("books").sum( + ["books__year", "books__ranking"] +) +assert result == dict(books__year=5773, books__ranking=9) + +assert ( + await Author.objects.select_related("books") + .filter(books__year__lt=1925) + .sum("books__year") + == 3843 +) +``` + +## avg + +`avg(columns) -> Any` + +Returns avg value of columns for rows matching the given criteria (applied with `filter` and `exclude` if set before). + +You can pass one or many column names including related columns. + +As of now each column passed is aggregated separately (so `sum(col1+col2)` is not possible, +you can have `sum(col1, col2)` and later add 2 returned sums in python) + +You cannot `avg` non numeric columns. + +If you aggregate on one column, the single value is directly returned as a result +If you aggregate on multiple columns a dictionary with column: result pairs is returned + +```Python +--8<-- "../docs_src/aggregations/docs001.py" +``` + +A sample usage might look like following + +```python +author = await Author(name="Author 1").save() +await Book(title="Book 1", year=1920, ranking=3, author=author).save() +await Book(title="Book 2", year=1930, ranking=1, author=author).save() +await Book(title="Book 3", year=1923, ranking=5, author=author).save() + +assert round(float(await Book.objects.avg("year")), 2) == 1924.33 +result = await Book.objects.avg(["year", "ranking"]) +assert round(float(result.get("year")), 2) == 1924.33 +assert result.get("ranking") == 3.0 + +try: + # cannot avg string column + await Book.objects.avg("title") +except ormar.QueryDefinitionError: + pass + +result = await Author.objects.select_related("books").avg("books__year") +assert round(float(result), 2) == 1924.33 +result = await Author.objects.select_related("books").avg( + ["books__year", "books__ranking"] +) +assert round(float(result.get("books__year")), 2) == 1924.33 +assert result.get("books__ranking") == 3.0 + +assert ( + await Author.objects.select_related("books") + .filter(books__year__lt=1925) + .avg("books__year") + == 1921.5 +) +``` + +## min + +`min(columns) -> Any` + +Returns min value of columns for rows matching the given criteria (applied with `filter` and `exclude` if set before). + +You can pass one or many column names including related columns. + +As of now each column passed is aggregated separately (so `sum(col1+col2)` is not possible, +you can have `sum(col1, col2)` and later add 2 returned sums in python) + +If you aggregate on one column, the single value is directly returned as a result +If you aggregate on multiple columns a dictionary with column: result pairs is returned + +```Python +--8<-- "../docs_src/aggregations/docs001.py" +``` + +A sample usage might look like following + +```python +author = await Author(name="Author 1").save() +await Book(title="Book 1", year=1920, ranking=3, author=author).save() +await Book(title="Book 2", year=1930, ranking=1, author=author).save() +await Book(title="Book 3", year=1923, ranking=5, author=author).save() + +assert await Book.objects.min("year") == 1920 +result = await Book.objects.min(["year", "ranking"]) +assert result == dict(year=1920, ranking=1) + +assert await Book.objects.min("title") == "Book 1" + +assert await Author.objects.select_related("books").min("books__year") == 1920 +result = await Author.objects.select_related("books").min( + ["books__year", "books__ranking"] +) +assert result == dict(books__year=1920, books__ranking=1) + +assert ( + await Author.objects.select_related("books") + .filter(books__year__gt=1925) + .min("books__year") + == 1930 +) +``` + +## max + +`max(columns) -> Any` + +Returns max value of columns for rows matching the given criteria (applied with `filter` and `exclude` if set before). + +Returns min value of columns for rows matching the given criteria (applied with `filter` and `exclude` if set before). + +You can pass one or many column names including related columns. + +As of now each column passed is aggregated separately (so `sum(col1+col2)` is not possible, +you can have `sum(col1, col2)` and later add 2 returned sums in python) + +If you aggregate on one column, the single value is directly returned as a result +If you aggregate on multiple columns a dictionary with column: result pairs is returned + +```Python +--8<-- "../docs_src/aggregations/docs001.py" +``` + +A sample usage might look like following + +```python +author = await Author(name="Author 1").save() +await Book(title="Book 1", year=1920, ranking=3, author=author).save() +await Book(title="Book 2", year=1930, ranking=1, author=author).save() +await Book(title="Book 3", year=1923, ranking=5, author=author).save() + +assert await Book.objects.max("year") == 1930 +result = await Book.objects.max(["year", "ranking"]) +assert result == dict(year=1930, ranking=5) + +assert await Book.objects.max("title") == "Book 3" + +assert await Author.objects.select_related("books").max("books__year") == 1930 +result = await Author.objects.select_related("books").max( + ["books__year", "books__ranking"] +) +assert result == dict(books__year=1930, books__ranking=5) + +assert ( + await Author.objects.select_related("books") + .filter(books__year__lt=1925) + .max("books__year") + == 1923 +) +``` + ## QuerysetProxy methods When access directly the related `ManyToMany` field as well as `ReverseForeignKey` @@ -89,6 +300,26 @@ objects from other side of the relation. Works exactly the same as [exists](./#exists) function above but allows you to select columns from related objects from other side of the relation. +### sum + +Works exactly the same as [sum](./#sum) function above but allows you to sum columns from related +objects from other side of the relation. + +### avg + +Works exactly the same as [avg](./#avg) function above but allows you to average columns from related +objects from other side of the relation. + +### min + +Works exactly the same as [min](./#min) function above but allows you to select minimum of columns from related +objects from other side of the relation. + +### max + +Works exactly the same as [max](./#max) function above but allows you to select maximum of columns from related +objects from other side of the relation. + !!!tip To read more about `QuerysetProxy` visit [querysetproxy][querysetproxy] section diff --git a/docs/queries/filter-and-sort.md b/docs/queries/filter-and-sort.md index a2b74e9..227cf00 100644 --- a/docs/queries/filter-and-sort.md +++ b/docs/queries/filter-and-sort.md @@ -289,7 +289,7 @@ books = ( ``` If you want or need to you can nest deeper conditions as deep as you want, in example to -acheive a query like this: +achieve a query like this: sql: ``` @@ -564,6 +564,38 @@ assert owner.toys[1].name == "Toy 1" Something like `Track.object.select_related("album").filter(album__name="Malibu").offset(1).limit(1).all()` +### Default sorting in ormar + +Since order of rows in a database is not guaranteed, `ormar` **always** issues an `order by` sql clause to each (part of) query even if you do not provide order yourself. + +When querying the database with given model by default the `Model` is ordered by the `primary_key` +column ascending. If you wish to change the default behaviour you can do it by providing `orders_by` +parameter to model `Meta` class. + +!!!tip + To read more about models sort order visit [models](../models/index.md#model-sort-order) section of documentation + +By default the relations follow the same ordering, but you can modify the order in which related models are loaded during query by providing `orders_by` and `related_orders_by` +parameters to relations. + +!!!tip + To read more about models sort order visit [relations](../relations/index.md#relationship-default-sort-order) section of documentation + +Order in which order_by clauses are applied is as follows: + + * Explicitly passed `order_by()` calls in query + * Relation passed `orders_by` and `related_orders_by` if exists + * Model `Meta` class `orders_by` + * Model `primary_key` column ascending (fallback, used if none of above provided) + +**Order from only one source is applied to each `Model` (so that you can always overwrite it in a single query).** + +That means that if you provide explicit `order_by` for a model in a query, the `Relation` and `Model` sort orders are skipped. + +If you provide a `Relation` one, the `Model` sort is skipped. + +Finally, if you provide one for `Model` the default one by `primary_key` is skipped. + ### QuerysetProxy methods When access directly the related `ManyToMany` field as well as `ReverseForeignKey` diff --git a/docs/relations/index.md b/docs/relations/index.md index 76ab3ba..385516a 100644 --- a/docs/relations/index.md +++ b/docs/relations/index.md @@ -128,6 +128,58 @@ class Post(ormar.Model): It allows you to use `await post.categories.all()` but also `await category.posts.all()` to fetch data related only to specific post, category etc. +## Relationship default sort order + +By default relations follow model default sort order so `primary_key` column ascending, or any sort order se in `Meta` class. + +!!!tip + To read more about models sort order visit [models](../models/index.md#model-sort-order) section of documentation + +But you can modify the order in which related models are loaded during query by providing `orders_by` and `related_orders_by` +parameters to relations. + +In relations you can sort only by directly related model columns or for `ManyToMany` +columns also `Through` model columns `{through_field_name}__{column_name}` + +Sample configuration might look like this: + +```python hl_lines="24" +database = databases.Database(DATABASE_URL) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ormar.ModelMeta): + metadata = metadata + database = database + + +class Author(ormar.Model): + class Meta(BaseMeta): + tablename = "authors" + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + + +class Book(ormar.Model): + class Meta(BaseMeta): + tablename = "books" + + id: int = ormar.Integer(primary_key=True) + author: Optional[Author] = ormar.ForeignKey( + Author, orders_by=["name"], related_orders_by=["-year"] + ) + title: str = ormar.String(max_length=100) + year: int = ormar.Integer(nullable=True) + ranking: int = ormar.Integer(nullable=True) +``` + +Now calls: + +`await Author.objects.select_related("books").get()` - the books will be sorted by the book year descending + +`await Book.objects.select_related("author").all()` - the authors will be sorted by author name ascending + ## Self-reference and postponed references In order to create auto-relation or create two models that reference each other in at least two diff --git a/docs/signals.md b/docs/signals.md index 14286ca..bc11238 100644 --- a/docs/signals.md +++ b/docs/signals.md @@ -192,6 +192,47 @@ Send for `Model.update()` method. `sender` is a `ormar.Model` class and `instance` is the model that was deleted. +### pre_relation_add + +`pre_relation_add(sender: Type["Model"], instance: "Model", child: "Model", +relation_name: str, passed_args: Dict)` + +Send for `Model.relation_name.add()` method for `ManyToMany` relations and reverse side of `ForeignKey` relation. + +`sender` - sender class, `instance` - instance to which related model is added, `child` - model being added, +`relation_name` - name of the relation to which child is added, for add signals also `passed_kwargs` - dict of kwargs passed to `add()` + +### post_relation_add + +`post_relation_add(sender: Type["Model"], instance: "Model", child: "Model", +relation_name: str, passed_args: Dict)` + +Send for `Model.relation_name.add()` method for `ManyToMany` relations and reverse side of `ForeignKey` relation. + +`sender` - sender class, `instance` - instance to which related model is added, `child` - model being added, +`relation_name` - name of the relation to which child is added, for add signals also `passed_kwargs` - dict of kwargs passed to `add()` + +### pre_relation_remove + +`pre_relation_remove(sender: Type["Model"], instance: "Model", child: "Model", +relation_name: str)` + +Send for `Model.relation_name.remove()` method for `ManyToMany` relations and reverse side of `ForeignKey` relation. + +`sender` - sender class, `instance` - instance to which related model is added, `child` - model being added, +`relation_name` - name of the relation to which child is added. + +### post_relation_remove + +`post_relation_remove(sender: Type["Model"], instance: "Model", child: "Model", +relation_name: str, passed_args: Dict)` + +Send for `Model.relation_name.remove()` method for `ManyToMany` relations and reverse side of `ForeignKey` relation. + +`sender` - sender class, `instance` - instance to which related model is added, `child` - model being added, +`relation_name` - name of the relation to which child is added. + + ## Defining your own signals Note that you can create your own signals although you will have to send them manually in your code or subclass `ormar.Model` diff --git a/docs_src/aggregations/__init__.py b/docs_src/aggregations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs_src/aggregations/docs001.py b/docs_src/aggregations/docs001.py new file mode 100644 index 0000000..bc81e04 --- /dev/null +++ b/docs_src/aggregations/docs001.py @@ -0,0 +1,36 @@ +from typing import Optional + +import databases +import sqlalchemy + +import ormar +from tests.settings import DATABASE_URL + +database = databases.Database(DATABASE_URL) +metadata = sqlalchemy.MetaData() + + +class BaseMeta(ormar.ModelMeta): + metadata = metadata + database = database + + +class Author(ormar.Model): + class Meta(BaseMeta): + tablename = "authors" + order_by = ["-name"] + + id: int = ormar.Integer(primary_key=True) + name: str = ormar.String(max_length=100) + + +class Book(ormar.Model): + class Meta(BaseMeta): + tablename = "books" + order_by = ["year", "-ranking"] + + id: int = ormar.Integer(primary_key=True) + author: Optional[Author] = ormar.ForeignKey(Author) + title: str = ormar.String(max_length=100) + year: int = ormar.Integer(nullable=True) + ranking: int = ormar.Integer(nullable=True) diff --git a/ormar/queryset/query.py b/ormar/queryset/query.py index 422b345..7b4b389 100644 --- a/ormar/queryset/query.py +++ b/ormar/queryset/query.py @@ -71,9 +71,16 @@ class Query: self.sorted_orders[clause] = clause.get_text_clause() if not current_table_sorted: - for order_by in self.model_cls.Meta.orders_by: - clause = ormar.OrderAction(order_str=order_by, model_cls=self.model_cls) - self.sorted_orders[clause] = clause.get_text_clause() + self._apply_default_model_sorting() + + def _apply_default_model_sorting(self) -> None: + """ + Applies orders_by from model Meta class (if provided), if it was not provided + it was filled by metaclass so it's always there and falls back to pk column + """ + for order_by in self.model_cls.Meta.orders_by: + clause = ormar.OrderAction(order_str=order_by, model_cls=self.model_cls) + self.sorted_orders[clause] = clause.get_text_clause() def _pagination_query_required(self) -> bool: """