Repository: DOV-Vlaanderen/pydov

Issue:
Combine WFS attribute and location on search

Discussion:
pjhaest: @Roel looks promising. You did not have any troubles with the filter attribute of owslib? This took ages about a year ago.

Patch:
diff --git a/examples/boring_search.py b/examples/boring_search.py
index 09349e9..86053d8 100644
--- a/examples/boring_search.py
+++ b/examples/boring_search.py
@@ -104,6 +104,22 @@ def get_boreholes_in_bounding_box():
     print(df)
 
 
+def get_deep_boreholes_in_bounding_box():
+    """Get all details of the boreholes with a depth of at least 2000m
+    within the given bounding box."""
+    from pydov.search import BoringSearch
+    from owslib.fes import PropertyIsGreaterThanOrEqualTo
+
+    b = BoringSearch()
+    query = PropertyIsGreaterThanOrEqualTo(
+        propertyname='diepte_boring_tot', literal='2000')
+    df = b.search(
+        location=(200000, 211000, 205000, 214000),
+        query=query
+    )
+    print(df)
+
+
 if __name__ == '__main__':
     # Comment out to skip these examples:
     get_description()
@@ -116,3 +132,4 @@ if __name__ == '__main__':
     # get_deep_boreholes()
     # get_groundwater_related_boreholes_in_antwerp()
     # get_boreholes_in_bounding_box()
+    # get_deep_boreholes_in_bounding_box()
diff --git a/pydov/search.py b/pydov/search.py
index 9c2e2e4..4283c81 100644
--- a/pydov/search.py
+++ b/pydov/search.py
@@ -1,13 +1,13 @@
 # -*- coding: utf-8 -*-
 """Module containing the search classes to retrieve DOV data."""
-import owslib
 import pandas as pd
+
+import owslib
 from owslib.etree import etree
 from owslib.fes import (
     FilterRequest,
 )
 from owslib.wfs import WebFeatureService
