Skip to content

Commit

Permalink
[OAPIF provider] Implements getFeature() expression translation
Browse files Browse the repository at this point in the history
  • Loading branch information
rouault authored and nyalldawson committed Apr 24, 2023
1 parent e10bd31 commit a19efb1
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 37 deletions.
122 changes: 89 additions & 33 deletions src/providers/wfs/oapif/qgsoapifprovider.cpp
Expand Up @@ -872,10 +872,10 @@ static void collectTopLevelAndNodes( const QgsExpressionNode *node,
topAndNodes.push_back( node );
}

QString QgsOapifSharedData::translateNodeToServer(
QString QgsOapifSharedData::compileExpressionNodeUsingPart1(
const QgsExpressionNode *rootNode,
QgsOapifProvider::FilterTranslationState &translationState,
QString &untranslatedPart )
QString &untranslatedPart ) const
{
std::vector<const QgsExpressionNode *> topAndNodes;
collectTopLevelAndNodes( rootNode, topAndNodes );
Expand Down Expand Up @@ -1035,18 +1035,15 @@ QString QgsOapifSharedData::translateNodeToServer(
return ret;
}

bool QgsOapifSharedData::computeServerFilter( QString &errorMsg )
bool QgsOapifSharedData::computeFilter( const QgsExpression &expr,
QgsOapifProvider::FilterTranslationState &translationState,
QString &serverSideParameters,
QString &clientSideFilterExpression ) const
{
errorMsg.clear();
mClientSideFilterExpression = mURI.filter();
mServerFilter.clear();
if ( mClientSideFilterExpression.isEmpty() )
{
mFilterTranslationState = QgsOapifProvider::FilterTranslationState::FULLY_SERVER;
return true;
}
const auto rootNode = expr.rootNode();
if ( !rootNode )
return false;

const QgsExpression expr( mClientSideFilterExpression );
if ( mServerSupportsFilterCql2Text )
{
const bool invertAxisOrientation = mSourceCrs.hasAxisInverted();
Expand All @@ -1056,54 +1053,73 @@ bool QgsOapifSharedData::computeServerFilter( QString &errorMsg )
QgsOapifCql2TextExpressionCompiler::Result res = compiler.compile( &expr );
if ( res == QgsOapifCql2TextExpressionCompiler::Fail )
{
QgsDebugMsg( "Whole filter will be evaluated on client-side" );
mFilterTranslationState = QgsOapifProvider::FilterTranslationState::FULLY_CLIENT;
clientSideFilterExpression = expr.rootNode()->dump();
translationState = QgsOapifProvider::FilterTranslationState::FULLY_CLIENT;
return true;
}
mServerFilter = getEncodedQueryParam( QStringLiteral( "filter" ), compiler.result() );
mServerFilter += QStringLiteral( "&filter-lang=cql2-text" );
serverSideParameters = getEncodedQueryParam( QStringLiteral( "filter" ), compiler.result() );
serverSideParameters += QStringLiteral( "&filter-lang=cql2-text" );
if ( compiler.geometryLiteralUsed() )
{
if ( mSourceCrs
!= QgsCoordinateReferenceSystem::fromOgcWmsCrs( QgsOapifProvider::OAPIF_PROVIDER_DEFAULT_CRS ) )
{
mServerFilter += QStringLiteral( "&filter-crs=%1" ).arg( mSourceCrs.toOgcUri() );
serverSideParameters += QStringLiteral( "&filter-crs=%1" ).arg( mSourceCrs.toOgcUri() );
}
}

clientSideFilterExpression.clear();
if ( res == QgsOapifCql2TextExpressionCompiler::Partial )
{
mFilterTranslationState = QgsOapifProvider::FilterTranslationState::PARTIAL;
QgsDebugMsg( "Part of the filter will be evaluated on client-side" );
translationState = QgsOapifProvider::FilterTranslationState::PARTIAL;
}
else
{
mFilterTranslationState = QgsOapifProvider::FilterTranslationState::FULLY_SERVER;
translationState = QgsOapifProvider::FilterTranslationState::FULLY_SERVER;
}
return true;
}

const auto rootNode = expr.rootNode();
if ( !rootNode )
return false;
serverSideParameters = compileExpressionNodeUsingPart1( rootNode, translationState, clientSideFilterExpression );
return true;
}

mServerFilter = translateNodeToServer( rootNode, mFilterTranslationState, mClientSideFilterExpression );
if ( mFilterTranslationState == QgsOapifProvider::FilterTranslationState::PARTIAL )
bool QgsOapifSharedData::computeServerFilter( QString &errorMsg )
{
errorMsg.clear();
mClientSideFilterExpression = mURI.filter();
mServerFilter.clear();
if ( mClientSideFilterExpression.isEmpty() )
{
QgsDebugMsg( QStringLiteral( "Part of the filter will be evaluated on client-side: %1" ).arg( mClientSideFilterExpression ) );
mFilterTranslationState = QgsOapifProvider::FilterTranslationState::FULLY_SERVER;
return true;
}
else if ( mFilterTranslationState == QgsOapifProvider::FilterTranslationState::FULLY_CLIENT )

const QgsExpression expr( mClientSideFilterExpression );
bool ret = computeFilter( expr, mFilterTranslationState, mServerFilter, mClientSideFilterExpression );
if ( ret )
{
QgsDebugMsg( "Whole filter will be evaluated on client-side" );
if ( mFilterTranslationState == QgsOapifProvider::FilterTranslationState::PARTIAL )
{
QgsDebugMsg( QStringLiteral( "Part of the filter will be evaluated on client-side: %1" ).arg( mClientSideFilterExpression ) );
}
else if ( mFilterTranslationState == QgsOapifProvider::FilterTranslationState::FULLY_CLIENT )
{
QgsDebugMsg( "Whole filter will be evaluated on client-side" );
}
}

return true;
return ret;
}

QString QgsOapifSharedData::computedExpression( const QgsExpression &expression ) const
{
Q_UNUSED( expression );
return QString();
if ( !expression.isValid() )
return QString();
QgsOapifProvider::FilterTranslationState translationState;
QString serverParameters;
QString clientSideFilterExpression;
computeFilter( expression, translationState, serverParameters, clientSideFilterExpression );
return serverParameters;
}

void QgsOapifSharedData::pushError( const QString &errorMsg ) const
Expand Down Expand Up @@ -1178,10 +1194,50 @@ void QgsOapifFeatureDownloaderImpl::run( bool serializeFeatures, long long maxFe
hasQueryParam = true;
}

// mServerFilter comes from the translation of the uri "filter" parameter
// mServerExpression comes from the translation of a getFeatures() expression
if ( !mShared->mServerFilter.isEmpty() )
{
url += ( hasQueryParam ? QStringLiteral( "&" ) : QStringLiteral( "?" ) );
url += mShared->mServerFilter;
if ( !mShared->mServerExpression.isEmpty() )
{
// Combine mServerFilter and mServerExpression
QStringList components1 = mShared->mServerFilter.split( QLatin1Char( '&' ) );
QStringList components2 = mShared->mServerExpression.split( QLatin1Char( '&' ) );
Q_ASSERT( components1[0].startsWith( QStringLiteral( "filter=" ) ) );
Q_ASSERT( components2[0].startsWith( QStringLiteral( "filter=" ) ) );
url += QStringLiteral( "filter=" );
url += '(';
url += components1[0].mid( static_cast<int>( strlen( "filter=" ) ) );
url += QStringLiteral( ") AND (" );
url += components2[0].mid( static_cast<int>( strlen( "filter=" ) ) );
url += ')';
// Add components1 extra parameters: filter-lang and filter-crs
for ( int i = 1; i < components1.size(); ++i )
{
url += '&';
url += components1[i];
}
// Add components2 extra parameters, not already included in components1
for ( int i = 1; i < components2.size(); ++i )
{
if ( !components1.contains( components2[i] ) )
{
url += '&';
url += components1[i];
}
}
}
else
{
url += mShared->mServerFilter;
}
hasQueryParam = true;
}
else if ( !mShared->mServerExpression.isEmpty() )
{
url += ( hasQueryParam ? QStringLiteral( "&" ) : QStringLiteral( "?" ) );
url += mShared->mServerExpression;
hasQueryParam = true;
}

Expand Down
14 changes: 10 additions & 4 deletions src/providers/wfs/oapif/qgsoapifprovider.h
Expand Up @@ -240,10 +240,16 @@ class QgsOapifSharedData final: public QObject, public QgsBackgroundCachedShared

private:

// Translate part of an expression to a server-side filter
QString translateNodeToServer( const QgsExpressionNode *node,
QgsOapifProvider::FilterTranslationState &translationState,
QString &untranslatedPart );
// Translate part of an expression to a server-side filter using Part1 features only
QString compileExpressionNodeUsingPart1( const QgsExpressionNode *node,
QgsOapifProvider::FilterTranslationState &translationState,
QString &untranslatedPart ) const;

// Translate part of an expression to a server-side filter using Part1 or Part3
bool computeFilter( const QgsExpression &expr,
QgsOapifProvider::FilterTranslationState &translationState,
QString &serverSideParameters,
QString &clientSideFilterExpression ) const;

//! Log error to QgsMessageLog and raise it to the provider
void pushError( const QString &errorMsg ) const override;
Expand Down
66 changes: 66 additions & 0 deletions tests/src/python/test_provider_oapif.py
Expand Up @@ -1038,6 +1038,72 @@ def testCQL2TextFilteringAndPart2(self):
os.unlink(filename)
self.assertEqual(values, ['feat.1'], expr)

def testCQL2TextFilteringGetFeaturesExpression(self):
"""Test Part 3 CQL2-Text filtering"""

endpoint = self.__class__.basetestpath + '/fake_qgis_http_endpoint_encoded_query_testCQL2TextFilteringGetFeaturesExpression'
additionalConformance = [
"http://www.opengis.net/spec/cql2/1.0/conf/basic-cql2",
"http://www.opengis.net/spec/cql2/1.0/conf/cql2-text",
"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/features-filter",
"http://www.opengis.net/spec/ogcapi-features-3/1.0/conf/filter",
]

create_landing_page_api_collection(endpoint, additionalConformance=additionalConformance)

filename = sanitize(endpoint, '/collections/mycollection/queryables?' + ACCEPT_QUERYABLES)
queryables = {
"properties": {
"strfield": {
"type": "string"
},
"geometry": {
"$ref": "https://geojson.org/schema/Point.json"
},
}
}
with open(filename, 'wb') as f:
f.write(json.dumps(queryables).encode('UTF-8'))

items = {
"type": "FeatureCollection",
"features": [
{"type": "Feature", "id": "feat.1", "properties":
{
"strfield": "foo=bar",
},
"geometry": {"type": "Point", "coordinates": [-70.5, 66.5]}}
]
}

filename = sanitize(endpoint, '/collections/mycollection/items?limit=10&' + ACCEPT_ITEMS)
with open(filename, 'wb') as f:
f.write(json.dumps(items).encode('UTF-8'))

vl = QgsVectorLayer(
"url='http://" + endpoint + "' typename='mycollection'", 'test', 'OAPIF')
self.assertTrue(vl.isValid())
os.unlink(filename)

tests = [
("", """"strfield" = 'foo=bar'""",
"""filter=(strfield%20%3D%20'foo%3Dbar')&filter-lang=cql2-text"""),
("strfield <> 'x'", """"strfield" = 'foo=bar'""",
"""filter=((strfield%20%3C%3E%20'x'))%20AND%20((strfield%20%3D%20'foo%3Dbar'))&filter-lang=cql2-text"""),
]
for (substring_expr, getfeatures_expr, cql_filter) in tests:
assert vl.setSubsetString(substring_expr)
filename = sanitize(endpoint,
"/collections/mycollection/items?limit=1000&" + cql_filter + ("&" if cql_filter else "") + ACCEPT_ITEMS)
with open(filename, 'wb') as f:
f.write(json.dumps(items).encode('UTF-8'))
request = QgsFeatureRequest()
if getfeatures_expr:
request.setFilterExpression(getfeatures_expr)
values = [f['id'] for f in vl.getFeatures(request)]
os.unlink(filename)
self.assertEqual(values, ['feat.1'], (substring_expr, getfeatures_expr))

def testStringList(self):

endpoint = self.__class__.basetestpath + '/fake_qgis_http_endpoint_testStringList'
Expand Down

0 comments on commit a19efb1

Please sign in to comment.