Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reverse lookup #599

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
30acf3c
reverse lookup prototype
sinisaos Aug 25, 2022
5940304
docstring fix
sinisaos Aug 26, 2022
c74e89e
remove reverse_lookup_name
sinisaos Aug 28, 2022
2839931
update docstring
sinisaos Aug 28, 2022
dfc1bb0
Merge branch 'piccolo-orm:master' into reverse_lookup
sinisaos Aug 28, 2022
292186b
Merge dfc1bb048da4a453604292a9843fe69db1f9f0df into 45dbb8b3053313ea9…
sinisaos Oct 5, 2022
8f47af2
Merge branch 'master' into reverse_lookup
powellnorma Dec 16, 2022
f98e0b3
reverse lookup: some fixes
powellnorma Dec 16, 2022
9a5ba3e
reverse_lookup.py: update docstrings
powellnorma Dec 18, 2022
d6c6453
reverse lookup: remove excess 's'
powellnorma Dec 18, 2022
c4f2777
Merge pull request #1 from powellnorma/reverse_lookup
sinisaos Dec 19, 2022
5f5221b
Merge branch 'piccolo-orm:master' into reverse_lookup
sinisaos Dec 19, 2022
b3a0e27
fix tests and linter errors
sinisaos Dec 19, 2022
a7405b7
Merge branch 'master' into reverse_lookup
sinisaos Mar 8, 2023
4b58c8c
Resolve merge conflict
sinisaos Dec 9, 2024
b42daf7
update the branch code
sinisaos Dec 9, 2024
bacb08d
add docs
sinisaos Dec 10, 2024
70b183a
fix indentation in docs
sinisaos Dec 11, 2024
bda0815
add option to change default order in results
sinisaos Dec 13, 2024
382a100
Merge branch 'piccolo-orm:master' into reverse_lookup
sinisaos Jan 9, 2025
5c65821
Merge branch 'piccolo-orm:master' into reverse_lookup
sinisaos Jan 20, 2025
c748a2b
Merge branch 'piccolo-orm:master' into reverse_lookup
sinisaos Jan 26, 2025
aa1c4df
Merge branch 'piccolo-orm:master' into reverse_lookup
sinisaos Feb 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/piccolo/schema/index.rst
Original file line number Diff line number Diff line change
@@ -9,5 +9,6 @@ The schema is how you define your database tables, columns and relationships.
./defining
./column_types
./m2m
./reverse_lookup
./one_to_one
./advanced
124 changes: 124 additions & 0 deletions docs/src/piccolo/schema/reverse_lookup.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
.. currentmodule:: piccolo.columns.reverse_lookup

##############
Reverse Lookup
##############

For example, we might have our ``Manager`` table, and we want to
get all the bands associated with the same manager.
For this we can use reverse foreign key lookup.

We create it in Piccolo like this:

.. code-block:: python

from piccolo.columns.column_types import (
ForeignKey,
LazyTableReference,
Varchar
)
from piccolo.columns.reverse_lookup import ReverseLookup
from piccolo.table import Table


class Manager(Table):
name = Varchar()
bands = ReverseLookup(
LazyTableReference("Band", module_path=__name__),
reverse_fk="manager",
)


class Band(Table):
name = Varchar()
manager = ForeignKey(Manager)

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

Select queries
==============

If we want to select each manager, along with a list of associated band names,
we can do this:

.. code-block:: python

>>> await Manager.select(Manager.name, Manager.bands(Band.name, as_list=True))
[
{'name': 'John', 'bands': ['C-Sharps']},
{'name': 'Guido', 'bands': ['Pythonistas', 'Rustaceans']},
]

You can request whichever column you like from the reverse lookup:

.. code-block:: python

>>> await Manager.select(Manager.name, Manager.bands(Band.id, as_list=True))
[
{'name': 'John', 'bands': [3]},
{'name': 'Guido', 'bands': [1, 2]},
]

You can also request multiple columns from the reverse lookup:

