Skip to content
14 changes: 12 additions & 2 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,17 @@ Changelog
=========


0.18.2 (not yet released)
~~~~~~~~~~~~~~~~~~~~~~~~~
0.19.0 (2021-07-20)
~~~~~~~~~~~~~~~~~~~

New features
------------

+ `#85`_: add an argument `join_specs` to the constructor of class
:class:`icat.query.Query` and a corresponding method
:meth:`icat.query.Query.setJoinSpecs` to override the join
specification to be used in the created query for selected related
objects.

Bug fixes and minor changes
---------------------------
Expand All @@ -18,6 +27,7 @@ Bug fixes and minor changes

.. _#83: https://github.com/icatproject/python-icat/issues/83
.. _#84: https://github.com/icatproject/python-icat/pull/84
.. _#85: https://github.com/icatproject/python-icat/pull/85


0.18.1 (2021-04-13)
Expand Down
6 changes: 2 additions & 4 deletions icat/dump_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,9 @@ def getAuthQueries(client):
return [ Query(client, "User", order=True),
Query(client, "Grouping", order=True,
includes={"userGroups", "userGroups.user"}),
Query(client, "Rule", order=["what", "id"],
conditions={"grouping": "IS NULL"}),
Query(client, "Rule", order=["grouping.name", "what", "id"],
conditions={"grouping": "IS NOT NULL"},
includes={"grouping"}),
includes={"grouping"},
join_specs={"grouping": "LEFT JOIN"}),
Query(client, "PublicStep", order=True) ]

def getStaticQueries(client):
Expand Down
6 changes: 3 additions & 3 deletions icat/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,14 @@ class ConfigError(_BaseException):
class QueryWarning(Warning):
"""Warning while building a query.

.. versionadded:: 0.18.2
.. versionadded:: 0.19.0
"""
pass

