Skip to content

Commit 0717f8d

Browse files
authored
Nested objects prototype (piccolo-orm#217)
* prototype for nested objects * fix typo * finish prototype * fix linting errors * added `all_related` method * more tests * adds more tests and docs * add notes about the `prefetch` clause * make sure `prefetch` work with `get` and `get_or_create` * load all intermediate objects * another test * update docs - declaring intermediate objects no longer required
1 parent fd72c60 commit 0717f8d

File tree

17 files changed

+732
-55
lines changed

17 files changed

+732
-55
lines changed

docs/src/piccolo/query_types/objects.rst

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ can manipulate them, and save the changes back to the database.
1111
In Piccolo, an instance of a ``Table`` class represents a row. Let's do some
1212
examples.
1313

14+
-------------------------------------------------------------------------------
15+
1416
Fetching objects
1517
----------------
1618

@@ -45,6 +47,8 @@ To get the first row:
4547
You'll notice that the API is similar to :ref:`Select` - except it returns all
4648
columns.
4749

50+
-------------------------------------------------------------------------------
51+
4852
Creating objects
4953
----------------
5054

@@ -53,19 +57,23 @@ Creating objects
5357
>>> band = Band(name="C-Sharps", popularity=100)
5458
>>> band.save().run_sync()
5559
60+
-------------------------------------------------------------------------------
61+
5662
Updating objects
5763
----------------
5864

5965
Objects have a ``save`` method, which is convenient for updating values:
6066

6167
.. code-block:: python
6268
63-
pythonistas = Band.objects().where(
69+
band = Band.objects().where(
6470
Band.name == 'Pythonistas'
6571
).first().run_sync()
6672
67-
pythonistas.popularity = 100000
68-
pythonistas.save().run_sync()
73+
band.popularity = 100000
74+
band.save().run_sync()
75+
76+
-------------------------------------------------------------------------------
6977

7078
Deleting objects
7179
----------------
@@ -74,28 +82,95 @@ Similarly, we can delete objects, using the ``remove`` method.
7482

7583
.. code-block:: python
7684
77-
pythonistas = Band.objects().where(
85+
band = Band.objects().where(
7886
Band.name == 'Pythonistas'
7987
).first().run_sync()
8088
81-
pythonistas.remove().run_sync()
89+
band.remove().run_sync()
90+
91+
-------------------------------------------------------------------------------
92+
93+
Fetching related objects
94+
------------------------
8295

8396
get_related
84-
-----------
97+
~~~~~~~~~~~
98+
99+
If you have an object from a table with a ``ForeignKey`` column, and you want
100+
to fetch the related row as an object, you can do so using ``get_related``.
101+
102+
.. code-block:: python
85103
86-
If you have an object with a foreign key, and you want to fetch the related
87-
object, you can do so using ``get_related``.
104+
band = Band.objects().where(
105+
Band.name == 'Pythonistas'
106+
).first().run_sync()
107+
108+
manager = band.get_related(Band.manager).run_sync()
109+
>>> manager
110+
<Manager: 1>
111+
>>> manager.name
112+
'Guido'
113+
114+
Prefetching related objects
115+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
116+
117+
You can also prefetch the rows from related tables, and store them as child
118+
objects. To do this, pass ``ForeignKey`` columns into ``objects``, which
119+
refer to the related rows you want to load.
88120

89121
.. code-block:: python
90122
91-
pythonistas = Band.objects().where(
123+
band = Band.objects(Band.manager).where(
92124
Band.name == 'Pythonistas'
93125
).first().run_sync()
94126
95-
manager = pythonistas.get_related(Band.manager).run_sync()
96-
>>> print(manager.name)
127+
>>> band.manager
128+
<Manager: 1>
129+
>>> band.manager.name
97130
'Guido'
98131
132+
If you have a table containing lots of ``ForeignKey`` columns, and want to
133+
prefetch them all you can do so using ``all_related``.
134+
135+
.. code-block:: python
136+
137+
ticket = Ticket.objects(
138+
Ticket.concert.all_related()
139+
).first().run_sync()
140+
141+
# Any intermediate objects will also be loaded:
142+
>>> ticket.concert
143+
<Concert: 1>
144+
145+
>>> ticket.concert.band_1
146+
<Band: 1>
147+
>>> ticket.concert.band_2
148+
<Band: 2>
149+
150+
You can manipulate these nested objects, and save the values back to the
151+
database, just as you would expect:
152+
153+
.. code-block:: python
154+
155+
ticket.concert.band_1.name = 'Pythonistas 2'
156+
ticket.concert.band_1.save().run_sync()
157+
158+
Instead of passing the ``ForeignKey`` columns into the ``objects`` method, you
159+
can use the ``prefetch`` clause if you prefer.
160+
161+
.. code-block:: python
162+
163+
# These are equivalent:
164+
ticket = Ticket.objects(
165+
Ticket.concert.all_related()
166+
).first().run_sync()
167+
168+
ticket = Ticket.objects().prefetch(
169+
Ticket.concert.all_related()
170+
).run_sync()
171+
172+
-------------------------------------------------------------------------------
173+
99174
get_or_create
100175
-------------
101176

@@ -138,6 +213,8 @@ Complex where clauses are supported, but only within reason. For example:
138213
defaults={'popularity': 100}
139214
).run_sync()
140215
216+
-------------------------------------------------------------------------------
217+
141218
to_dict
142219
-------
143220

@@ -161,6 +238,7 @@ the columns:
161238
>>> band.to_dict(Band.id, Band.name.as_alias('title'))
162239
{'id': 1, 'title': 'Pythonistas'}
163240
241+
-------------------------------------------------------------------------------
164242

165243
Query clauses
166244
-------------

piccolo/columns/column_types.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,14 +1224,23 @@ def all_columns(
12241224
self, exclude: t.List[t.Union[Column, str]] = []
12251225
) -> t.List[Column]:
12261226
"""
1227-
Allow a user to access all of the columns on the related table.
1227+
Allow a user to access all of the columns on the related table. This is
1228+
intended for use with ``select`` queries, and saves the user from
1229+
typing out all of the columns by hand.
12281230
12291231
For example:
12301232
12311233
.. code-block:: python
12321234
12331235
Band.select(Band.name, Band.manager.all_columns()).run_sync()
12341236
1237+
# Equivalent to:
1238+
Band.select(
1239+
Band.name,
1240+
Band.manager.id,
1241+
Band.manager.name
1242+
).run_sync()
1243+
12351244
To exclude certain columns:
12361245
12371246
.. code-block:: python
@@ -1260,6 +1269,60 @@ def all_columns(
12601269
if column._meta.name not in excluded_column_names
12611270
]
12621271

1272+
def all_related(
1273+
self, exclude: t.List[t.Union[ForeignKey, str]] = []
1274+
) -> t.List[ForeignKey]:
1275+
"""
1276+
Returns each ``ForeignKey`` column on the related table. This is
1277+
intended for use with ``objects`` queries, where you want to return
1278+
all of the related tables as nested objects.
1279+
1280+
For example:
1281+
1282+
.. code-block:: python
1283+
1284+
class Band(Table):
1285+
name = Varchar()
1286+
1287+
class Concert(Table):
1288+
name = Varchar()
1289+
band_1 = ForeignKey(Band)
1290+
band_2 = ForeignKey(Band)
1291+
1292+
class Tour(Table):
1293+
name = Varchar()
1294+
concert = ForeignKey(Concert)
1295+
1296+
Tour.objects(Tour.concert, Tour.concert.all_related()).run_sync()
1297+
1298+
# Equivalent to
1299+
Tour.objects(
1300+
Tour.concert,
1301+
Tour.concert.band_1,
1302+
Tour.concert.band_2
1303+
).run_sync()
1304+
1305+
:param exclude:
1306+
Columns to exclude - can be the name of a column, or a
1307+
``ForeignKey`` instance. For example ``['band_1']`` or
1308+
``[Tour.concert.band_1]``.
1309+
1310+
"""
1311+
_fk_meta: ForeignKeyMeta = object.__getattribute__(
1312+
self, "_foreign_key_meta"
1313+
)
1314+
related_fk_columns = (
1315+
_fk_meta.resolved_references._meta.foreign_key_columns
1316+
)
1317+
excluded_column_names = [
1318+
i._meta.name if isinstance(i, ForeignKey) else i for i in exclude
1319+
]
1320+
return [
1321+
getattr(self, fk_column._meta.name)
1322+
for fk_column in related_fk_columns
1323+
if fk_column._meta.name not in excluded_column_names
1324+
]
1325+
12631326
def set_proxy_columns(self):
12641327
"""
12651328
In order to allow a fluent interface, where tables can be traversed
@@ -1334,7 +1397,6 @@ def __getattribute__(self, name: str):
13341397
_column._meta.call_chain = [
13351398
i for i in new_column._meta.call_chain
13361399
]
1337-
_column._meta.call_chain.append(new_column)
13381400
setattr(new_column, _column._meta.name, _column)
13391401
foreign_key_meta.proxy_columns.append(_column)
13401402

piccolo/query/base.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from piccolo.query.mixins import ColumnsDelegate
99
from piccolo.querystring import QueryString
1010
from piccolo.utils.encoding import dump_json, load_json
11+
from piccolo.utils.objects import make_nested_object
1112
from piccolo.utils.sync import run_sync
1213

1314
if t.TYPE_CHECKING: # pragma: no cover
@@ -109,14 +110,22 @@ async def _process_results(self, results): # noqa: C901
109110
# When using .first() we get a single row, not a list
110111
# of rows.
111112
if type(raw) is list:
112-
raw = [
113-
self.table(**columns, exists_in_db=True)
114-
for columns in raw
115-
]
113+
if output._output.nested:
114+
raw = [
115+
make_nested_object(row, self.table) for row in raw
116+
]
117+
else:
118+
raw = [
119+
self.table(**columns, exists_in_db=True)
120+
for columns in raw
121+
]
116122
elif raw is None:
117123
pass
118124
else:
119-
raw = self.table(**raw, exists_in_db=True)
125+
if output._output.nested:
126+
raw = make_nested_object(raw, self.table)
127+
else:
128+
raw = self.table(**raw, exists_in_db=True)
120129
elif type(raw) is list:
121130
if output._output.as_list:
122131
if len(raw) == 0:

0 commit comments

Comments
 (0)