Coverage for pygeoapi/provider/sqlite.py: 76%
167 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 08:15 +0000
1# =================================================================
2#
3# Authors: Jorge Samuel Mendes de Jesus <jorge.dejesus@protonmail.net>
4# Tom Kralidis <tomkralidis@gmail.com>
5# Francesco Bartoli <xbartolone@gmail.com>
6#
7# Copyright (c) 2018 Jorge Samuel Mendes de Jesus
8# Copyright (c) 2023 Tom Kralidis
9# Copyright (c) 2025 Francesco Bartoli
10#
11# Permission is hereby granted, free of charge, to any person
12# obtaining a copy of this software and associated documentation
13# files (the "Software"), to deal in the Software without
14# restriction, including without limitation the rights to use,
15# copy, modify, merge, publish, distribute, sublicense, and/or sell
16# copies of the Software, and to permit persons to whom the
17# Software is furnished to do so, subject to the following
18# conditions:
19#
20# The above copyright notice and this permission notice shall be
21# included in all copies or substantial portions of the Software.
22#
23# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
24# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
25# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
26# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
27# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
28# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
29# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
30# OTHER DEALINGS IN THE SOFTWARE.
31#
32# =================================================================
34import sqlite3
35import logging
36import os
37import json
39from pygeoapi.crs import crs_transform
40from pygeoapi.plugin import InvalidPluginError
41from pygeoapi.provider.base import (BaseProvider, ProviderConnectionError,
42 ProviderItemNotFoundError)
44LOGGER = logging.getLogger(__name__)
47SPATIALITE_EXTENSION = os.getenv('SPATIALITE_LIBRARY_PATH',
48 'mod_spatialite.so')
51class SQLiteGPKGProvider(BaseProvider):
52 """Generic provider for SQLITE and GPKG using sqlite3 module.
53 This module requires install of libsqlite3-mod-spatialite
54 TODO: DELETE, UPDATE, CREATE
55 """
57 def __init__(self, provider_def):
58 """
59 SQLiteGPKGProvider Class constructor
61 :param provider_def: provider definitions from yml pygeoapi-config.
62 data,id_field, name set in parent class
64 :returns: pygeoapi.provider.base.SQLiteProvider
65 """
66 super().__init__(provider_def)
68 self.table = provider_def['table']
69 self.application_id = None
70 self.geom_col = None
72 LOGGER.debug('Setting SQLite properties:')
73 LOGGER.debug(f'Data source: {self.data}')
74 LOGGER.debug(f'Name: {self.name}')
75 LOGGER.debug(f'ID_field: {self.id_field}')
76 LOGGER.debug(f'Table: {self.table}')
78 self.cursor = self.__load()
80 LOGGER.debug('Got cursor from DB')
81 LOGGER.debug('Get available fields/properties')
83 self.get_fields()
85 def get_fields(self):
86 """
87 Get fields from sqlite table (columns are field)
89 :returns: dict of fields
90 """
92 if not self._fields: 92 ↛ 106line 92 didn't jump to line 106 because the condition on line 92 was always true
93 results = self.cursor.execute(
94 f'PRAGMA table_info({self.table})').fetchall()
95 for item in results:
96 json_type = None
98 if item['type'] in ['INTEGER', 'REAL']:
99 json_type = 'number'
100 elif item['type'].startswith('TEXT') or item['type'] == 'BLOB': 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 json_type = 'string'
103 if json_type is not None:
104 self._fields[item['name']] = {'type': json_type}
106 return self._fields
108 def __get_where_clauses(self, properties=[], bbox=[]):
109 """
110 Generarates WHERE conditions to be implemented in query.
111 Private method mainly associated with query method.
113 Method returns part of the SQL query, plus tuple to be used
114 in the sqlite query method
116 :param properties: list of tuples (name, value)
117 :param bbox: bounding box [minx,miny,maxx,maxy]
119 :returns: str, tuple
120 """
122 where_values = tuple()
123 where_clause = " WHERE " if (properties or bbox) else ""
124 if not where_clause:
125 return where_clause, where_values
127 if properties:
128 where_clause += " AND ".join(
129 [f"{k}=?" for k, v in properties])
130 where_values += where_values + tuple((v for k, v in properties))
132 if bbox: 132 ↛ 138line 132 didn't jump to line 138 because the condition on line 132 was always true
133 if properties:
134 where_clause += " AND "
135 where_clause += f" Intersects({self.geom_col}, BuildMbr(?,?,?,?)) "
136 where_values += tuple(bbox)
137 # WHERE continent=? <class 'tuple'>: ('Europe',)
138 return where_clause, where_values
140 def __response_feature(self, row_data, skip_geometry=False):
141 """
142 Assembles GeoJSON output from DB query
144 :param row_data: DB row result
145 :param skip_geometry: whether to skip geometry (default False)
147 :returns: `dict` of GeoJSON Feature
148 """
150 if row_data:
151 rd = dict(row_data) # sqlite3.Row is doesnt support pop
152 feature = {
153 'type': 'Feature',
154 'geometry': None
155 }
157 try:
158 if not skip_geometry:
159 feature['geometry'] = json.loads(
160 rd.pop(f'AsGeoJSON({self.geom_col})')
161 )
162 except TypeError:
163 LOGGER.warning('Missing geometry')
165 feature['properties'] = rd
166 feature['id'] = feature['properties'][self.id_field]
168 return feature
169 else:
170 return None
172 def __response_feature_hits(self, hits):
173 """Assembles GeoJSON/Feature number
175 :returns: GeoJSON FeaturesCollection
176 """
178 feature_collection = {"features": [],
179 "type": "FeatureCollection"}
180 feature_collection['numberMatched'] = hits
182 return feature_collection
184 def __load(self):
185 """
186 Private method for loading spatiallite,
187 get the table structure and dump geometry
189 :returns: sqlite3.Cursor
190 """
192 if (os.path.exists(self.data)): 192 ↛ 195line 192 didn't jump to line 195 because the condition on line 192 was always true
193 conn = sqlite3.connect(self.data)
194 else:
195 LOGGER.error('Path to sqlite does not exist')
196 raise InvalidPluginError()
198 try:
199 conn.enable_load_extension(True)
200 except AttributeError as err:
201 LOGGER.error(f'Extension loading not enabled: {err}')
202 raise ProviderConnectionError()
204 conn.row_factory = sqlite3.Row
205 conn.enable_load_extension(True)
206 # conn.set_trace_callback(LOGGER.debug)
207 cursor = conn.cursor()
208 try:
209 cursor.execute(f"SELECT load_extension('{SPATIALITE_EXTENSION}')")
210 except sqlite3.OperationalError as err:
211 LOGGER.error(f'Extension loading error: {err}')
212 raise ProviderConnectionError()
213 result = cursor.fetchall()
215 # Checking for geopackage
216 cursor.execute("PRAGMA application_id")
217 result = cursor.fetchone()
219 self.application_id = result["application_id"]
220 if self.application_id == 1196444487: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true
221 LOGGER.info("Detected GPKG 1.2 and greater")
222 elif self.application_id == 1196437808: 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
223 LOGGER.info("Detected GPKG 1.0 or 1.1")
224 else:
225 LOGGER.info("No GPKG detected assuming spatial sqlite3")
226 self.application_id = 0
228 if self.application_id: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 geometry_columns_table = 'gpkg_geometry_columns'
230 geometry_columns_table_name = 'table_name'
231 geometry_columns_column_name = 'column_name'
232 cursor.execute("SELECT AutoGPKGStart()")
233 result = cursor.fetchall()
234 if result[0][0] >= 1:
235 LOGGER.info("Loaded Geopackage support")
236 else:
237 LOGGER.info("SELECT AutoGPKGStart() returned 0." +
238 "Detected GPKG but couldn't load support")
239 raise InvalidPluginError()
240 else:
241 geometry_columns_table = 'geometry_columns'
242 geometry_columns_column_name = 'f_geometry_column'
243 geometry_columns_table_name = 'f_table_name'
245 try:
246 cursor.execute(f'PRAGMA table_info({self.table})')
247 result = cursor.fetchall()
248 except sqlite3.OperationalError:
249 LOGGER.error(f'Could not find table: {self.table}')
250 raise ProviderConnectionError()
252 LOGGER.debug('Determining name of geometry column')
253 cursor.execute(f"SELECT {geometry_columns_column_name} FROM {geometry_columns_table} WHERE {geometry_columns_table_name} = '{self.table}'") # noqa
254 geometry_column = cursor.fetchall()
256 if geometry_column: 256 ↛ 260line 256 didn't jump to line 260 because the condition on line 256 was always true
257 LOGGER.debug("Found geometry column")
258 self.geom_col = geometry_column[0][0]
259 else:
260 msg = 'No geometry column found'
261 LOGGER.error(msg)
262 raise ProviderConnectionError(msg)
264 try:
265 assert len(result), 'Table not found'
266 assert len([item for item in result
267 if self.id_field in item]), 'id_field not present'
268 except AssertionError:
269 raise InvalidPluginError()
271 self.columns = [item[1] for item in result if item[1]
272 not in [self.geom_col, self.geom_col.upper()]]
273 self.columns = ','.join(self.columns)+f',AsGeoJSON({self.geom_col})'
275 if self.application_id: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 self.table = f"vgpkg_{self.table}"
278 return cursor
280 @crs_transform
281 def query(self, offset=0, limit=10, resulttype='results',
282 bbox=[], datetime_=None, properties=[], sortby=[],
283 select_properties=[], skip_geometry=False, q=None, **kwargs):
284 """
285 Query SQLite/GPKG for all the content.
286 e,g: http://localhost:5000/collections/countries/items?
287 limit=5&offset=2&resulttype=results&continent=Europe&admin=Albania&bbox=29.3373,-3.4099,29.3761,-3.3924
288 http://localhost:5000/collections/countries/items?continent=Africa&bbox=29.3373,-3.4099,29.3761,-3.3924
290 :param offset: starting record to return (default 0)
291 :param limit: number of records to return (default 10)
292 :param resulttype: return results or hit limit (default results)
293 :param bbox: bounding box [minx,miny,maxx,maxy]
294 :param datetime_: temporal (datestamp or extent)
295 :param properties: list of tuples (name, value)
296 :param sortby: list of dicts (property, order)
297 :param select_properties: list of property names
298 :param skip_geometry: bool of whether to skip geometry (default False)
299 :param q: full-text search term(s)
301 :returns: GeoJSON FeaturesCollection
302 """
303 LOGGER.debug('Querying SQLite/GPKG')
305 where_clause, where_values = self.__get_where_clauses(
306 properties=properties, bbox=bbox)
308 if resulttype == 'hits': 308 ↛ 310line 308 didn't jump to line 310 because the condition on line 308 was never true
310 sql_query = f"SELECT COUNT(*) as hits FROM {self.table} {where_clause} " # noqa
312 res = self.cursor.execute(sql_query, where_values)
314 hits = res.fetchone()["hits"]
315 return self.__response_feature_hits(hits)
317 sql_query = f"SELECT DISTINCT {self.columns} from \
318 {self.table} {where_clause} limit ? offset ?"
320 end_index = offset + limit
322 LOGGER.debug(f'SQL Query: {sql_query}')
323 LOGGER.debug(f'Start Index: {offset}')
324 LOGGER.debug(f'End Index: {end_index}')
326 row_data = self.cursor.execute(
327 sql_query, where_values + (limit, offset))
329 feature_collection = {
330 'type': 'FeatureCollection',
331 'features': []
332 }
334 for rd in row_data:
335 feature_collection['features'].append(
336 self.__response_feature(rd, skip_geometry=skip_geometry))
338 return feature_collection
340 @crs_transform
341 def get(self, identifier, **kwargs):
342 """
343 Query the provider for a specific
344 feature id e.g: /collections/countries/items/1
346 :param identifier: feature id
348 :returns: dict of single GeoJSON feature
349 """
351 LOGGER.debug('Get item from SQLite/GPKG')
353 sql_query = f'SELECT {self.columns} FROM \
354 {self.table} WHERE {self.id_field}==?;'
356 LOGGER.debug(f'SQL Query: {sql_query}')
357 LOGGER.debug(f'Identifier: {identifier}')
359 row_data = self.cursor.execute(sql_query, (identifier, )).fetchone()
361 feature = self.__response_feature(row_data)
362 if feature:
363 return feature
364 else:
365 err = f'item {identifier} not found'
366 LOGGER.error(err)
367 raise ProviderItemNotFoundError(err)
369 def __repr__(self):
370 return f'<SQLiteGPKGProvider> {self.data}, {self.table}'