class QueryNullableOrderWarning(QueryWarning):
"""Warn about using a nullable many to one relation for ordering.

.. versionchanged:: 0.18.2
.. versionchanged:: 0.19.0
Inherit from :exc:`QueryWarning`.
"""
def __init__(self, attr):
Expand All @@ -326,7 +326,7 @@ def __init__(self, attr):
class QueryOneToManyOrderWarning(QueryWarning):
"""Warn about using a one to many relation for ordering.

.. versionadded:: 0.18.2
.. versionadded:: 0.19.0
"""
def __init__(self, attr):
msg = ("ordering on a one to many relation %s may surprisingly "
Expand Down
83 changes: 72 additions & 11 deletions icat/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
"""

from warnings import warn
try:
# Python 3.3 and newer
from collections.abc import Mapping
except ImportError:
# Python 2
from collections import Mapping
import icat.entity
from icat.exception import *

Expand Down Expand Up @@ -45,6 +51,16 @@
:meth:`icat.query.Query.setAggregate` method.
"""

jpql_join_specs = frozenset([
"JOIN",
"INNER JOIN",
"LEFT JOIN",
"LEFT OUTER JOIN",
])
"""Allowed values for the `join_specs` argument to the
:meth:`icat.query.Query.setJoinSpecs` method.
"""

# ========================== class Query =============================

class Query(object):
Expand Down Expand Up @@ -75,22 +91,30 @@ class Query(object):
:param limit: a tuple (skip, count) to be used in the LIMIT
clause. See the :meth:`~icat.query.Query.setLimit` method for
details.
:param join_specs: a mapping to override the join specification
for selected related objects. See the
:meth:`~icat.query.Query.setJoinSpecs` method for details.
:param attribute: alias for `attributes`, retained for
compatibility. Deprecated, use `attributes` instead.
:raise TypeError: if `entity` is not a valid entity type or if
both `attributes` and `attribute` are provided.
:raise TypeError: if `entity` is not a valid entity type, if both
`attributes` and `attribute` are provided, or if any of the
keyword arguments have an invalid type, see the corresponding
method for details.
:raise ValueError: if any of the keyword arguments is not valid,
see the corresponding method for details.

.. versionchanged:: 0.18.0
add support for queries requesting a list of attributes rather
then a single one. Consequently, the keyword argument
`attribute` has been renamed to `attributes` (in the plural).
.. versionchanged:: 0.19.0
add the `join_specs` argument.
"""

def __init__(self, client, entity,
attributes=None, aggregate=None, order=None,
conditions=None, includes=None, limit=None, attribute=None):
conditions=None, includes=None, limit=None,
join_specs=None, attribute=None):
"""Initialize the query.
"""

Expand Down Expand Up @@ -123,6 +147,7 @@ def __init__(self, client, entity,
self.addConditions(conditions)
self.includes = set()
self.addIncludes(includes)
self.setJoinSpecs(join_specs)
self.setOrder(order)
self.setLimit(limit)
self._init = None
Expand Down Expand Up @@ -251,6 +276,39 @@ def setAggregate(self, function):
else:
self.aggregate = None

def setJoinSpecs(self, join_specs):
"""Override the join specifications.

:param join_specs: a mapping of related object names to join
specifications. Allowed values are "JOIN", "INNER JOIN",
"LEFT JOIN", and "LEFT OUTER JOIN". Any entry in this
mapping overrides how this particular related object is to
be joined. The default for any relation not included in
the mapping is "JOIN". A special value of :const:`None`
for `join_specs` is equivalent to the empty mapping.
:type join_specs: :class:`dict`
:raise TypeError: if `join_specs` is not a mapping.
:raise ValueError: if any key in `join_specs` is not a name of
a related object or if any value is not in the allowed
set.

.. versionadded:: 0.19.0
"""
if join_specs:
if not isinstance(join_specs, Mapping):
raise TypeError("join_specs must be a mapping")
for obj, js in join_specs.items():
for (pattr, attrInfo, rclass) in self._attrpath(obj):
pass
if rclass is None:
raise ValueError("%s.%s is not a related object"
% (self.entity.BeanName, obj))
if js not in jpql_join_specs:
raise ValueError("invalid join specification %s" % js)
self.join_specs = join_specs
else:
self.join_specs = dict()

def setOrder(self, order):
"""Set the order to build the ORDER BY clause from.

Expand All @@ -264,7 +322,7 @@ def setOrder(self, order):
:type order: iterable or :class:`bool`
:raise ValueError: if any attribute in `order` is not valid.

.. versionchanged:: 0.18.2
.. versionchanged:: 0.19.0
allow one to many relationships in `order`. Emit a
:exc:`~icat.exception.QueryOneToManyOrderWarning` rather
then raising a :exc:`ValueError` in this case.
Expand All @@ -289,15 +347,17 @@ def setOrder(self, order):

for (pattr, attrInfo, rclass) in self._attrpath(obj):
if attrInfo.relType == "ONE":
if (not attrInfo.notNullable and
pattr not in self.conditions):
if (not attrInfo.notNullable and
pattr not in self.conditions and
pattr not in self.join_specs):
sl = 3 if self._init else 2
warn(QueryNullableOrderWarning(pattr),
warn(QueryNullableOrderWarning(pattr),
stacklevel=sl)
elif attrInfo.relType == "MANY":
sl = 3 if self._init else 2
warn(QueryOneToManyOrderWarning(pattr),
stacklevel=sl)
if (pattr not in self.join_specs):
sl = 3 if self._init else 2
warn(QueryOneToManyOrderWarning(pattr),
stacklevel=sl)

if rclass is None:
# obj is an attribute, use it right away.
Expand Down Expand Up @@ -428,7 +488,8 @@ def __str__(self):
base = "SELECT %s FROM %s o" % (res, self.entity.BeanName)
joins = ""
for obj in sorted(subst.keys()):
joins += " JOIN %s" % self._dosubst(obj, subst)
js = self.join_specs.get(obj, "JOIN")
joins += " %s %s" % (js, self._dosubst(obj, subst))
if self.conditions:
conds = []
for a in sorted(self.conditions.keys()):
Expand Down
Loading