Skip to content

Commit 61e2ede

Browse files
committed
added support for group_by and Count
1 parent f7a6520 commit 61e2ede

File tree

7 files changed

+195
-1
lines changed

7 files changed

+195
-1
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
.. _group_by:
2+
3+
group_by
4+
========
5+
6+
You can use ``group_by`` clauses with the following queries:
7+
8+
* :ref:`Select`
9+
10+
It is used in combination with aggregate functions - ``Count`` is currently
11+
supported.
12+
13+
Count
14+
-----
15+
16+
In the following query, we get a count of the number of bands per manager:
17+
18+
.. code-block:: python
19+
20+
>>> from piccolo.query.methods.select import Count
21+
22+
>>> b = Band
23+
>>> b.select(
24+
>>> b.manager.name,
25+
>>> Count(b.manager)
26+
>>> ).group_by(
27+
>>> b.manager
28+
>>> ).run_sync()
29+
30+
[
31+
{"manager.name": "Graydon", "count": 1},
32+
{"manager.name": "Guido", "count": 1}
33+
]
34+
35+
.. currentmodule:: piccolo.query.methods.select
36+
37+
.. autoclass:: Count

docs/src/piccolo/query_clauses/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ by modifying the return values.
1010
:maxdepth: 0
1111

1212
./first
13+
./group_by
1314
./limit
1415
./offset
1516
./order_by

docs/src/piccolo/query_types/select.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ first
114114

115115
See  :ref:`first`.
116116

117+
group_by
118+
~~~~~~~~
119+
120+
See  :ref:`group_by`.
121+
117122
limit
118123
~~~~~
119124