.. code-block:: python

>>> await Manager.select(Manager.name, Manager.bands(Band.id, Band.name))
[
{
'name': 'John',
'bands': [
{'id': 3, 'name': 'C-Sharps'},
]
},
{
'name': 'Guido',
'bands': [
{'id': 1, 'name': 'Pythonistas'},
{'id': 2, 'name': 'Rustaceans'},
]
}
]

If you omit the columns argument, then all of the columns are returned.

.. code-block:: python

>>> await Manager.select(Manager.name, Manager.bands())
[
{
'name': 'John',
'bands': [
{'id': 3, 'name': 'C-Sharps'},
]
},
{
'name': 'Guido',
'bands': [
{'id': 1, 'name': 'Pythonistas'},
{'id': 2, 'name': 'Rustaceans'},
]
}
]

The default order of reverse lookup results is ascending, but if you
specify ``descending=True``, you can get the results in descending order.

.. code-block:: python

>>> await Manager.select(Manager.name, Manager.bands(descending=True))
[
{
'name': 'John',
'bands': [
{'id': 3, 'name': 'C-Sharps'},
]
},
{
'name': 'Guido',
'bands': [
{'id': 2, 'name': 'Rustaceans'},
{'id': 1, 'name': 'Pythonistas'},
]
}
]
2 changes: 1 addition & 1 deletion piccolo/columns/m2m.py
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@
from piccolo.utils.list import flatten
from piccolo.utils.sync import run_sync

if t.TYPE_CHECKING:
if t.TYPE_CHECKING: # pragma: no cover
from piccolo.table import Table


246 changes: 246 additions & 0 deletions piccolo/columns/reverse_lookup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
from __future__ import annotations

import inspect
import typing as t
from dataclasses import dataclass

from piccolo.columns.base import QueryString, Selectable
from piccolo.columns.column_types import (
JSON,
JSONB,
Column,
LazyTableReference,
)

if t.TYPE_CHECKING: # pragma: no cover
from piccolo.table import Table


class ReverseLookupSelect(Selectable):
"""
This is a subquery used within a select to fetch reverse lookup data.
"""

def __init__(
self,
*columns: Column,
reverse_lookup: ReverseLookup,
as_list: bool = False,
load_json: bool = False,
descending: bool = False,
):
"""
:param columns:
Which columns to include from the related table.
:param as_list:
If a single column is provided, and ``as_list`` is ``True`` a
flattened list will be returned, rather than a list of objects.
:param load_json:
If ``True``, any JSON strings are loaded as Python objects.
:param descending:
If ``True'', reverse lookup results sorted in descending order,
otherwise in default ascending order.

"""
self.as_list = as_list
self.columns = columns
self.reverse_lookup = reverse_lookup
self.load_json = load_json
self.descending = descending

safe_types = [int, str]

# If the columns can be serialised / deserialise as JSON, then we
# can fetch the data all in one go.
self.serialisation_safe = all(
(column.__class__.value_type in safe_types)
and (type(column) not in (JSON, JSONB))
for column in columns
)

def get_select_string(
self, engine_type: str, with_alias=True
) -> QueryString:
reverse_lookup_name = self.reverse_lookup._meta.name

table1 = self.reverse_lookup._meta.table
table1_pk = table1._meta.primary_key._meta.name
table1_name = table1._meta.tablename

table2 = self.reverse_lookup._meta.resolved_reverse_joining_table
table2_name = table2._meta.tablename
table2_pk = table2._meta.primary_key._meta.name
table2_fk = self.reverse_lookup._meta.reverse_fk

reverse_select = f"""
"{table2_name}"
WHERE "{table2_name}"."{table2_fk}"
= "{table1_name}"."{table1_pk}"
"""

