Skip to content

Commit

Permalink
Merge branch 'query-multiple-field' into release/0.18.0
Browse files Browse the repository at this point in the history
  • Loading branch information
RKrahl committed Mar 29, 2021
2 parents 6e7094f + 4703da1 commit f56accf
Show file tree
Hide file tree
Showing 10 changed files with 326 additions and 54 deletions.
20 changes: 20 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ Changelog
0.18.0 (not yet released)
~~~~~~~~~~~~~~~~~~~~~~~~~

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

+ `#76`_, `#78`_: add client side support for searching for multiple
fields introduced in icat.server 4.11.0. Add support for building
the corresponding queries in the in class :class:`icat.query.Query`.

Incompatible changes and deprecations
-------------------------------------

+ Since :class:`icat.query.Query` now also accepts a list of attribute
names rather then only a single one, the corresponding keyword
argument `attribute` has been renamed to `attributes` (in the
plural). Accordingly, the method
:meth:`icat.query.Query.setAttribute` has been renamed to
:meth:`icat.query.Query.setAttributes`. The old names are retained
as aliases, but are deprecated.

Bug fixes and minor changes
---------------------------

Expand All @@ -14,6 +32,8 @@ Bug fixes and minor changes
+ `#80`_: add :exc:`TypeError` as additional ancestor of
:exc:`icat.exception.EntityTypeError`.

.. _#76: https://github.com/icatproject/python-icat/pull/76
.. _#78: https://github.com/icatproject/python-icat/issues/78
.. _#79: https://github.com/icatproject/python-icat/pull/79
.. _#80: https://github.com/icatproject/python-icat/pull/80

Expand Down
15 changes: 13 additions & 2 deletions doc/src/tutorial-search.rst
Original file line number Diff line number Diff line change
Expand Up @@ -367,12 +367,23 @@ Instead of returning a list of the matching objects, we may also
request single attributes. The result will be a list of the attribute
values of the matching objects. Listing the names of all datasets::

>>> query = Query(client, "Dataset", attribute="name")
>>> query = Query(client, "Dataset", attributes="name")
>>> print(query)
SELECT o.name FROM Dataset o
>>> client.search(query)
[e201215, e201216, e208339, e208341, e208342, e208945, e208946, e208947]

As the name of that keyword argument suggests, we may also search for
multiple attributes at once. The result will be a list of attribute
values rather then a single value for each object found in the query.
This requires an ICAT server version 4.11 or newer though::

>>> query = Query(client, "Dataset", attributes=["investigation.name", "name", "complete", "type.name"])
>>> print(query)
SELECT i.name, o.name, o.complete, t.name FROM Dataset o JOIN o.investigation AS i JOIN o.type AS t
>>> client.search(query)
[[08100122-EF, e201215, False, raw], [08100122-EF, e201216, False, raw], [10100601-ST, e208339, False, raw], [10100601-ST, e208341, False, raw], [10100601-ST, e208342, False, raw], [12100409-ST, e208945, False, raw], [12100409-ST, e208946, False, raw], [12100409-ST, e208947, True, analyzed]]
There are also some aggregate functions that may be applied to search
results. Let's count all datasets::

Expand All @@ -395,7 +406,7 @@ average magnetic field applied in the measurements::
... "type.name": "= 'Magnetic field'",
... "type.units": "= 'T'",
... }
>>> query = Query(client, "DatasetParameter", conditions=conditions, attribute="numericValue")
>>> query = Query(client, "DatasetParameter", conditions=conditions, attributes="numericValue")
>>> print(query)
SELECT o.numericValue FROM DatasetParameter o JOIN o.dataset AS ds JOIN ds.investigation AS i JOIN o.type AS t WHERE i.name = '10100601-ST' AND t.name = 'Magnetic field' AND t.units = 'T'
>>> client.search(query)
Expand Down
22 changes: 16 additions & 6 deletions icat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
from icat.entities import getTypeMap
from icat.query import Query
from icat.exception import *
from icat.helper import (simpleqp_unquote, parse_attr_val,
ms_timestamp, disable_logger)
from icat.ids import *
from icat.sslcontext import create_ssl_context, HTTPSTransport
from icat.helper import simpleqp_unquote, parse_attr_val, ms_timestamp

__all__ = ['Client']

Expand Down Expand Up @@ -208,6 +209,12 @@ def clone(self):
return Class(self.url, **self.kwargs)


def _has_wsdl_type(self, name):
"""Check if this client's WSDL defines a particular type name.
"""
with disable_logger("suds.resolver"):
return self.factory.resolver.find(name)

