Skip to content

Commit

Permalink
1028 Document how to achieve a OneToOneField in Piccolo (#1029)
Browse files Browse the repository at this point in the history
* add reverse method

* one to one field docs

* add FanClub to playground

* first attempt at making reverse work on multiple levels

* make reverse work multiple levels deep

* remove unused import

* more on to one to its own page

* break up the one to one docs into sections

* final tweaks to docs
  • Loading branch information
dantownsend committed Jun 19, 2024
1 parent 2335b95 commit 4be8aec
Show file tree
Hide file tree
Showing 10 changed files with 244 additions and 23 deletions.
10 changes: 10 additions & 0 deletions docs/src/piccolo/query_types/django_comparison.rst
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,16 @@ Piccolo has something similar:
>>> band.manager
<Manager: 1>
-------------------------------------------------------------------------------

Schema
------

OneToOneField
~~~~~~~~~~~~~

To do this in Piccolo, use a ``ForeignKey`` with a unique constraint - see
:ref:`One to One<OneToOne>`.

-------------------------------------------------------------------------------

Expand Down
1 change: 1 addition & 0 deletions docs/src/piccolo/schema/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ The schema is how you define your database tables, columns and relationships.
./defining
./column_types
./m2m
./one_to_one
./advanced
85 changes: 85 additions & 0 deletions docs/src/piccolo/schema/one_to_one.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
.. _OneToOne:

One to One
==========

Schema
------

A one to one relationship is basically just a foreign key with a unique
constraint. In Piccolo, you can do it like this:

.. code-block:: python
from piccolo.table import Table
from piccolo.columns import ForeignKey, Varchar, Text
class Band(Table):
name = Varchar()
class FanClub(Table):
band = ForeignKey(Band, unique=True) # <- Note the unique constraint
address = Text()
Queries
-------

Getting a related object
~~~~~~~~~~~~~~~~~~~~~~~~

If we have a ``Band`` object:

.. code-block:: python
band = await Band.objects().where(Band.name == "Pythonistas").first()
To get the associated ``FanClub`` object, you could do this:

.. code-block:: python
fan_club = await FanClub.objects().where(FanClub.band == band).first()
Or alternatively, using ``get_related``:

.. code-block:: python
fan_club = await band.get_related(Band.id.join_on(FanClub.band))
Instead of using ``join_on``, you can use ``reverse`` to traverse the foreign
key backwards if you prefer:

.. code-block:: python
fan_club = await band.get_related(FanClub.band.reverse())
Select
~~~~~~

If doing a select query, and you want data from the related table:

.. code-block:: python
>>> await Band.select(
... Band.name,
... Band.id.join_on(FanClub.band).address.as_alias("address")
... )
[{'name': 'Pythonistas', 'address': '1 Flying Circus, UK'}, ...]
Where
~~~~~

If you want to filter by related tables in the ``where`` clause:

.. code-block:: python
>>> await Band.select(
... Band.name,
... ).where(Band.id.join_on(FanClub.band).address.like("%Flying%"))
[{'name': 'Pythonistas'}]
Source
------

.. currentmodule:: piccolo.columns.column_types

.. automethod:: ForeignKey.reverse
11 changes: 11 additions & 0 deletions piccolo/apps/playground/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Interval,
Numeric,
Serial,
Text,
Timestamp,
Varchar,
)
Expand Down Expand Up @@ -55,6 +56,12 @@ def get_readable(cls) -> Readable:
)


class FanClub(Table):
id: Serial
address = Text()
band = ForeignKey(Band, unique=True)