piccolo/columns/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ def _validate_default(
236236
"""
237237
if getattr(self, "validated", None):
238238
# If it has previously been validated by a subclass, don't
239-
# validated again.
239+
# validate again.
240240
return True
241241
elif (
242242
default is None

piccolo/query/methods/select.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from piccolo.query.mixins import (
1010
ColumnsDelegate,
1111
DistinctDelegate,
12+
GroupByDelegate,
1213
LimitDelegate,
1314
OffsetDelegate,
1415
OrderByDelegate,
@@ -22,13 +23,36 @@
2223
from piccolo.custom_types import Combinable
2324

2425

26+
class Count(Selectable):
27+
"""
28+
Used in conjunction with the ``group_by`` clause in ``Select`` queries.
29+
30+
If a column is specified, the count is for non-null values in that
31+
column. If no column is specified, the count is for all rows, whether
32+
they have null values or not.
33+
"""
34+
35+
def __init__(self, column: t.Optional[Column] = None):
36+
self.column = column
37+
38+
def get_select_string(self, engine_type: str, just_alias=False) -> str:
39+
if self.column is None:
40+
column_name = "*"
41+
else:
42+
column_name = self.column._meta.get_full_name(
43+
just_alias=just_alias
44+
)
45+
return f"COUNT({column_name}) AS count"
46+
47+
2548
class Select(Query):
2649

2750
__slots__ = (
2851
"columns_list",
2952
"exclude_secrets",
3053
"columns_delegate",
3154
"distinct_delegate",
55+
"group_by_delegate",
3256
"limit_delegate",
3357
"offset_delegate",
3458
"order_by_delegate",
@@ -47,6 +71,7 @@ def __init__(
4771

4872
self.columns_delegate = ColumnsDelegate()
4973
self.distinct_delegate = DistinctDelegate()
74+
self.group_by_delegate = GroupByDelegate()
5075
self.limit_delegate = LimitDelegate()
5176
self.offset_delegate = OffsetDelegate()
5277
self.order_by_delegate = OrderByDelegate()
@@ -64,6 +89,11 @@ def distinct(self) -> Select:
6489
self.distinct_delegate.distinct()
6590
return self
6691

92+
def group_by(self, *columns: Column) -> Select:
93+
columns = self.table._process_column_args(*columns)
94+
self.group_by_delegate.group_by(*columns)
95+
return self
96+
6797
def limit(self, number: int) -> Select:
6898
self.limit_delegate.limit(number)
6999
return self
@@ -220,6 +250,10 @@ def querystrings(self) -> t.Sequence[QueryString]:
220250
query += " WHERE {}"
221251
args.append(self.where_delegate._where.querystring)
222252

253+
if self.group_by_delegate._group_by:
254+
query += " {}"
255+
args.append(self.group_by_delegate._group_by.querystring)
256+
223257
if self.order_by_delegate._order_by:
224258
query += " {}"
225259
args.append(self.order_by_delegate._order_by.querystring)

piccolo/query/mixins.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,34 @@ class OffsetDelegate:
233233

234234
def offset(self, number: int = 0):
235235
self._offset = Offset(number)
236+
237+
238+
@dataclass
239+
class GroupBy:
240+
__slots__ = ("columns",)
241+
242+
columns: t.Sequence[Column]
243+
244+
@property
245+
def querystring(self) -> QueryString:
246+
columns_names = ", ".join(
247+
[i._meta.get_full_name(just_alias=True) for i in self.columns]
248+
)
249+
return QueryString(f" GROUP BY {columns_names}")
250+
251+
def __str__(self):
252+
return self.querystring.__str__()
253+
254+
255+
@dataclass
256+
class GroupByDelegate:
257+
"""
258+
Used to group results - needed when doing aggregation.
259+
260+
.group_by(Band.name)
261+
"""
262+
263+
_group_by: t.Optional[GroupBy] = None
264+
265+
def group_by(self, *columns: Column):
266+
self._group_by = GroupBy(columns=columns)

tests/table/test_select.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from unittest import TestCase
22

33
from piccolo.apps.user.tables import BaseUser
4+
from piccolo.query.methods.select import Count
45

56
from ..base import DBTestCase, postgres_only, sqlite_only
67
from ..example_project.tables import Band, Concert
@@ -293,6 +294,91 @@ def test_distinct(self):
293294

294295
self.assertTrue(response == [{"name": "Pythonistas"}])
295296

297+
def test_count_group_by(self):
298+
"""
299+
Test grouping and counting all rows.
300+
"""
301+
self.insert_rows()
302+
self.insert_rows()
303+
304+
response = (
305+
Band.select(Band.name, Count())
306+
.group_by(Band.name)
307+
.order_by(Band.name)
308+
.run_sync()
309+
)
310+
311+
self.assertTrue(
312+
response
313+
== [
314+
{"name": "CSharps", "count": 2},
315+
{"name": "Pythonistas", "count": 2},
316+
{"name": "Rustaceans", "count": 2},
317+
]
318+
)
319+
320+
def test_count_column_group_by(self):
321+
"""
322+
Test grouping and counting a specific column. Any null values in the
323+
specified column will be omitted from the count.
324+
"""
325+
self.insert_rows()
326+
self.insert_rows()
327+
self.run_sync(
328+
"""
329+
INSERT INTO band (
330+
name,
331+
manager,
332+
popularity
333+
) VALUES (
334+
'SomeBand',
335+
null,
336+
1000
337+
);"""
338+
)
339+
340+
response = (
341+
Band.select(Band.manager.name, Count(Band.manager))
342+
.group_by(Band.manager.name)
343+
.order_by(Band.manager.name)
344+
.run_sync()
345+
)
346+
347+
# We need to sort them, because SQLite and Postgres treat Null
348+
# differently when sorting.
349+
response = sorted(response, key=lambda x: x["manager.name"] or "")
350+
351+
self.assertTrue(
352+
response
353+
== [
354+
{"manager.name": None, "count": 0},
355+
{"manager.name": "Graydon", "count": 2},
356+
{"manager.name": "Guido", "count": 2},
357+
{"manager.name": "Mads", "count": 2},
358+
]
359+
)
360+
361+
# This time the nulls should be counted, as we omit the column argument
362+
# from Count:
363+
response = (
364+
Band.select(Band.manager.name, Count())
365+
.group_by(Band.manager.name)
366+
.order_by(Band.manager.name)
367+
.run_sync()
368+
)
369+
370+
response = sorted(response, key=lambda x: x["manager.name"] or "")
371+
372+
self.assertTrue(
373+
response
374+
== [
375+
{"manager.name": None, "count": 1},
376+
{"manager.name": "Graydon", "count": 2},
377+
{"manager.name": "Guido", "count": 2},
378+
{"manager.name": "Mads", "count": 2},
379+
]
380+
)
381+
296382
def test_columns(self):
297383
"""
298384
Make sure the colums method can be used to specify which columns to

0 commit comments

Comments
 (0)