-
 from pydov.types.boring import Boring
 from pydov.util import owsutil
 from pydov.util.errors import (
@@ -41,6 +41,7 @@ class AbstractSearch(object):
 
         self._fields = None
         self._wfs_fields = None
+        self._geometry_column = None
 
         self._map_wfs_source_df = {}
         self._map_df_wfs_source = {}
@@ -197,6 +198,7 @@ class AbstractSearch(object):
         """
         fields = {}
         self._wfs_fields = []
+        self._geometry_column = wfs_schema.get('geometry_column', None)
 
         _map_wfs_datatypes = {
             'int': 'integer',
@@ -251,7 +253,7 @@ class AbstractSearch(object):
 
         Parameters
         ----------
-        location : tuple<minx,maxx,miny,maxy>
+        location : tuple<minx,miny,maxx,maxy>
             The bounding box limiting the features to retrieve.
         query : owslib.fes.OgcExpression
             OGC filter expression to use for searching. This can contain any
@@ -268,8 +270,6 @@ class AbstractSearch(object):
         pydov.util.errors.InvalidSearchParameterError
             When not one of `location` or `query` is provided.
 
-            When both `location` and `query` are provided.
-
         pydov.util.errors.InvalidFieldError
             When at least one of the fields in `return_fields` is unknown.
 
@@ -285,11 +285,6 @@ class AbstractSearch(object):
                 'Provide either the location or the query parameter.'
             )
 
-        if location is not None and query is not None:
-            raise InvalidSearchParameterError(
-                'Provide either the location or the query parameter, not both.'
-            )
-
         if query is not None:
             if not isinstance(query, owslib.fes.OgcExpression):
                 raise InvalidSearchParameterError(
@@ -311,6 +306,9 @@ class AbstractSearch(object):
                     raise InvalidFieldError(
                         "Unknown query parameter: '%s'" % name)
 
+        if location is not None:
+            self._init_fields()
+
         if return_fields is not None:
             if type(return_fields) not in (list, tuple, set):
                 raise AttributeError('return_fields should be a list, '
@@ -332,19 +330,23 @@ class AbstractSearch(object):
                         "Field cannot be used as a return field: '%s'" % rf)
 
     @staticmethod
-    def _get_remote_wfs_feature(wfs, typename, bbox, filter, propertyname):
-        """Perform the OWSLib call to get features from the remote service.
+    def _get_remote_wfs_feature(wfs, typename, bbox, filter, propertyname,
+                                geometry_column):
+        """Perform the WFS GetFeature call to get features from the remote
+        service.
 
         Parameters
         ----------
         typename : str
             Layername to query.
-        bbox : tuple<minx,maxx,miny,maxy>
+        bbox : tuple<minx,miny,maxx,maxy>
             The bounding box limiting the features to retrieve.
         filter : owslib.fes.FilterRequest
             Filter request to search on attribute values.
         propertyname : list<str>
             List of properties to return.
+        geometry_column : str
+            Name of the geometry column to use in the spatial filter.
 
         Returns
         -------
@@ -352,18 +354,26 @@ class AbstractSearch(object):
             Response of the WFS service.
 
         """
-        return wfs.getfeature(
+        wfs_getfeature_xml = owsutil.wfs_build_getfeature_request(
+            version=wfs.version,
+            geometry_column=geometry_column,
             typename=typename,
             bbox=bbox,
             filter=filter,
-            propertyname=propertyname).read().encode('utf-8')
+            propertyname=propertyname
+        )
+
+        return owsutil.wfs_get_feature(
+            baseurl=wfs.url,
+            get_feature_request=wfs_getfeature_xml
+        )
 
     def _search(self, location=None, query=None, return_fields=None):
         """Perform the WFS search by issuing a GetFeature request.
 
         Parameters
         ----------
-        location : tuple<minx,maxx,miny,maxy>
+        location : tuple<minx,miny,maxx,maxy>
             The bounding box limiting the features to retrieve.
         query : owslib.fes.OgcExpression
             OGC filter expression to use for searching. This can contain any
@@ -386,8 +396,6 @@ class AbstractSearch(object):
         pydov.util.errors.InvalidSearchParameterError
             When not one of `location` or `query` is provided.
 
-            When both `location` and `query` are provided.
-
         pydov.util.errors.InvalidFieldError
             When at least one of the fields in `return_fields` is unknown.
 
@@ -436,11 +444,13 @@ class AbstractSearch(object):
                                        if i in return_fields])
             wfs_property_names = list(set(wfs_property_names))
 
-        fts = self._get_remote_wfs_feature(wfs=self.__wfs,
-                                           typename=self._layer,
-                                           bbox=location,
-                                           filter=filter_request,
-                                           propertyname=wfs_property_names)
+        fts = self._get_remote_wfs_feature(
+            wfs=self.__wfs,
+            typename=self._layer,
+            bbox=location,
+            filter=filter_request,
+            propertyname=wfs_property_names,
+            geometry_column=self._geometry_column)
 
         tree = etree.fromstring(fts)
 
@@ -542,7 +552,7 @@ class BoringSearch(AbstractSearch):
 
         Parameters
         ----------
-        location : tuple<minx,maxx,miny,maxy>
+        location : tuple<minx,miny,maxx,maxy>
             The bounding box limiting the features to retrieve.
         query : owslib.fes.OgcExpression
             OGC filter expression to use for searching. This can contain any
@@ -564,8 +574,6 @@ class BoringSearch(AbstractSearch):
         pydov.util.errors.InvalidSearchParameterError
             When not one of `location` or `query` is provided.
 
-            When both `location` and `query` are provided.
-
         pydov.util.errors.InvalidFieldError
             When at least one of the fields in `return_fields` is unknown.
 
diff --git a/pydov/util/owsutil.py b/pydov/util/owsutil.py
index 29df850..e2df59a 100644
--- a/pydov/util/owsutil.py
+++ b/pydov/util/owsutil.py
@@ -1,10 +1,12 @@
 # -*- coding: utf-8 -*-
 """Module grouping utility functions for OWS services."""
+import requests
+
 from owslib.feature.schema import (
     _get_describefeaturetype_url,
     _get_elements,
-    _construct_schema,
     XS_NAMESPACE,
+    GML_NAMESPACES
 )
 
 try:
@@ -151,6 +153,7 @@ def get_csw_base_url(contentmetadata):
     ------
     pydov.util.errors.MetadataNotFoundError
         If the `contentmetadata` has no valid metadata URL associated with it.
+
     """
     md_url = None
     for md in contentmetadata.metadataUrls:
@@ -326,6 +329,72 @@ def get_namespace(wfs, layer):
     return namespace
 
 
+def _construct_schema(elements, nsmap):
+    """Copy the owslib.feature.schema.get_schema method to be able to get
+    the geometry column name.
+
+    Parameters
+    ----------
+    elements : list<Element>
+        List of elements
+    nsmap : dict
+        Namespace map
+
+    Returns
+    -------
+    dict
+        Schema
+
+    """
+    schema = {
+        'properties': {},
+        'geometry': None
+    }
+
+    schema_key = None
+    gml_key = None
+
+    # if nsmap is defined, use it
+    if nsmap:
+        for key in nsmap:
+            if nsmap[key] == XS_NAMESPACE:
+                schema_key = key
+            if nsmap[key] in GML_NAMESPACES:
+                gml_key = key
+    # if no nsmap is defined, we have to guess
+    else:
+        gml_key = 'gml'
+        schema_key = 'xsd'
+
+    mappings = {
+        'PointPropertyType': 'Point',
+        'PolygonPropertyType': 'Polygon',
+        'LineStringPropertyType': 'LineString',
+        'MultiPointPropertyType': 'MultiPoint',
+        'MultiLineStringPropertyType': 'MultiLineString',
+        'MultiPolygonPropertyType': 'MultiPolygon',
+        'MultiGeometryPropertyType': 'MultiGeometry',
+        'GeometryPropertyType': 'GeometryCollection',
+        'SurfacePropertyType': '3D Polygon',
+        'MultiSurfacePropertyType': '3D MultiPolygon'
+    }
+
+    for element in elements:
+        data_type = element.attrib['type'].replace(gml_key + ':', '')
+        name = element.attrib['name']
+
+        if data_type in mappings:
+            schema['geometry'] = mappings[data_type]
+            schema['geometry_column'] = name
+        else:
+            schema['properties'][name] = data_type.replace(schema_key+':', '')
+
+    if schema['properties'] or schema['geometry']:
+        return schema
+    else:
+        return None
+
+
 def get_remote_schema(url, typename, version='1.0.0'):
     """Copy the owslib.feature.schema.get_schema method to be able to
     monkeypatch the openURL request in tests.
@@ -359,3 +428,119 @@ def get_remote_schema(url, typename, version='1.0.0'):
     if hasattr(root, 'nsmap'):
         nsmap = root.nsmap
     return _construct_schema(elements, nsmap)
+
+
+def wfs_build_getfeature_request(typename, geometry_column=None, bbox=None,
+                                 filter=None, propertyname=None,
+                                 version='1.1.0'):
+    """Build a WFS GetFeature request in XML to be used as payload in a WFS
+    GetFeature request using POST.
+
+    Parameters
+    ----------
+    typename : str
+        Typename to query.
+    geometry_column : str, optional
+        Name of the geometry column to use in the spatial filter.
+        Required if the ``bbox`` parameter is supplied.
+    bbox : tuple<minx,miny,maxx,maxy>, optional
+        The bounding box limiting the features to retrieve.
+        Requires ``geometry_column`` to be supplied as well.
+    filter : owslib.fes.FilterRequest, optional
+        Filter request to search on attribute values.
+    propertyname : list<str>, optional
+        List of properties to return. Defaults to all properties.
+    version : str, optional
+        WFS version to use. Defaults to 1.1.0
+
+    Raises
+    ------
+    AttributeError
+        If ``bbox`` is given without ``geometry_column``.
+
+    Returns
+    -------
+    element : etree.Element
+        XML element representing the WFS GetFeature request.
+
+    """
+    if bbox is not None and geometry_column is None:
+        raise AttributeError('bbox requires geometry_column and it is None')
+
+    xml = etree.Element('{http://www.opengis.net/wfs}GetFeature')
+    xml.set('service', 'WFS')
+    xml.set('version', version)
+
+    xml.set('{http://www.w3.org/2001/XMLSchema-instance}schemaLocation',
+            'http://www.opengis.net/wfs '
+            'http://schemas.opengis.net/wfs/%s/wfs.xsd' % version)
+
+    query = etree.Element('{http://www.opengis.net/wfs}Query')
+    query.set('typeName', typename)
+
+    if propertyname and len(propertyname) > 0:
+        for property in propertyname:
+            propertyname_xml = etree.Element(
+                '{http://www.opengis.net/wfs}PropertyName')
+            propertyname_xml.text = property
+            query.append(propertyname_xml)
+
+    filter_xml = etree.Element('{http://www.opengis.net/ogc}Filter')
+    filter_parent = filter_xml
+
+    if filter is not None and bbox is not None:
+        # if both filter and bbox are specified, we wrap them inside an
+        # ogc:And
+        and_xml = etree.Element('{http://www.opengis.net/ogc}And')
+        filter_xml.append(and_xml)
+        filter_parent = and_xml
+
+    if filter is not None:
+        filterrequest = etree.fromstring(filter)
+        filter_parent.append(filterrequest[0])
+
+    if bbox is not None:
+        within = etree.Element('{http://www.opengis.net/ogc}Within')
+        geom = etree.Element('{http://www.opengis.net/ogc}PropertyName')
+        geom.text = geometry_column
+        within.append(geom)
+
+        envelope = etree.Element('{http://www.opengis.net/gml}Envelope')
+        envelope.set('srsDimension', '2')
+        envelope.set('srsName',
+                     'http://www.opengis.net/gml/srs/epsg.xml#31370')
+
+        lower_corner = etree.Element('{http://www.opengis.net/gml}lowerCorner')
+        lower_corner.text = '%0.3f %0.3f' % (bbox[0], bbox[1])
+        envelope.append(lower_corner)
+
+        upper_corner = etree.Element('{http://www.opengis.net/gml}upperCorner')
+        upper_corner.text = '%0.3f %0.3f' % (bbox[2], bbox[3])
+        envelope.append(upper_corner)
+        within.append(envelope)
+        filter_parent.append(within)
+
+    query.append(filter_xml)
+    xml.append(query)
+    return xml
+
+
+def wfs_get_feature(baseurl, get_feature_request):
+    """Perform a WFS request using POST.
+
+    Parameters
+    ----------
+    baseurl : str
+        Base URL of the WFS service.
+    get_feature_request : etree.Element
+        XML element representing the WFS GetFeature request.
+
+    Returns
+    -------
+    bytes
+        Response of the WFS service.
+
+    """
+    data = etree.tostring(get_feature_request)
+    request = requests.post(baseurl, data)
+    return request.text.encode('utf8')
diff --git a/requirements.txt b/requirements.txt
index 41672b2..6e0b593 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,4 @@ owslib
 xmltodict
 pandas
 numpy
+requests


Test patch:
diff --git a/tests/test_search.py b/tests/test_search.py
index dfd22fd..0f9d6ce 100644
--- a/tests/test_search.py
+++ b/tests/test_search.py
@@ -44,11 +44,11 @@ def mp_remote_wfs_feature(monkeypatch):
 
     if sys.version_info[0] < 3:
         monkeypatch.setattr(
-            'pydov.search.AbstractSearch._get_remote_wfs_feature',
+            'pydov.util.owsutil.wfs_get_feature',
             __get_remote_wfs_feature)
     else:
         monkeypatch.setattr(
-            'pydov.search.AbstractSearch._get_remote_wfs_feature',
+            'pydov.util.owsutil.wfs_get_feature',
             __get_remote_wfs_feature)
 
 
@@ -175,23 +175,32 @@ class TestBoringSearch(object):
         with pytest.raises(InvalidSearchParameterError):
             boringsearch.search(location=None, query=None)
 
-    def test_search_both_location_query(self, boringsearch):
+    def test_search_both_location_query(self, mp_remote_describefeaturetype,
+                                        mp_remote_wfs_feature, boringsearch):
         """Test the search method providing both a location and a query.
 
-        Test whether an InvalidSearchParameterError is raised.
+        Test whether a dataframe is returned.
 
         Parameters
         ----------
+        mp_remote_describefeaturetype : pytest.fixture
+            Monkeypatch the call to a remote DescribeFeatureType of the
+            dov-pub:Boringen layer.
+        mp_remote_wfs_feature : pytest.fixture
+            Monkeypatch the call to get WFS features.
         boringsearch : pytest.fixture returning pydov.search.BoringSearch
             An instance of BoringSearch to perform search operations on the DOV
             type 'Boring'.
 
         """
-        with pytest.raises(InvalidSearchParameterError):
-            query = PropertyIsEqualTo(propertyname='gemeente',
-                                      literal='Blankenberge')
-            boringsearch.search(location=(1, 2, 3, 4),
-                                query=query)
+        query = PropertyIsEqualTo(propertyname='gemeente',
+                                  literal='Blankenberge')
+
+        df = boringsearch.search(location=(1, 2, 3, 4),
+                                 query=query,
+                                 return_fields=('pkey_boring', 'boornummer'))
+
+        assert type(df) is DataFrame
 
     def test_search_both_location_query_wrongquerytype(self, boringsearch):
         """Test the search method providing both a location and a query,
@@ -459,8 +468,9 @@ class TestBoringSearch(object):
         with pytest.raises(InvalidFieldError):
             boringsearch.search(query=query)
 
-    def test_search_xmlresolving(self, mp_remote_wfs_feature, mp_boring_xml,
-                                     boringsearch):
+    def test_search_xmlresolving(self, mp_remote_describefeaturetype,
+                                 mp_remote_wfs_feature, mp_boring_xml,
+                                 boringsearch):
         """Test the search method with return fields from XML but not from a
         subtype.
 
@@ -468,6 +478,9 @@ class TestBoringSearch(object):
 
         Parameters
         ----------
+        mp_remote_describefeaturetype : pytest.fixture
+            Monkeypatch the call to a remote DescribeFeatureType of the
+            dov-pub:Boringen layer.
         mp_remote_wfs_feature : pytest.fixture
             Monkeypatch the call to get WFS features.
         mp_boring_xml : pytest.fixture
diff --git a/tests/test_util_owsutil.py b/tests/test_util_owsutil.py
index e23802f..f0b002e 100644
--- a/tests/test_util_owsutil.py
+++ b/tests/test_util_owsutil.py
@@ -1,15 +1,19 @@
 """Module grouping tests for the pydov.util.owsutil module."""
-
+import re
 import sys
 
-import owslib
 import pytest
 from numpy.compat import unicode
+
+import owslib
 from owslib.etree import etree
+from owslib.fes import (
+    PropertyIsEqualTo,
+    FilterRequest,
+)
 from owslib.iso import MD_Metadata
 from owslib.util import nspath_eval
 from owslib.wfs import WebFeatureService
-
 from pydov.util import owsutil
 from pydov.util.errors import (
     MetadataNotFoundError,
@@ -209,6 +213,36 @@ def mp_remote_describefeaturetype(monkeypatch):
             __get_remote_describefeaturetype.__code__)
 
 
+def clean_xml(xml):
+    """Clean the given XML string of namespace definition, namespace
+    prefixes and syntactical but otherwise meaningless differences.
+
+    Parameters
+    ----------
+    xml : str
+        String representation of XML document.
+
+    Returns
+    -------
+    str
+        String representation of cleaned XML document.
+
+    """
+    # remove xmlns namespace definitions
+    r = re.sub(r'[ ]+xmlns:[^=]+="[^"]+"', '', xml)
+
+    # remove namespace prefixes in tags
+    r = re.sub(r'<(/?)[^:]+:([^ >]+)([ >])', r'<\1\2\3', r)
+
+    # remove extra spaces in tags
+    r = re.sub(r'[ ]+/>', '/>', r)
+
+    # remove extra spaces between tags
+    r = re.sub(r'>[ ]+<', '><', r)
+
+    return r
+
+
 class TestOwsutil(object):
     """Class grouping tests for the pydov.util.owsutil module."""
 
@@ -419,3 +453,198 @@ class TestOwsutil(object):
         contentmetadata.metadataUrls = []
         with pytest.raises(MetadataNotFoundError):
             owsutil.get_remote_metadata(contentmetadata)
+
+    def test_wfs_build_getfeature_request_onlytypename(self):
+        """Test the owsutil.wfs_build_getfeature_request method with only a
+        typename specified.
+
+        Test whether the XML of the WFS GetFeature call is generated correctly.
+
+        """
+        xml = owsutil.wfs_build_getfeature_request('dov-pub:Boringen')
+        assert clean_xml(etree.tostring(xml).decode('utf8')) == clean_xml(
+            '<wfs:GetFeature xmlns:wfs="http://www.opengis.net/wfs" '
+            'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
+            'service="WFS" version="1.1.0" '
+            'xsi:schemaLocation="http://www.opengis.net/wfs '
+            'http://schemas.opengis.net/wfs/1.1.0/wfs.xsd"><wfs:Query '
+            'typeName="dov-pub:Boringen"><ogc:Filter '
+            'xmlns:ogc="http://www.opengis.net/ogc"/></wfs:Query></wfs'
+            ':GetFeature>')
+
+    def test_wfs_build_getfeature_request_bbox_nogeometrycolumn(self):
+        """Test the owsutil.wfs_build_getfeature_request method with a bbox
+        argument but without the geometry_column argument.
+
+        Test whether an AttributeError is raised.
+
+        """
+        with pytest.raises(AttributeError):
+            xml = owsutil.wfs_build_getfeature_request(
+                'dov-pub:Boringen', bbox=(151650, 214675, 151750, 214775))
+
+    def test_wfs_build_getfeature_request_bbox(self):
+        """Test the owsutil.wfs_build_getfeature_request method with a
+        typename, bbox and geometry_column.
+
+        Test whether the XML of the WFS GetFeature call is generated correctly.
+
+        """
+        xml = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen', bbox=(151650, 214675, 151750, 214775),
+            geometry_column='geom')
+        assert clean_xml(etree.tostring(xml).decode('utf8')) == clean_xml(
+            '<wfs:GetFeature xmlns:wfs="http://www.opengis.net/wfs" '
+            'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
+            'service="WFS" version="1.1.0" '
+            'xsi:schemaLocation="http://www.opengis.net/wfs '
+            'http://schemas.opengis.net/wfs/1.1.0/wfs.xsd"><wfs:Query '
+            'typeName="dov-pub:Boringen"><ogc:Filter '
+            'xmlns:ogc="http://www.opengis.net/ogc"><ogc:Within> '
+            '<ogc:PropertyName>geom</ogc:PropertyName><gml:Envelope '
+            'xmlns:gml="http://www.opengis.net/gml" srsDimension="2" '
+            'srsName="http://www.opengis.net/gml/srs/epsg.xml#31370"><gml'
+            ':lowerCorner>151650.000 '
+            '214675.000</gml:lowerCorner><gml:upperCorner>151750.000 '
+            '214775.000</gml:upperCorner></gml:Envelope></ogc:Within></ogc'
+            ':Filter></wfs:Query></wfs:GetFeature>')
+
+    def test_wfs_build_getfeature_request_propertyname(self):
+        """Test the owsutil.wfs_build_getfeature_request method with a list
+        of propertynames.
+
+        Test whether the XML of the WFS GetFeature call is generated correctly.
+
+        """
+        xml = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen', propertyname=['fiche', 'diepte_tot_m'])
+        assert clean_xml(etree.tostring(xml).decode('utf8')) == clean_xml(
+            '<wfs:GetFeature xmlns:wfs="http://www.opengis.net/wfs" '
+            'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
+            'service="WFS" version="1.1.0" '
+            'xsi:schemaLocation="http://www.opengis.net/wfs '
+            'http://schemas.opengis.net/wfs/1.1.0/wfs.xsd"> <wfs:Query '
+            'typeName="dov-pub:Boringen"> '
+            '<wfs:PropertyName>fiche</wfs:PropertyName> '
+            '<wfs:PropertyName>diepte_tot_m</wfs:PropertyName> <ogc:Filter/> '
+            '</wfs:Query> </wfs:GetFeature>')
+
+    def test_wfs_build_getfeature_request_filter(self):
+        """Test the owsutil.wfs_build_getfeature_request method with an
+        attribute filter.
+
+        Test whether the XML of the WFS GetFeature call is generated correctly.
+
+        """
+        query = PropertyIsEqualTo(propertyname='gemeente',
+                                  literal='Herstappe')
+        filter_request = FilterRequest()
+        filter_request = filter_request.setConstraint(query)
+        try:
+            filter_request = etree.tostring(filter_request,
+                                            encoding='unicode')
+        except LookupError:
+            # Python2.7 without lxml uses 'utf-8' instead.
+            filter_request = etree.tostring(filter_request,
+                                            encoding='utf-8')
+
+        xml = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen', filter=filter_request)
+        assert clean_xml(etree.tostring(xml).decode('utf8')) == clean_xml(
+            '<wfs:GetFeature xmlns:wfs="http://www.opengis.net/wfs" '
+            'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
+            'service="WFS" version="1.1.0" '
+            'xsi:schemaLocation="http://www.opengis.net/wfs '
+            'http://schemas.opengis.net/wfs/1.1.0/wfs.xsd"> <wfs:Query '
+            'typeName="dov-pub:Boringen"> <ogc:Filter> '
+            '<ogc:PropertyIsEqualTo> '
+            '<ogc:PropertyName>gemeente</ogc:PropertyName> '
+            '<ogc:Literal>Herstappe</ogc:Literal> </ogc:PropertyIsEqualTo> '
+            '</ogc:Filter> </wfs:Query> </wfs:GetFeature>')
+
+    def test_wfs_build_getfeature_request_bbox_filter(self):
+        """Test the owsutil.wfs_build_getfeature_request method with an
+        attribute filter, a bbox and a geometry_column.
+
+        Test whether the XML of the WFS GetFeature call is generated correctly.
+
+        """
+        query = PropertyIsEqualTo(propertyname='gemeente',
+                                  literal='Herstappe')
+        filter_request = FilterRequest()
+        filter_request = filter_request.setConstraint(query)
+        try:
+            filter_request = etree.tostring(filter_request,
+                                            encoding='unicode')
+        except LookupError:
+            # Python2.7 without lxml uses 'utf-8' instead.
+            filter_request = etree.tostring(filter_request,
+                                            encoding='utf-8')
+
+        xml = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen', filter=filter_request,
+            bbox=(151650, 214675, 151750, 214775),
+            geometry_column='geom')
+        assert clean_xml(etree.tostring(xml).decode('utf8')) == clean_xml(
+            '<wfs:GetFeature xmlns:wfs="http://www.opengis.net/wfs" '
+            'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
+            'service="WFS" version="1.1.0" '
+            'xsi:schemaLocation="http://www.opengis.net/wfs '
+            'http://schemas.opengis.net/wfs/1.1.0/wfs.xsd"> <wfs:Query '
+            'typeName="dov-pub:Boringen"> <ogc:Filter> <ogc:And> '
+            '<ogc:PropertyIsEqualTo> '
+            '<ogc:PropertyName>gemeente</ogc:PropertyName> '
+            '<ogc:Literal>Herstappe</ogc:Literal> </ogc:PropertyIsEqualTo> '
+            '<ogc:Within> <ogc:PropertyName>geom</ogc:PropertyName> '
+            '<gml:Envelope xmlns:gml="http://www.opengis.net/gml" '
+            'srsDimension="2" '
+            'srsName="http://www.opengis.net/gml/srs/epsg.xml#31370"> '
+            '<gml:lowerCorner>151650.000 214675.000</gml:lowerCorner> '
+            '<gml:upperCorner>151750.000 214775.000</gml:upperCorner> '
+            '</gml:Envelope> </ogc:Within> </ogc:And> </ogc:Filter> '
+            '</wfs:Query> </wfs:GetFeature>')
+
+    def test_wfs_build_getfeature_request_bbox_filter_propertyname(self):
+        """Test the owsutil.wfs_build_getfeature_request method with an
+        attribute filter, a bbox, a geometry_column and a list of
+        propertynames.
+
+        Test whether the XML of the WFS GetFeature call is generated correctly.
+
+        """
+        query = PropertyIsEqualTo(propertyname='gemeente',
+                                  literal='Herstappe')
+        filter_request = FilterRequest()
+        filter_request = filter_request.setConstraint(query)
+        try:
+            filter_request = etree.tostring(filter_request,
+                                            encoding='unicode')
+        except LookupError:
+            # Python2.7 without lxml uses 'utf-8' instead.
+            filter_request = etree.tostring(filter_request,
+                                            encoding='utf-8')
+
+        xml = owsutil.wfs_build_getfeature_request(
+            'dov-pub:Boringen', filter=filter_request,
+            bbox=(151650, 214675, 151750, 214775),
+            geometry_column='geom', propertyname=['fiche', 'diepte_tot_m'])
+        assert clean_xml(etree.tostring(xml).decode('utf8')) == clean_xml(
+            '<wfs:GetFeature xmlns:wfs="http://www.opengis.net/wfs" '
+            'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
+            'service="WFS" version="1.1.0" '
+            'xsi:schemaLocation="http://www.opengis.net/wfs '
+            'http://schemas.opengis.net/wfs/1.1.0/wfs.xsd"> <wfs:Query '
+            'typeName="dov-pub:Boringen"> '
+            '<wfs:PropertyName>fiche</wfs:PropertyName> '
+            '<wfs:PropertyName>diepte_tot_m</wfs:PropertyName> <ogc:Filter> '
+            '<ogc:And> <ogc:PropertyIsEqualTo> '
+            '<ogc:PropertyName>gemeente</ogc:PropertyName> '
+            '<ogc:Literal>Herstappe</ogc:Literal> </ogc:PropertyIsEqualTo> '
+            '<ogc:Within> <ogc:PropertyName>geom</ogc:PropertyName> '
+            '<gml:Envelope xmlns:gml="http://www.opengis.net/gml" '
+            'srsDimension="2" '
+            'srsName="http://www.opengis.net/gml/srs/epsg.xml#31370"> '
+            '<gml:lowerCorner>151650.000 214675.000</gml:lowerCorner> '
+            '<gml:upperCorner>151750.000 214775.000</gml:upperCorner> '
+            '</gml:Envelope> </ogc:Within> </ogc:And> </ogc:Filter> '
+            '</wfs:Query> </wfs:GetFeature>')