Skip to content

Commit a1770a7

Browse files
authored
Implement optional count in Feature Providers (#2215)
* Implement optional count in Feature Providers * Revert collection entry * Add count for SQLite.GPKG * Update docs
1 parent 6302a5f commit a1770a7

20 files changed

Lines changed: 215 additions & 61 deletions

docs/source/configuration.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,9 @@ default.
273273
include_extra_query_parameters: false # include extra query parameters that are not part of the collection properties (default: false)
274274
# editable transactions: DO NOT ACTIVATE unless you have setup access control beyond pygeoapi
275275
editable: true # optional: if backend is writable, default is false
276+
# count: include `numberMatched` in collection results responses, for providers
277+
# that require an additional query to calculate this value (e.g. a SQL COUNT query).
278+
count: true # optional: perform additional count on queries, default is true
276279
# coordinate reference systems (CRS) section is optional
277280
# default CRSs are http://www.opengis.net/def/crs/OGC/1.3/CRS84 (coordinates without height)
278281
# and http://www.opengis.net/def/crs/OGC/1.3/CRS84h (coordinates with ellipsoidal height)

docs/source/publishing/ogcapi-features.rst

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,36 @@ parameters.
1616

1717

1818
.. csv-table::
19-
:header: Provider, property filters/display, resulttype, bbox, datetime, sortby, skipGeometry, domains, CQL, transactions, crs
19+
:header: Provider, property filters/display, resulttype hits/count, bbox, datetime, sortby, skipGeometry, domains, CQL, transactions, crs
2020
:align: left
2121

22-
`CSV`_,✅/✅,results/hits,✅,❌,❌,✅,❌,❌,❌,✅
23-
`Elasticsearch`_,✅/✅,results/hits,✅,✅,✅,✅,✅,✅,✅,✅
24-
`ERDDAP Tabledap Service`_,❌/❌,results/hits,✅,✅,❌,❌,❌,❌,❌,✅
25-
`ESRI Feature Service`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,❌,✅
26-
`GeoJSON`_,✅/✅,results/hits,✅,❌,❌,✅,❌,❌,❌,✅
27-
`MongoDB`_,✅/❌,results,✅,✅,✅,✅,❌,❌,❌,✅
28-
`MySQL`_,✅/✅,results/hits,✅,✅,✅,✅,❌,✅,✅,✅
29-
`OGR`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,❌,✅
30-
`OpenSearch`_,✅/✅,results/hits,✅,✅,✅,✅,❌,✅,✅,✅
31-
`Oracle`_,✅/✅,results/hits,✅,❌,✅,✅,❌,❌,❌,✅
32-
`Parquet`_,✅/✅,results/hits,✅,✅,❌,✅,❌,❌,❌,✅
33-
`PostgreSQL`_,✅/✅,results/hits,✅,✅,✅,✅,❌,✅,✅,✅
34-
`SQLiteGPKG`_,✅/❌,results/hits,✅,❌,❌,✅,❌,❌,❌,✅
35-
`SensorThings API`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,✅,✅
36-
`Socrata`_,✅/✅,results/hits,✅,✅,✅,✅,❌,❌,❌,✅
37-
`TinyDB`_,✅/✅,results/hits,✅,✅,✅,✅,✅,❌,✅,✅
22+
`CSV`_,✅/✅,✅/✅,✅,❌,❌,✅,❌,❌,❌,✅
23+
`Elasticsearch`_,✅/✅,✅/❌,✅,✅,✅,✅,✅,✅,✅,✅
24+
`ERDDAP Tabledap Service`_,❌/❌,❌/❌,✅,✅,❌,❌,❌,❌,❌,✅
25+
`ESRI Feature Service`_,✅/✅,✅/✅,✅,✅,✅,✅,❌,❌,❌,✅
26+
`GeoJSON`_,✅/✅,✅/✅,✅,❌,❌,✅,❌,❌,❌,✅
27+
`MongoDB`_,✅/❌,✅/✅,✅,✅,✅,✅,❌,❌,❌,✅
28+
`MySQL`_,✅/✅,✅/✅,✅,✅,✅,✅,❌,✅,✅,✅
29+
`OGR`_,✅/❌,✅/❌,✅,❌,❌,✅,❌,❌,❌,✅
30+
`OpenSearch`_,✅/✅,✅/❌,✅,✅,✅,✅,❌,✅,✅,✅
31+
`Oracle`_,✅/✅,✅/✅,✅,❌,✅,✅,❌,❌,❌,✅
32+
`Parquet`_,✅/✅,✅/❌,✅,✅,❌,✅,❌,❌,❌,✅
33+
`PostgreSQL`_,✅/✅,✅/✅,✅,✅,✅,✅,❌,✅,✅,✅
34+
`SQLiteGPKG`_,✅/❌,✅/✅,✅,❌,❌,✅,❌,❌,❌,✅
35+
`SensorThings API`_,✅/✅,✅/❌,✅,✅,✅,✅,❌,❌,✅,✅
36+
`Socrata`_,✅/✅,✅/✅,✅,✅,✅,✅,❌,❌,❌,✅
37+
`TinyDB`_,✅/✅,✅/✅,✅,✅,✅,✅,✅,❌,✅,✅
3838

3939
.. note::
4040
For more information on CRS transformations, see :ref:`crs`.
4141

4242

43+
.. note::
44+
Providers that support the query parameter ``resulttype=hits`` will also
45+
include ``numberMatched`` in the default ``resulttype=results`` response.
46+
Providers that support the configuration option ``count: false`` will
47+
not include the ``numberMatched`` property in the ``results`` response.
48+
4349
Connection examples
4450
-------------------
4551

pygeoapi/provider/csv_.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,9 @@ def _load(self, offset=0, limit=10, resulttype='results',
194194

195195
feature_collection['features'].append(feature)
196196

197-
feature_collection['numberMatched'] = \
198-
len(feature_collection['features'])
197+
if self.count:
198+
feature_collection['numberMatched'] = \
199+
len(feature_collection['features'])
199200

200201
if identifier is not None and not found:
201202
return None

pygeoapi/provider/esri.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,13 @@ def query(self, offset=0, limit=10, resulttype='results',
156156
fc = {
157157
'type': 'FeatureCollection',
158158
'features': [],
159-
'numberMatched': self._get_count(params)
160159
}
161160

161+
if self.count or resulttype == 'hits':
162+
matched = self._get_count(params)
163+
LOGGER.debug(f'Found {matched} result(s)')
164+
fc['numberMatched'] = matched
165+
162166
if resulttype == 'hits':
163167
return fc
164168

@@ -168,7 +172,7 @@ def query(self, offset=0, limit=10, resulttype='results',
168172
params['resultOffset'] = offset
169173
params['resultRecordCount'] = limit
170174

171-
hits_ = min(limit, fc['numberMatched'])
175+
hits_ = min(limit, matched) if self.count else limit
172176
fc['features'] = self._get_all(params, hits_)
173177

174178
fc['numberReturned'] = len(fc['features'])

pygeoapi/provider/geojson.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ def query(self, offset=0, limit=10, resulttype='results',
188188
properties=properties,
189189
select_properties=select_properties)
190190

191-
data['numberMatched'] = len(data['features'])
191+
if self.count or resulttype == 'hits':
192+
data['numberMatched'] = len(data['features'])
192193

193194
if resulttype == 'hits':
194195
data['features'] = []

pygeoapi/provider/mongo.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
102102
if sortList:
103103
featurecursor = featurecursor.sort(sortList)
104104

105-
matchCount = self.featuredb[self.collection].count_documents(filterObj)
106105
featurecursor.skip(skip)
107106
featurecursor.limit(maxitems)
108107
featurelist = list(featurecursor)
@@ -111,7 +110,7 @@ def _get_feature_list(self, filterObj, sortList=[], skip=0, maxitems=1,
111110
if skip_geometry:
112111
item['geometry'] = None
113112

114-
return featurelist, matchCount
113+
return featurelist
115114

116115
@crs_transform
117116
def query(self, offset=0, limit=10, resulttype='results',
@@ -144,20 +143,28 @@ def query(self, offset=0, limit=10, resulttype='results',
144143
ASCENDING if (sort['order'] == '+') else DESCENDING)
145144
for sort in sortby]
146145

147-
featurelist, matchcount = self._get_feature_list(
148-
filterobj, sortList=sort_list, skip=offset, maxitems=limit,
149-
skip_geometry=skip_geometry)
150-
151-
if resulttype == 'hits':
152-
featurelist = []
153-
154146
feature_collection = {
155147
'type': 'FeatureCollection',
156-
'features': featurelist,
157-
'numberMatched': matchcount,
158-
'numberReturned': len(featurelist)
148+
'features': []
159149
}
160150

151+
if self.count or resulttype == 'hits':
152+
matched = self.featuredb[self.collection].count_documents(
153+
filterobj)
154+
LOGGER.debug(f'Found {matched} result(s)')
155+
feature_collection['numberMatched'] = matched
156+
157+
if resulttype == 'hits':
158+
return feature_collection
159+
160+
featurelist = self._get_feature_list(
161+
filterobj, sortList=sort_list, skip=offset, maxitems=limit,
162+
skip_geometry=skip_geometry
163+
)
164+
165+
feature_collection['features'] = featurelist
166+
feature_collection['numberReturned'] = len(featurelist)
167+
161168
return feature_collection
162169

163170
@crs_transform
@@ -168,8 +175,7 @@ def get(self, identifier, **kwargs):
168175
:param identifier: feature id
169176
:returns: dict of single GeoJSON feature
170177
"""
171-
featurelist, matchcount = self._get_feature_list(
172-
{'_id': ObjectId(identifier)})
178+
featurelist = self._get_feature_list({'_id': ObjectId(identifier)})
173179
if featurelist:
174180
return featurelist[0]
175181
else:

pygeoapi/provider/socrata.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,14 @@ def query(self, offset=0, limit=10, resulttype='results',
123123

124124
fc = {
125125
'type': 'FeatureCollection',
126-
'features': [],
127-
'numberMatched': self._get_count(params)
126+
'features': []
128127
}
129128

129+
if self.count or resulttype == 'hits':
130+
matched = self._get_count(params)
131+
LOGGER.debug(f'Found {matched} result(s)')
132+
fc['numberMatched'] = matched
133+
130134
if resulttype == 'hits':
131135
# Return hits
132136
LOGGER.debug('Returning hits')

pygeoapi/provider/sql.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ def query(
220220

221221
crs_transform_out = get_transform_from_spec(crs_transform_spec)
222222

223+
response['numberReturned'] = 0
223224
for item in (
224225
results.order_by(*order_by_clauses).offset(offset).limit(limit)
225226
):

pygeoapi/provider/sqlite.py

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -169,18 +169,6 @@ def __response_feature(self, row_data, skip_geometry=False):
169169
else:
170170
return None
171171

172-
def __response_feature_hits(self, hits):
173-
"""Assembles GeoJSON/Feature number
174-
175-
:returns: GeoJSON FeaturesCollection
176-
"""
177-
178-
feature_collection = {"features": [],
179-
"type": "FeatureCollection"}
180-
feature_collection['numberMatched'] = hits
181-
182-
return feature_collection
183-
184172
def __load(self):
185173
"""
186174
Private method for loading spatiallite,
@@ -305,14 +293,25 @@ def query(self, offset=0, limit=10, resulttype='results',
305293
where_clause, where_values = self.__get_where_clauses(
306294
properties=properties, bbox=bbox)
307295

308-
if resulttype == 'hits':
296+
feature_collection = {
297+
'type': 'FeatureCollection',
298+
'features': []
299+
}
300+
301+
if self.count or resulttype == 'hits':
309302

310-
sql_query = f"SELECT COUNT(*) as hits FROM {self.table} {where_clause} " # noqa
303+
sql_query = (
304+
'SELECT COUNT(*) as hits '
305+
f'FROM {self.table} {where_clause}'
306+
)
311307

312308
res = self.cursor.execute(sql_query, where_values)
313309

314-
hits = res.fetchone()["hits"]
315-
return self.__response_feature_hits(hits)
310+
hits = res.fetchone()['hits']
311+
feature_collection['numberMatched'] = hits
312+
313+
if resulttype == 'hits':
314+
return feature_collection
316315

317316
sql_query = f"SELECT DISTINCT {self.columns} from \
318317
{self.table} {where_clause} limit ? offset ?"
@@ -326,14 +325,12 @@ def query(self, offset=0, limit=10, resulttype='results',
326325
row_data = self.cursor.execute(
327326
sql_query, where_values + (limit, offset))
328327

329-
feature_collection = {
330-
'type': 'FeatureCollection',
331-
'features': []
332-
}
333-
328+
feature_collection['numberReturned'] = 0
334329
for rd in row_data:
335330
feature_collection['features'].append(
336-
self.__response_feature(rd, skip_geometry=skip_geometry))
331+
self.__response_feature(rd, skip_geometry=skip_geometry)
332+
)
333+
feature_collection['numberReturned'] += 1
337334

338335
return feature_collection
339336

pygeoapi/provider/tinydb_.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@ def query(self, offset=0, limit=10, resulttype='results',
229229
LOGGER.error(f'{msg}: {err}')
230230
raise ProviderInvalidQueryError(msg)
231231

232-
feature_collection['numberMatched'] = len(results)
232+
if self.count or resulttype == 'hits':
233+
matched = len(results)
234+
LOGGER.debug(f'Found {matched} result(s)')
235+
feature_collection['numberMatched'] = matched
233236

234237
if resulttype == 'hits':
235238
return feature_collection

0 commit comments

Comments
 (0)