class Venue(Table):
id: Serial
name = Varchar(length=100)
Expand Down Expand Up @@ -154,6 +161,7 @@ def get_readable(cls) -> Readable:
TABLES = (
Manager,
Band,
FanClub,
Venue,
Concert,
Ticket,
Expand Down Expand Up @@ -185,6 +193,9 @@ def populate():
pythonistas = Band(name="Pythonistas", manager=guido.id, popularity=1000)
pythonistas.save().run_sync()

fan_club = FanClub(address="1 Flying Circus, UK", band=pythonistas)
fan_club.save().run_sync()

graydon = Manager(name="Graydon")
graydon.save().run_sync()

Expand Down
55 changes: 55 additions & 0 deletions piccolo/columns/column_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2016,6 +2016,61 @@ def all_columns(
if column._meta.name not in excluded_column_names
]

def reverse(self) -> ForeignKey:
"""
If there's a unique foreign key, this function reverses it.
.. code-block:: python
class Band(Table):
name = Varchar()
class FanClub(Table):
band = ForeignKey(Band, unique=True)
address = Text()
class Treasurer(Table):
fan_club = ForeignKey(FanClub, unique=True)
name = Varchar()
It's helpful with ``get_related``, for example:
.. code-block:: python
>>> band = await Band.objects().first()
>>> await band.get_related(FanClub.band.reverse())
<Fan Club: 1>
It works multiple levels deep:
.. code-block:: python
>>> await band.get_related(Treasurer.fan_club._.band.reverse())
<Treasurer: 1>
"""
if not self._meta.unique or any(
not i._meta.unique for i in self._meta.call_chain
):
raise ValueError("Only reverse unique foreign keys.")

foreign_keys = [*self._meta.call_chain, self]

root_foreign_key = foreign_keys[0]
target_column = (
root_foreign_key._foreign_key_meta.resolved_target_column
)
foreign_key = target_column.join_on(root_foreign_key)

call_chain = []
for fk in reversed(foreign_keys[1:]):
target_column = fk._foreign_key_meta.resolved_target_column
call_chain.append(target_column.join_on(fk))

foreign_key._meta.call_chain = call_chain

return foreign_key

def all_related(
self, exclude: t.Optional[t.List[t.Union[ForeignKey, str]]] = None
) -> t.List[ForeignKey]:
Expand Down
21 changes: 20 additions & 1 deletion tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
from piccolo.engine.finder import engine_finder
from piccolo.engine.postgres import PostgresEngine
from piccolo.engine.sqlite import SQLiteEngine
from piccolo.table import Table, create_table_class
from piccolo.table import (
Table,
create_db_tables_sync,
create_table_class,
drop_db_tables_sync,
)
from piccolo.utils.sync import run_sync

ENGINE = engine_finder()
Expand Down Expand Up @@ -454,3 +459,17 @@ def setUp(self):

def tearDown(self):
self.drop_tables()


class TableTest(TestCase):
"""
Used for tests where we need to create Piccolo tables.
"""

tables: t.List[t.Type[Table]]

def setUp(self) -> None:
create_db_tables_sync(*self.tables)

def tearDown(self) -> None:
drop_db_tables_sync(*self.tables)
56 changes: 56 additions & 0 deletions tests/columns/foreign_key/test_reverse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from piccolo.columns import ForeignKey, Text, Varchar
from piccolo.table import Table
from tests.base import TableTest


class Band(Table):
name = Varchar()


class FanClub(Table):
address = Text()
band = ForeignKey(Band, unique=True)


class Treasurer(Table):
name = Varchar()
fan_club = ForeignKey(FanClub, unique=True)


class TestReverse(TableTest):
tables = [Band, FanClub, Treasurer]

def setUp(self):
super().setUp()

band = Band({Band.name: "Pythonistas"})
band.save().run_sync()

fan_club = FanClub(
{FanClub.band: band, FanClub.address: "1 Flying Circus, UK"}
)
fan_club.save().run_sync()

treasurer = Treasurer(
{Treasurer.fan_club: fan_club, Treasurer.name: "Bob"}
)
treasurer.save().run_sync()

def test_reverse(self):
response = Band.select(
Band.name,
FanClub.band.reverse().address.as_alias("address"),
Treasurer.fan_club._.band.reverse().name.as_alias(
"treasurer_name"
),
).run_sync()
self.assertListEqual(
response,
[
{
"name": "Pythonistas",
"address": "1 Flying Circus, UK",
"treasurer_name": "Bob",
}
],
)
17 changes: 2 additions & 15 deletions tests/query/functions/base.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,8 @@
import typing as t
from unittest import TestCase

from piccolo.table import Table, create_db_tables_sync, drop_db_tables_sync
from tests.base import TableTest
from tests.example_apps.music.tables import Band, Manager


class FunctionTest(TestCase):
tables: t.List[t.Type[Table]]

def setUp(self) -> None:
create_db_tables_sync(*self.tables)

def tearDown(self) -> None:
drop_db_tables_sync(*self.tables)


class BandTest(FunctionTest):
class BandTest(TableTest):
tables = [Band, Manager]

def setUp(self) -> None:
Expand Down
6 changes: 2 additions & 4 deletions tests/query/functions/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,14 @@
Year,
)
from piccolo.table import Table
from tests.base import engines_only, sqlite_only

from .base import FunctionTest
from tests.base import TableTest, engines_only, sqlite_only


class Concert(Table):
starts = Timestamp()


class DatetimeTest(FunctionTest):
class DatetimeTest(TableTest):
tables = [Concert]

def setUp(self) -> None:
Expand Down
5 changes: 2 additions & 3 deletions tests/query/functions/test_math.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
from piccolo.columns import Numeric
from piccolo.query.functions.math import Abs, Ceil, Floor, Round
from piccolo.table import Table

from .base import FunctionTest
from tests.base import TableTest


class Ticket(Table):
price = Numeric(digits=(5, 2))


class TestMath(FunctionTest):
class TestMath(TableTest):

tables = [Ticket]

Expand Down

0 comments on commit 4be8aec

Please sign in to comment.