if engine_type in ("postgres", "cockroach"):
if self.as_list:
column_name = self.columns[0]._meta.db_column_name
return QueryString(
f"""
ARRAY(
SELECT
"{table2_name}"."{column_name}"
FROM {reverse_select}
) AS "{reverse_lookup_name}"
"""
)
elif not self.serialisation_safe:
column_name = table2_pk
return QueryString(
f"""
ARRAY(
SELECT
"{table2_name}"."{column_name}"
FROM {reverse_select}
) AS "{reverse_lookup_name}"
"""
)
else:
if len(self.columns) > 0:
column_names = ", ".join(
f'"{table2_name}"."{column._meta.db_column_name}"' # noqa: E501
for column in self.columns
)
else:
column_names = ", ".join(
f'"{table2_name}"."{column._meta.db_column_name}"' # noqa: E501
for column in table2._meta.columns
)
return QueryString(
f"""
(
SELECT JSON_AGG("{table2_name}s")
FROM (
SELECT {column_names} FROM {reverse_select}
) AS "{table2_name}s"
) AS "{reverse_lookup_name}"
"""
)
elif engine_type == "sqlite":
if len(self.columns) > 1 or not self.serialisation_safe:
column_name = table2_pk
else:
try:
column_name = self.columns[0]._meta.db_column_name
except IndexError:
column_name = table2_pk

return QueryString(
f"""
(
SELECT group_concat(
"{table2_name}"."{column_name}"
)
FROM {reverse_select}
)
AS "{reverse_lookup_name} [M2M]"
"""
)
else:
raise ValueError(f"{engine_type} is an unrecognised engine type")


@dataclass
class ReverseLookupMeta:
reverse_joining_table: t.Union[t.Type[Table], LazyTableReference]
reverse_fk: str

# Set by the Table Metaclass:
_name: t.Optional[str] = None
_table: t.Optional[t.Type[Table]] = None

@property
def name(self) -> str:
if not self._name:
raise ValueError(
"`_name` isn't defined - the Table Metaclass should set it."
)
return self._name

@property
def table(self) -> t.Type[Table]:
if not self._table:
raise ValueError(
"`_table` isn't defined - the Table Metaclass should set it."
)
return self._table

@property
def resolved_reverse_joining_table(self) -> t.Type[Table]:
"""
Evaluates the ``reverse_joining_table`` attribute if it's a
``LazyTableReference``, raising a ``ValueError`` if it fails,
otherwise returns a ``Table`` subclass.
"""
from piccolo.table import Table

if isinstance(self.reverse_joining_table, LazyTableReference):
return self.reverse_joining_table.resolve()
elif inspect.isclass(self.reverse_joining_table) and issubclass(
self.reverse_joining_table, Table
):
return self.reverse_joining_table
else:
raise ValueError(
"The reverse_joining_table attribute is neither a Table"
" subclass or a LazyTableReference instance."
)


class ReverseLookup:
def __init__(
self,
reverse_joining_table: t.Union[t.Type[Table], LazyTableReference],
reverse_fk: str,
):
"""
:param reverse_joining_table:
A ``Table`` for reverse lookup.
:param reverse_fk:
The ForeignKey to be used for the reverse lookup.
"""
self._meta = ReverseLookupMeta(
reverse_joining_table=reverse_joining_table,
reverse_fk=reverse_fk,
)

def __call__(
self,
*columns: Column,
as_list: bool = False,
load_json: bool = False,
descending: bool = False,
) -> ReverseLookupSelect:
"""
:param columns:
Which columns to include from the related table. If none are
specified, then all of the columns are returned.
:param as_list:
If a single column is provided, and ``as_list`` is ``True`` a
flattened list will be returned, rather than a list of objects.
:param load_json:
If ``True``, any JSON strings are loaded as Python objects.
:param descending:
If ``True'', reverse lookup results sorted in descending order,
otherwise in default ascending order.

"""

if as_list and len(columns) != 1:
raise ValueError(
"`as_list` is only valid with a single column argument"
)

return ReverseLookupSelect(
*columns,
reverse_lookup=self,
as_list=as_list,
load_json=load_json,
descending=descending,
)
Loading