def new(self, obj, **kwargs):

"""Instantiate a new :class:`icat.entity.Entity` object.
Expand Down Expand Up @@ -280,16 +287,19 @@ def getEntityClass(self, name):
def getEntity(self, obj):
"""Get the corresponding :class:`icat.entity.Entity` for an object.
If obj is a Suds instance object, create a new object with
:meth:`~icat.client.Client.new`. Otherwise do nothing and
return obj unchanged.
if obj is a `fieldSet`, return the list of fields. If obj is
any other Suds instance object, create a new entity object
with :meth:`~icat.client.Client.new`. Otherwise do nothing
and return obj unchanged.
:param obj: either a Suds instance object or anything.
:type obj: :class:`suds.sudsobject.Object` or any type
:return: the new entity object or obj.
:rtype: :class:`icat.entity.Entity` or any type
:rtype: :class:`list` or :class:`icat.entity.Entity` or any type
"""
if isinstance(obj, suds.sudsobject.Object):
if obj.__class__.__name__ == 'fieldSet':
return obj.fields
elif isinstance(obj, suds.sudsobject.Object):
return self.new(obj)
else:
return obj
Expand Down
13 changes: 13 additions & 0 deletions icat/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@
"""

import sys
from contextlib import contextmanager
import datetime
import logging
import suds.sax.date


Expand Down Expand Up @@ -219,3 +221,14 @@ def ms_timestamp(dt):
dt = dt.replace(tzinfo=None) - offs
ts = 1000 * (dt - datetime.datetime(1970, 1, 1)).total_seconds()
return int(ts)


@contextmanager
def disable_logger(name):
"""Context manager to temporarily disable a logger.
"""
logger = logging.getLogger(name)
sav_state = logger.disabled
logger.disabled = True
yield
logger.disabled = sav_state
119 changes: 84 additions & 35 deletions icat/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ class Query(object):
:param entity: the type of objects to search for. This may either
be an :class:`icat.entity.Entity` subclass or the name of an
entity type.
:param attribute: the attribute that the query shall return. See
the :meth:`~icat.query.Query.setAttribute` method for details.
:param attributes: the attributes that the query shall return. See
the :meth:`~icat.query.Query.setAttributes` method for details.
:param aggregate: the aggregate function to be applied in the
SELECT clause, if any. See the
:meth:`~icat.query.Query.setAggregate` method for details.
Expand All @@ -75,11 +75,22 @@ 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 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 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).
"""

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

Expand All @@ -99,7 +110,14 @@ def __init__(self, client, entity,
else:
raise EntityTypeError("Invalid entity type '%s'." % type(entity))

self.setAttribute(attribute)
if attribute is not None:
if attributes:
raise TypeError("cannot use both, attribute and attributes")
warn("The attribute keyword argument is deprecated and will be "
"removed in python-icat 1.0.", DeprecationWarning, 2)
attributes = attribute

self.setAttributes(attributes)
self.setAggregate(aggregate)
self.conditions = dict()
self.addConditions(conditions)
Expand Down Expand Up @@ -172,21 +190,39 @@ def _dosubst(self, obj, subst, addas=True):
n += " AS %s" % (subst[obj])
return n

def setAttribute(self, attribute):
"""Set the attribute that the query shall return.
def setAttributes(self, attributes):
"""Set the attributes that the query shall return.
:param attribute: the name of the attribute. The result of
the query will be a list of attribute values for the
matching entity objects. If attribute is :const:`None`,
:param attributes: the names of the attributes. This can
either be a single name or a list of names. The result of
the search will be a list with either a single attribute
value or a list of attribute values respectively for each
matching entity object. If attributes is :const:`None`,
the result will be the list of matching objects instead.
:type attribute: :class:`str`
:raise ValueError: if `attribute` is not valid.
:type attributes: :class:`str` or :class:`list` of :class:`str`
:raise ValueError: if any name in `attributes` is not valid or
if multiple attributes are provided, but the ICAT server
does not support this.
.. versionchanged:: 0.18.0
also accept a list of attribute names. Renamed from
:meth:`setAttribute` to :meth:`setAttributes` (in the
plural).
"""
if attribute is not None:
# Get the attribute path only to verify that the attribute is valid.
for (pattr, attrInfo, rclass) in self._attrpath(attribute):
pass
self.attribute = attribute
self.attributes = []
if attributes:
if isinstance(attributes, str):
attributes = [ attributes ]
if (len(attributes) > 1 and
not self.client._has_wsdl_type('fieldSet')):
raise ValueError("This ICAT server does not support queries "
"searching for multiple attributes")
for attr in attributes:
# Get the attribute path only to verify that the
# attribute is valid.
for (pattr, attrInfo, rclass) in self._attrpath(attr):
pass
self.attributes.append(attr)

