Add extent based filtering for SensorThings layers

Allows users to set an extent limit for the layer, so that
features are only ever loaded within this extent

The extent can be set from the data source manager before adding
the layer initially, or modified from the layer properties, source
tab.
This commit is contained in:
Nyall Dawson 2024-02-27 14:18:08 +10:00
parent 43ad16022e
commit 24edefb684
13 changed files with 496 additions and 30 deletions

View File

@ -61,6 +61,25 @@ Returns ``True`` if the specified entity ``type`` can have geometry attached.
%Docstring
Returns a filter string which restricts results to those matching the specified
``entityType`` and ``wkbType``.
%End
static QString filterForExtent( const QString &geometryField, const QgsRectangle &extent );
%Docstring
Returns a filter string which restricts results to those within the specified
``extent``.
The ``extent`` should always be specified in EPSG:4326.
.. versionadded:: 3.38
%End
static QString combineFilters( const QStringList &filters );
%Docstring
Combines a set of SensorThings API filter operators.
See https://docs.ogc.org/is/18-088/18-088.html#requirement-request-data-filter
.. versionadded:: 3.38
%End
static QList< Qgis::GeometryType > availableGeometryTypes( const QString &uri, Qgis::SensorThingsEntity type, QgsFeedback *feedback = 0, const QString &authCfg = QString() );

View File

@ -61,6 +61,25 @@ Returns ``True`` if the specified entity ``type`` can have geometry attached.
%Docstring
Returns a filter string which restricts results to those matching the specified
``entityType`` and ``wkbType``.
%End
static QString filterForExtent( const QString &geometryField, const QgsRectangle &extent );
%Docstring
Returns a filter string which restricts results to those within the specified
``extent``.
The ``extent`` should always be specified in EPSG:4326.
.. versionadded:: 3.38
%End
static QString combineFilters( const QStringList &filters );
%Docstring
Combines a set of SensorThings API filter operators.
See https://docs.ogc.org/is/18-088/18-088.html#requirement-request-data-filter
.. versionadded:: 3.38
%End
static QList< Qgis::GeometryType > availableGeometryTypes( const QString &uri, Qgis::SensorThingsEntity type, QgsFeedback *feedback = 0, const QString &authCfg = QString() );

View File

@ -346,6 +346,22 @@ QVariantMap QgsSensorThingsProviderMetadata::decodeUri( const QString &uri ) con
break;
}
const QStringList bbox = dsUri.param( QStringLiteral( "bbox" ) ).split( ',' );
if ( bbox.size() == 4 )
{
QgsRectangle r;
bool xminOk = false;
bool yminOk = false;
bool xmaxOk = false;
bool ymaxOk = false;
r.setXMinimum( bbox[0].toDouble( &xminOk ) );
r.setYMinimum( bbox[1].toDouble( &yminOk ) );
r.setXMaximum( bbox[2].toDouble( &xmaxOk ) );
r.setYMaximum( bbox[3].toDouble( &ymaxOk ) );
if ( xminOk && yminOk && xmaxOk && ymaxOk )
components.insert( QStringLiteral( "bounds" ), r );
}
return components;
}
@ -407,6 +423,12 @@ QString QgsSensorThingsProviderMetadata::encodeUri( const QVariantMap &parts ) c
dsUri.setWkbType( Qgis::WkbType::MultiPolygonZ );
}
if ( parts.contains( QStringLiteral( "bounds" ) ) && parts.value( QStringLiteral( "bounds" ) ).userType() == QMetaType::type( "QgsRectangle" ) )
{
const QgsRectangle bBox = parts.value( QStringLiteral( "bounds" ) ).value< QgsRectangle >();
dsUri.setParam( QStringLiteral( "bbox" ), QStringLiteral( "%1,%2,%3,%4" ).arg( bBox.xMinimum() ).arg( bBox.yMinimum() ).arg( bBox.xMaximum() ).arg( bBox.yMaximum() ) );
}
return dsUri.uri( false );
}