def setAggregate(self, function):
"""Set the aggregate function to be applied to the result.
Expand All @@ -206,7 +242,7 @@ def setAggregate(self, function):
":DISTINCT", may be appended to "COUNT", "AVG", and "SUM"
to combine the respective function with "DISTINCT".
:type function: :class:`str`
:raise ValueError: if `function` is not a valid.
:raise ValueError: if `function` is not valid.
"""
if function:
if function not in aggregate_fcts:
Expand Down Expand Up @@ -342,12 +378,12 @@ def setLimit(self, limit):
def __repr__(self):
"""Return a formal representation of the query.
"""
return ("%s(%s, %s, attribute=%s, aggregate=%s, order=%s, "
return ("%s(%s, %s, attributes=%s, aggregate=%s, order=%s, "
"conditions=%s, includes=%s, limit=%s)"
% (self.__class__.__name__,
repr(self.client), repr(self.entity.BeanName),
repr(self.attribute), repr(self.aggregate),
repr(self.order), repr(self.conditions),
% (self.__class__.__name__,
repr(self.client), repr(self.entity.BeanName),
repr(self.attributes), repr(self.aggregate),
repr(self.order), repr(self.conditions),
repr(self.includes), repr(self.limit)))

def __str__(self):
Expand All @@ -362,17 +398,20 @@ def __str__(self):
usefulness over formal correctness. For Python 3, there is no
distinction between Unicode and string objects anyway.
"""
joinattrs = { a for a, d in self.order } | set(self.conditions.keys())
if self.attribute:
joinattrs.add(self.attribute)
joinattrs = ( { a for a, d in self.order } |
set(self.conditions.keys()) |
set(self.attributes) )
subst = self._makesubst(joinattrs)
if self.attribute:
if self.client.apiversion >= "4.7.0":
res = self._dosubst(self.attribute, subst, False)
else:
# Old versions of icat.server do not accept
# substitution in the SELECT clause.
res = "o.%s" % self.attribute
if self.attributes:
attrs = []
for a in self.attributes:
if self.client.apiversion >= "4.7.0":
attrs.append(self._dosubst(a, subst, False))
else:
# Old versions of icat.server do not accept
# substitution in the SELECT clause.
attrs.append("o.%s" % a)
res = ", ".join(attrs)
else:
res = "o"
if self.aggregate:
Expand Down Expand Up @@ -424,10 +463,20 @@ def copy(self):
"""Return an independent clone of this query.
"""
q = Query(self.client, self.entity)
q.attribute = self.attribute
q.attributes = list(self.attributes)
q.aggregate = self.aggregate
q.order = list(self.order)
q.conditions = self.conditions.copy()
q.includes = self.includes.copy()
q.limit = self.limit
return q

def setAttribute(self, attribute):
"""Alias for :meth:`setAttributes`.
.. deprecated:: 0.18.0
use :meth:`setAttributes` instead.
"""
warn("setAttribute() is deprecated "
"and will be removed in python-icat 1.0.", DeprecationWarning, 2)
self.setAttributes(attribute)
2 changes: 1 addition & 1 deletion icatdump.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
dumpfile.writedata(getAuthQueries(client))
dumpfile.writedata(getStaticQueries(client))
# Dump the investigations each in their own chunk
investsearch = Query(client, "Investigation", attribute="id",
investsearch = Query(client, "Investigation", attributes="id",
order=["facility.name", "name", "visitId"])
for i in client.searchChunked(investsearch):
# We fetch Dataset including DatasetParameter. This may lead
Expand Down
2 changes: 1 addition & 1 deletion tests/test_05_dumpfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def icatdump(client, f, backend):
with open_dumpfile(client, f, backend, 'w') as dumpfile:
dumpfile.writedata(getAuthQueries(client))
dumpfile.writedata(getStaticQueries(client))
investsearch = Query(client, "Investigation", attribute="id",
investsearch = Query(client, "Investigation", attributes="id",
order=["facility.name", "name", "visitId"])
for i in client.searchChunked(investsearch):
dumpfile.writedata(getInvestigationQueries(client, i), chunksize=5)
Expand Down
Loading

0 comments on commit f56accf

Please sign in to comment.