View File

@ -37,6 +37,7 @@ QgsSensorThingsSharedData::QgsSensorThingsSharedData( const QString &uri )
mGeometryField = QgsSensorThingsUtils::geometryFieldForEntityType( mEntityType );
// use initial value of maximum page size as default
mMaximumPageSize = uriParts.value( QStringLiteral( "pageSize" ), mMaximumPageSize ).toInt();
mFilterExtent = uriParts.value( QStringLiteral( "bounds" ) ).value< QgsRectangle >();
if ( QgsSensorThingsUtils::entityTypeHasGeometry( mEntityType ) )
{
@ -133,9 +134,11 @@ QUrl QgsSensorThingsSharedData::parseUrl( const QUrl &url, bool *isTestEndpoint
QgsRectangle QgsSensorThingsSharedData::extent() const
{
QgsReadWriteLocker locker( mReadWriteLock, QgsReadWriteLocker::Read );
// Since we can't retrieve the actual layer extent via SensorThings API, we use a pessimistic
// global extent until we've retrieved all the features from the layer
return hasCachedAllFeatures() ? mFetchedFeatureExtent : QgsRectangle( -180, -90, 180, 90 );
return hasCachedAllFeatures() ? mFetchedFeatureExtent
: ( !mFilterExtent.isNull() ? mFilterExtent : QgsRectangle( -180, -90, 180, 90 ) );
}
long long QgsSensorThingsSharedData::featureCount( QgsFeedback *feedback ) const
@ -150,8 +153,12 @@ long long QgsSensorThingsSharedData::featureCount( QgsFeedback *feedback ) const
// return no features, just the total count
QString countUri = QStringLiteral( "%1?$top=0&$count=true" ).arg( mEntityBaseUri );
const QString typeFilter = QgsSensorThingsUtils::filterForWkbType( mEntityType, mGeometryType );
if ( !typeFilter.isEmpty() )
countUri += QStringLiteral( "&$filter=" ) + typeFilter;
const QString extentFilter = QgsSensorThingsUtils::filterForExtent( mGeometryField, mFilterExtent );
QString filterString = QgsSensorThingsUtils::combineFilters( { typeFilter, extentFilter } );
if ( !filterString.isEmpty() )
filterString = QStringLiteral( "&$filter=" ) + filterString;
if ( !filterString.isEmpty() )
countUri += filterString;
const QUrl url = parseUrl( QUrl( countUri ) );
@ -223,8 +230,10 @@ bool QgsSensorThingsSharedData::getFeature( QgsFeatureId id, QgsFeature &f, QgsF
locker.changeMode( QgsReadWriteLocker::Write );
mNextPage = QStringLiteral( "%1?$top=%2&$count=false" ).arg( mEntityBaseUri ).arg( mMaximumPageSize );
const QString typeFilter = QgsSensorThingsUtils::filterForWkbType( mEntityType, mGeometryType );
if ( !typeFilter.isEmpty() )
mNextPage += QStringLiteral( "&$filter=" ) + typeFilter;
const QString extentFilter = QgsSensorThingsUtils::filterForExtent( mGeometryField, mFilterExtent );
const QString filterString = QgsSensorThingsUtils::combineFilters( { typeFilter, extentFilter } );
if ( !filterString.isEmpty() )
mNextPage += QStringLiteral( "&$filter=" ) + filterString;
}
locker.unlock();
@ -251,18 +260,22 @@ bool QgsSensorThingsSharedData::getFeature( QgsFeatureId id, QgsFeature &f, QgsF
QgsFeatureIds QgsSensorThingsSharedData::getFeatureIdsInExtent( const QgsRectangle &extent, QgsFeedback *feedback, const QString &thisPage, QString &nextPage, const QgsFeatureIds &alreadyFetchedIds )
{
const QgsGeometry extentGeom = QgsGeometry::fromRect( extent );
const QgsRectangle requestExtent = mFilterExtent.isNull() ? extent : extent.intersect( mFilterExtent );
const QgsGeometry extentGeom = QgsGeometry::fromRect( requestExtent );
QgsReadWriteLocker locker( mReadWriteLock, QgsReadWriteLocker::Read );
if ( hasCachedAllFeatures() || mCachedExtent.contains( extentGeom ) )
{
// all features cached locally, rely on local spatial index
return qgis::listToSet( mSpatialIndex.intersects( extent ) );
return qgis::listToSet( mSpatialIndex.intersects( requestExtent ) );
}
// TODO -- is using 'geography' always correct here?
const QString typeFilter = QgsSensorThingsUtils::filterForWkbType( mEntityType, mGeometryType );
QString queryUrl = !thisPage.isEmpty() ? thisPage : QStringLiteral( "%1?$top=%2&$count=false&$filter=geo.intersects(%3, geography'%4')%5" ).arg( mEntityBaseUri ).arg( mMaximumPageSize ).arg( mGeometryField, extent.asWktPolygon(), typeFilter.isEmpty() ? QString() : ( QStringLiteral( " and " ) + typeFilter ) );
const QString extentFilter = QgsSensorThingsUtils::filterForExtent( mGeometryField, requestExtent );
QString filterString = QgsSensorThingsUtils::combineFilters( { extentFilter, typeFilter } );
if ( !filterString.isEmpty() )
filterString = QStringLiteral( "&$filter=" ) + filterString;
QString queryUrl = !thisPage.isEmpty() ? thisPage : QStringLiteral( "%1?$top=%2&$count=false%3" ).arg( mEntityBaseUri ).arg( mMaximumPageSize ).arg( filterString );
if ( thisPage.isEmpty() && mCachedExtent.intersects( extentGeom ) )
{
@ -270,7 +283,7 @@ QgsFeatureIds QgsSensorThingsSharedData::getFeatureIdsInExtent( const QgsRectang
// This is slightly nicer from a rendering point of view, because panning the map won't see features
// previously visible disappear temporarily while we wait for them to be included in the service's result set...
nextPage = queryUrl;
return qgis::listToSet( mSpatialIndex.intersects( extent ) );
return qgis::listToSet( mSpatialIndex.intersects( requestExtent ) );
}
locker.unlock();

View File

@ -84,6 +84,8 @@ class QgsSensorThingsSharedData
QString mGeometryField;
QgsFields mFields;
QgsRectangle mFilterExtent;
//! Extent calculated from features actually fetched so far
QgsRectangle mFetchedFeatureExtent;

View File

@ -20,6 +20,7 @@
#include "qgsnetworkaccessmanager.h"
#include "qgsblockingnetworkrequest.h"
#include "qgslogger.h"
#include "qgsrectangle.h"
#include <QUrl>
#include <QNetworkRequest>
#include <nlohmann/json.hpp>
@ -264,6 +265,30 @@ QString QgsSensorThingsUtils::filterForWkbType( Qgis::SensorThingsEntity entityT
return QString();
}
QString QgsSensorThingsUtils::filterForExtent( const QString &geometryField, const QgsRectangle &extent )
{
// TODO -- confirm using 'geography' is always correct here
return ( extent.isNull() || geometryField.isEmpty() )
? QString()
: QStringLiteral( "geo.intersects(%1, geography'%2')" ).arg( geometryField, extent.asWktPolygon() );
}
QString QgsSensorThingsUtils::combineFilters( const QStringList &filters )
{
QStringList nonEmptyFilters;
for ( const QString &filter : filters )
{
if ( !filter.isEmpty() )
nonEmptyFilters.append( filter );
}
if ( nonEmptyFilters.empty() )
return QString();
if ( nonEmptyFilters.size() == 1 )
return nonEmptyFilters.at( 0 );
return QStringLiteral( "(" ) + nonEmptyFilters.join( QStringLiteral( ") and (" ) ) + QStringLiteral( ")" );
}
QList<Qgis::GeometryType> QgsSensorThingsUtils::availableGeometryTypes( const QString &uri, Qgis::SensorThingsEntity type, QgsFeedback *feedback, const QString &authCfg )
{
QNetworkRequest request = QNetworkRequest( QUrl( uri ) );

View File

@ -21,6 +21,7 @@
class QgsFields;
class QgsFeedback;
class QgsRectangle;
/**
* \ingroup core
@ -78,6 +79,25 @@ class CORE_EXPORT QgsSensorThingsUtils
*/
static QString filterForWkbType( Qgis::SensorThingsEntity entityType, Qgis::WkbType wkbType );
/**
* Returns a filter string which restricts results to those within the specified
* \a extent.
*
* The \a extent should always be specified in EPSG:4326.
*
* \since QGIS 3.38
*/
static QString filterForExtent( const QString &geometryField, const QgsRectangle &extent );
/**
* Combines a set of SensorThings API filter operators.
*
* See https://docs.ogc.org/is/18-088/18-088.html#requirement-request-data-filter
*
* \since QGIS 3.38
*/
static QString combineFilters( const QStringList &filters );
/**
* Returns a list of available geometry types for the server at the specified \a uri
* and entity \a type.

View File

@ -212,6 +212,12 @@ void QgsSensorThingsSourceSelect::addButtonClicked()
emit addLayer( Qgis::LayerType::Vector, layerUri, baseName, QgsSensorThingsProvider::SENSORTHINGS_PROVIDER_KEY );
}
void QgsSensorThingsSourceSelect::setMapCanvas( QgsMapCanvas *mapCanvas )
{
QgsAbstractDataSourceWidget::setMapCanvas( mapCanvas );
mSourceWidget->setMapCanvas( mapCanvas );
}
void QgsSensorThingsSourceSelect::populateConnectionList()
{
cmbConnections->blockSignals( true );

View File

@ -33,9 +33,8 @@ class QgsSensorThingsSourceSelect : public QgsAbstractDataSourceWidget, private
public:
QgsSensorThingsSourceSelect( QWidget *parent = nullptr, Qt::WindowFlags fl = QgsGuiUtils::ModalDialogFlags, QgsProviderRegistry::WidgetMode widgetMode = QgsProviderRegistry::WidgetMode::None );
//! Determines the layers the user selected
void addButtonClicked() override;
void setMapCanvas( QgsMapCanvas *mapCanvas ) override;
private slots:

View File

@ -24,6 +24,7 @@
#include "qgsiconutils.h"
#include "qgssensorthingsconnectionpropertiestask.h"
#include "qgsapplication.h"
#include "qgsextentwidget.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QStandardItemModel>
@ -33,6 +34,14 @@ QgsSensorThingsSourceWidget::QgsSensorThingsSourceWidget( QWidget *parent )
{
setupUi( this );
QVBoxLayout *vl = new QVBoxLayout();
vl->setContentsMargins( 0, 0, 0, 0 );
mExtentWidget = new QgsExtentWidget( nullptr, QgsExtentWidget::CondensedStyle );
mExtentWidget->setNullValueAllowed( true, tr( "Not set" ) );
mExtentWidget->setOutputCrs( QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) );
vl->addWidget( mExtentWidget );
mExtentLimitFrame->setLayout( vl );
mSpinPageSize->setClearValue( 0, tr( "Default (%1)" ).arg( QgsSensorThingsUtils::DEFAULT_PAGE_SIZE ) );
for ( Qgis::SensorThingsEntity type :
@ -58,6 +67,7 @@ QgsSensorThingsSourceWidget::QgsSensorThingsSourceWidget( QWidget *parent )
connect( mSpinPageSize, qOverload< int >( &QSpinBox::valueChanged ), this, &QgsSensorThingsSourceWidget::validate );
connect( mRetrieveTypesButton, &QToolButton::clicked, this, &QgsSensorThingsSourceWidget::retrieveTypes );
mRetrieveTypesButton->setEnabled( false );
connect( mExtentWidget, &QgsExtentWidget::extentChanged, this, &QgsSensorThingsSourceWidget::validate );
validate();
}
@ -92,6 +102,17 @@ void QgsSensorThingsSourceWidget::setSourceUri( const QString &uri )
mSpinPageSize->setValue( maxPageSizeParam );
}
const QgsRectangle bounds = mSourceParts.value( QStringLiteral( "bounds" ) ).value< QgsRectangle >();
if ( !bounds.isNull() )
{
mExtentWidget->setCurrentExtent( bounds, QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) );
mExtentWidget->setOutputExtentFromUser( bounds, QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) );
}
else
{
mExtentWidget->clear();
}
mIsValid = true;
}
@ -108,6 +129,12 @@ QString QgsSensorThingsSourceWidget::groupTitle() const
return QObject::tr( "SensorThings Configuration" );
}
void QgsSensorThingsSourceWidget::setMapCanvas( QgsMapCanvas *mapCanvas )
{
QgsProviderSourceWidget::setMapCanvas( mapCanvas );
mExtentWidget->setMapCanvas( mapCanvas, false );
}
QString QgsSensorThingsSourceWidget::updateUriFromGui( const QString &connectionUri ) const
{
QVariantMap parts = QgsProviderRegistry::instance()->decodeUri(
@ -152,6 +179,11 @@ QString QgsSensorThingsSourceWidget::updateUriFromGui( const QString &connection
parts.remove( QStringLiteral( "pageSize" ) );
}
if ( mExtentWidget->outputExtent().isNull() )
parts.remove( QStringLiteral( "bounds" ) );
else
parts.insert( QStringLiteral( "bounds" ), QVariant::fromValue( mExtentWidget->outputExtent() ) );
return QgsProviderRegistry::instance()->encodeUri(
QgsSensorThingsProvider::SENSORTHINGS_PROVIDER_KEY,
parts

View File

@ -23,7 +23,7 @@
#include <QVariantMap>
#include <QPointer>
class QgsFileWidget;
class QgsExtentWidget;
class QgsSensorThingsConnectionPropertiesTask;
///@cond PRIVATE
@ -40,6 +40,7 @@ class QgsSensorThingsSourceWidget : public QgsProviderSourceWidget, protected Ui
void setSourceUri( const QString &uri ) override;
QString sourceUri() const override;
QString groupTitle() const override;
void setMapCanvas( QgsMapCanvas *mapCanvas ) override;
/**
* Updates a connection uri with the layer specific URI settings defined in the widget.
@ -59,6 +60,7 @@ class QgsSensorThingsSourceWidget : public QgsProviderSourceWidget, protected Ui
void rebuildGeometryTypes( Qgis::SensorThingsEntity type );
void setCurrentGeometryTypeFromString( const QString &geometryType );
QgsExtentWidget *mExtentWidget = nullptr;
QVariantMap mSourceParts;
bool mIsValid = false;
QPointer< QgsSensorThingsConnectionPropertiesTask > mPropertiesTask;

View File

@ -7,13 +7,13 @@
<x>0</x>
<y>0</y>
<width>537</width>
<height>91</height>
<height>134</height>
</rect>
</property>
<property name="windowTitle">
<string>QgsSensorThingsSourceWidgetBase</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<layout class="QGridLayout" name="gridLayout" columnstretch="1,2,0">
<property name="leftMargin">
<number>0</number>
</property>
@ -40,6 +40,16 @@
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="mComboGeometryType"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Extent limit</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
@ -47,19 +57,6 @@
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="mComboGeometryType"/>
</item>
<item row="0" column="1" colspan="2">
<widget class="QComboBox" name="mComboEntityType"/>
</item>
<item row="2" column="1" colspan="2">
<widget class="QgsSpinBox" name="mSpinPageSize">
<property name="maximum">
<number>9999999</number>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QToolButton" name="mRetrieveTypesButton">
<property name="minimumSize">
@ -87,6 +84,23 @@
</property>
</widget>
</item>
<item row="0" column="1" colspan="2">
<widget class="QComboBox" name="mComboEntityType"/>
</item>
<item row="2" column="1" colspan="2">
<widget class="QgsSpinBox" name="mSpinPageSize">
<property name="maximum">
<number>9999999</number>
</property>
</widget>
</item>
<item row="3" column="1" colspan="2">
<widget class="QWidget" name="mExtentLimitFrame" native="true">
<property name="focusPolicy">
<enum>Qt::StrongFocus</enum>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
@ -96,6 +110,13 @@
<header>qgsspinbox.h</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>mComboEntityType</tabstop>
<tabstop>mComboGeometryType</tabstop>
<tabstop>mRetrieveTypesButton</tabstop>
<tabstop>mSpinPageSize</tabstop>
<tabstop>mExtentLimitFrame</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

@ -170,6 +170,19 @@ class TestPyQgsSensorThingsProvider(QgisTestCase): # , ProviderTestCase):
Qgis.SensorThingsEntity.FeatureOfInterest,
)
def test_filter_for_extent(self):
self.assertFalse(QgsSensorThingsUtils.filterForExtent('', QgsRectangle()))
self.assertFalse(QgsSensorThingsUtils.filterForExtent('test', QgsRectangle()))
self.assertFalse(QgsSensorThingsUtils.filterForExtent('', QgsRectangle(1,2 ,3 ,4 )))
self.assertEqual(QgsSensorThingsUtils.filterForExtent('test', QgsRectangle(1,2 ,3 ,4 )), "geo.intersects(test, geography'POLYGON((1 2, 3 2, 3 4, 1 4, 1 2))')")
def test_combine_filters(self):
self.assertFalse(QgsSensorThingsUtils.combineFilters([]))
self.assertFalse(QgsSensorThingsUtils.combineFilters(['']))
self.assertEqual(QgsSensorThingsUtils.combineFilters(['', 'a eq 1']), 'a eq 1')
self.assertEqual(QgsSensorThingsUtils.combineFilters(['a eq 1', 'b eq 2']), '(a eq 1) and (b eq 2)')
self.assertEqual(QgsSensorThingsUtils.combineFilters(['a eq 1', '', 'b eq 2', 'c eq 3']), '(a eq 1) and (b eq 2) and (c eq 3)')
def test_invalid_layer(self):
vl = QgsVectorLayer(
"url='http://fake.com/fake_qgis_http_endpoint'", "test", "sensorthings"
@ -753,7 +766,7 @@ class TestPyQgsSensorThingsProvider(QgisTestCase): # , ProviderTestCase):
)
with open(
sanitize(endpoint, "/Locations?$top=2&$count=false&$filter=geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))') and location/type eq 'Point'"),
sanitize(endpoint, "/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))')) and (location/type eq 'Point')"),
"wt",
encoding="utf8",
) as f:
@ -806,7 +819,7 @@ class TestPyQgsSensorThingsProvider(QgisTestCase): # , ProviderTestCase):
)
with open(
sanitize(endpoint, "/Locations?$top=2&$count=false&$filter=geo.intersects(location, geography'POLYGON((10 0, 20 0, 20 80, 10 80, 10 0))') and location/type eq 'Point'"),
sanitize(endpoint, "/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((10 0, 20 0, 20 80, 10 80, 10 0))')) and (location/type eq 'Point')"),
"wt",
encoding="utf8",
) as f:
@ -927,6 +940,253 @@ class TestPyQgsSensorThingsProvider(QgisTestCase): # , ProviderTestCase):
["/Locations(1)", "/Locations(3)", "/Locations(2)"],
)
def test_extent_limit(self):
with tempfile.TemporaryDirectory() as temp_dir:
base_path = temp_dir.replace("\\", "/")
endpoint = base_path + "/fake_qgis_http_endpoint"
with open(sanitize(endpoint, ""), "wt", encoding="utf8") as f:
f.write(
"""
{
"value": [
{
"name": "Locations",
"url": "endpoint/Locations"
}
],
"serverSettings": {
}
}""".replace(
"endpoint", "http://" + endpoint
)
)
with open(
sanitize(endpoint, "/Locations?$top=0&$count=true&$filter=(location/type eq 'Point') and (geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))'))"),
"wt",
encoding="utf8",
) as f:
f.write("""{"@iot.count":2,"value":[]}""")
with open(
sanitize(endpoint, "/Locations?$top=2&$count=false&$filter=(location/type eq 'Point') and (geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))'))"),
"wt",
encoding="utf8",
) as f:
f.write(
"""
{
"value": [
{
"@iot.selfLink": "endpoint/Locations(1)",
"@iot.id": 1,
"name": "Location 1",
"description": "Desc 1",
"encodingType": "application/geo+json",
"location": {
"type": "Point",
"coordinates": [
1.623373,
52.132017
]
},
"properties": {
"owner": "owner 1"
},
"Things@iot.navigationLink": "endpoint/Locations(1)/Things",
"HistoricalLocations@iot.navigationLink": "endpoint/Locations(1)/HistoricalLocations"
},
{
"@iot.selfLink": "endpoint/Locations(3)",
"@iot.id": 3,
"name": "Location 3",
"description": "Desc 3",
"encodingType": "application/geo+json",
"location": {
"type": "Point",
"coordinates": [
3.623373,
55.132017
]
},
"properties": {
"owner": "owner 3"
},
"Things@iot.navigationLink": "endpoint/Locations(3)/Things",
"HistoricalLocations@iot.navigationLink": "endpoint/Locations(3)/HistoricalLocations"
}
]
}
""".replace(
"endpoint", "http://" + endpoint
)
)
with open(
sanitize(endpoint,
"/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))')) and (location/type eq 'Point')"),
"wt",
encoding="utf8",
) as f:
f.write(
"""
{
"value": [
{
"@iot.selfLink": "endpoint/Locations(1)",
"@iot.id": 1,
"name": "Location 1",
"description": "Desc 1",
"encodingType": "application/geo+json",
"location": {
"type": "Point",
"coordinates": [
1.623373,
52.132017
]
},
"properties": {
"owner": "owner 1"
},
"Things@iot.navigationLink": "endpoint/Locations(1)/Things",
"HistoricalLocations@iot.navigationLink": "endpoint/Locations(1)/HistoricalLocations"
},
{
"@iot.selfLink": "endpoint/Locations(3)",
"@iot.id": 3,
"name": "Location 3",
"description": "Desc 3",
"encodingType": "application/geo+json",
"location": {
"type": "Point",
"coordinates": [
3.623373,
55.132017
]
},
"properties": {
"owner": "owner 3"
},
"Things@iot.navigationLink": "endpoint/Locations(3)/Things",
"HistoricalLocations@iot.navigationLink": "endpoint/Locations(3)/HistoricalLocations"
}
]
}""".replace(
"endpoint", "http://" + endpoint
)
)
with open(
sanitize(endpoint,
"/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((1 0, 3 0, 3 50, 1 50, 1 0))')) and (location/type eq 'Point')"),
"wt",
encoding="utf8",
) as f:
f.write(
"""
{
"value": [
{
"@iot.selfLink": "endpoint/Locations(1)",
"@iot.id": 1,
"name": "Location 1",
"description": "Desc 1",
"encodingType": "application/geo+json",
"location": {
"type": "Point",
"coordinates": [
1.623373,
52.132017
]
},
"properties": {
"owner": "owner 1"
},
"Things@iot.navigationLink": "endpoint/Locations(1)/Things",
"HistoricalLocations@iot.navigationLink": "endpoint/Locations(1)/HistoricalLocations"
}
]
}""".replace(
"endpoint", "http://" + endpoint
)
)
vl = QgsVectorLayer(
f"url='http://{endpoint}' bbox='1,0,10,80' type=PointZ pageSize=2 entity='Location'",
"test",
"sensorthings",
)
self.assertTrue(vl.isValid())
self.assertEqual(vl.storageType(), "OGC SensorThings API")
self.assertEqual(vl.wkbType(), Qgis.WkbType.PointZ)
self.assertEqual(vl.featureCount(), 2)
# should use the hardcoded extent limit as the initial guess, not global extents
self.assertEqual(vl.extent(), QgsRectangle(1, 0, 10, 80))
self.assertEqual(vl.crs().authid(), "EPSG:4326")
self.assertIn("Entity Type</td><td>Location</td>",
vl.htmlMetadata())
self.assertIn(f'href="http://{endpoint}/Locations"',
vl.htmlMetadata())
request = QgsFeatureRequest()
request.setFilterRect(
QgsRectangle(1, 0, 3, 50)
)
features = list(vl.getFeatures(request))
self.assertEqual([f["id"] for f in features], ["1"])
self.assertEqual(
[f["selfLink"][-13:] for f in features],
["/Locations(1)"],
)
self.assertEqual(
[f["name"] for f in features],
["Location 1"],
)
self.assertEqual(
[f["description"] for f in features],
["Desc 1"]
)
self.assertEqual(
[f["properties"] for f in features],
[{"owner": "owner 1"}],
)
self.assertEqual(
[f.geometry().asWkt(1) for f in features],
["Point (1.6 52.1)"],
)
request = QgsFeatureRequest()
features = list(vl.getFeatures(request))
self.assertEqual([f["id"] for f in features], ["1", "3"])
self.assertEqual(
[f["selfLink"][-13:] for f in features],
["/Locations(1)", "/Locations(3)"],
)
self.assertEqual(
[f["name"] for f in features],
["Location 1", "Location 3"],
)
self.assertEqual(
[f["description"] for f in features],
["Desc 1", "Desc 3"]
)
self.assertEqual(
[f["properties"] for f in features],
[{"owner": "owner 1"},
{"owner": "owner 3"}],
)
self.assertEqual(
[f.geometry().asWkt(1) for f in features],
["Point (1.6 52.1)",
"Point (3.6 55.1)"],
)
# should have accurate layer extent now
self.assertEqual(vl.extent(), QgsRectangle(1.62337299999999995, 52.13201699999999761, 3.62337299999999995, 55.13201699999999761))
def test_historical_location(self):
with tempfile.TemporaryDirectory() as temp_dir:
base_path = temp_dir.replace("\\", "/")
@ -2093,6 +2353,19 @@ class TestPyQgsSensorThingsProvider(QgisTestCase): # , ProviderTestCase):
},
)
uri = "url='https://sometest.com/api' bbox='1,2,3,4' type=MultiPolygonZ authcfg='abc' entity='Location'"
parts = QgsProviderRegistry.instance().decodeUri("sensorthings", uri)
self.assertEqual(
parts,
{
"url": "https://sometest.com/api",
"entity": "Location",
"geometryType": "polygon",
"authcfg": "abc",
"bounds": QgsRectangle(1, 2, 3, 4)
},
)
def testEncodeUri(self):
"""
Test encoding a SensorThings uri
@ -2146,6 +2419,19 @@ class TestPyQgsSensorThingsProvider(QgisTestCase): # , ProviderTestCase):
"authcfg=aaaaa type=MultiPolygonZ entity='Location' url='http://blah.com'",
)
parts = {
"url": "http://blah.com",
"authcfg": "aaaaa",
"entity": "location",
"geometryType": "polygon",
"bounds": QgsRectangle(1,2 ,3 ,4 )
}
uri = QgsProviderRegistry.instance().encodeUri("sensorthings", parts)
self.assertEqual(
uri,
"authcfg=aaaaa type=MultiPolygonZ bbox='1,2,3,4' entity='Location' url='http://blah.com'",
)
if __name__ == "__main__":
unittest.main()