Skip to content

Commit 3eb37cf

Browse files
committed
Implement SQLAlchemy native MVT query
1 parent 2666907 commit 3eb37cf

File tree

1 file changed

+86
-98
lines changed

1 file changed

+86
-98
lines changed

pygeoapi/provider/mvt_postgresql.py

Lines changed: 86 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
# Authors: Prajwal Amaravati <prajwal.s@satsure.co>
44
# Tanvi Prasad <tanvi.prasad@cdpg.org.in>
55
# Bryan Robert <bryan.robert@cdpg.org.in>
6+
# Benjamin Webb <bwebb@lincolninst.edu>
67
#
78
# Copyright (c) 2025 Prajwal Amaravati
89
# Copyright (c) 2025 Tanvi Prasad
910
# Copyright (c) 2025 Bryan Robert
11+
# Copyright (c) 2025 Benjamin Webb
1012
#
1113
# Permission is hereby granted, free of charge, to any person
1214
# obtaining a copy of this software and associated documentation
@@ -34,8 +36,8 @@
3436
from copy import deepcopy
3537
import logging
3638

37-
from sqlalchemy.sql import text
38-
39+
from sqlalchemy.sql import func, select
40+
from sqlalchemy.orm import Session
3941
from pygeoapi.models.provider.base import (
4042
TileSetMetadata, TileMatrixSetEnum, LinkType)
4143
from pygeoapi.provider.base import ProviderConnectionError
@@ -46,8 +48,17 @@
4648

4749
LOGGER = logging.getLogger(__name__)
4850

51+
WEBMERCATORQUAD = TileMatrixSetEnum.WEBMERCATORQUAD.value
52+
WORLDCRS84QUAD = TileMatrixSetEnum.WORLDCRS84QUAD.value
53+
54+
CRS_CODES = {
55+
'http://www.opengis.net/def/crs/OGC/1.3/CRS84': 4326,
56+
'https://www.opengis.net/def/crs/OGC/0/CRS84': 4326,
57+
'http://www.opengis.net/def/crs/EPSG/0/3857': 3857
58+
}
59+
4960

50-
class MVTPostgreSQLProvider(BaseMVTProvider):
61+
class MVTPostgreSQLProvider(BaseMVTProvider, PostgreSQLProvider):
5162
"""
5263
MVT PostgreSQL Provider
5364
Provider for serving tiles rendered on-the-fly from
@@ -62,48 +73,31 @@ def __init__(self, provider_def):
6273
6374
:returns: pygeoapi.provider.MVT.MVTPostgreSQLProvider
6475
"""
65-
66-
super().__init__(provider_def)
67-
6876
pg_def = deepcopy(provider_def)
6977
# delete the zoom option before initializing the PostgreSQL provider
7078
# that provider breaks otherwise
71-
del pg_def["options"]["zoom"]
72-
self.postgres = PostgreSQLProvider(pg_def)
79+
del pg_def['options']['zoom']
80+
PostgreSQLProvider.__init__(self, pg_def)
81+
BaseMVTProvider.__init__(self, provider_def)
7382

74-
self.layer_name = provider_def["table"]
75-
self.table = provider_def['table']
76-
self.id_field = provider_def['id_field']
77-
self.geom = provider_def.get('geom_field', 'geom')
78-
79-
LOGGER.debug(f'DB connection: {repr(self.postgres._engine.url)}')
80-
81-
def __repr__(self):
82-
return f'<MVTPostgreSQLProvider> {self.data}'
83-
84-
@property
85-
def service_url(self):
86-
return self._service_url
83+
def get_fields(self):
84+
"""
85+
Get Postgrres fields
8786
88-
@property
89-
def service_metadata_url(self):
90-
return self._service_metadata_url
87+
:returns: `dict` of item fields
88+
"""
89+
PostgreSQLProvider.get_fields(self)
9190

9291
def get_layer(self):
9392
"""
9493
Extracts layer name from url
9594
9695
:returns: layer name
9796
"""
98-
99-
return self.layer_name
97+
return self.table
10098

10199
def get_tiling_schemes(self):
102-
103-
return [
104-
TileMatrixSetEnum.WEBMERCATORQUAD.value,
105-
TileMatrixSetEnum.WORLDCRS84QUAD.value
106-
]
100+
return [WEBMERCATORQUAD, WORLDCRS84QUAD]
107101

108102
def get_tiles_service(self, baseurl=None, servicepath=None,
109103
dirpath=None, tile_type=None):
@@ -118,13 +112,14 @@ def get_tiles_service(self, baseurl=None, servicepath=None,
118112
:returns: `dict` of item tile service
119113
"""
120114

121-
super().get_tiles_service(baseurl, servicepath,
122-
dirpath, tile_type)
115+
BaseMVTProvider.get_tiles_service(self,
116+
baseurl, servicepath,
117+
dirpath, tile_type)
123118

124119
self._service_url = servicepath
125120
return self.get_tms_links()
126121

127-
def get_tiles(self, layer=None, tileset=None,
122+
def get_tiles(self, layer='default', tileset=None,
128123
z=None, y=None, x=None, format_=None):
129124
"""
130125
Gets tile
@@ -141,64 +136,52 @@ def get_tiles(self, layer=None, tileset=None,
141136
if format_ == 'mvt':
142137
format_ = self.format_type
143138

144-
fields_arr = self.postgres.get_fields().keys()
145-
fields = ', '.join(['"' + f + '"' for f in fields_arr])
146-
if len(fields) != 0:
147-
fields = ',' + fields
148-
149-
query = ''
150-
if tileset == TileMatrixSetEnum.WEBMERCATORQUAD.value.tileMatrixSet:
151-
if not self.is_in_limits(TileMatrixSetEnum.WEBMERCATORQUAD.value, z, x, y): # noqa
152-
raise ProviderTileNotFoundError
153-
154-
query = text("""
155-
WITH
156-
bounds AS (
157-
SELECT ST_TileEnvelope(:z, :x, :y) AS boundgeom
158-
),
159-
mvtgeom AS (
160-
SELECT ST_AsMVTGeom(ST_Transform(ST_CurveToLine({geom}), 3857), bounds.boundgeom) AS geom {fields}
161-
FROM "{table}", bounds
162-
WHERE ST_Intersects({geom}, ST_Transform(bounds.boundgeom, 4326))
163-
)
164-
SELECT ST_AsMVT(mvtgeom, 'default') FROM mvtgeom;
165-
""".format(geom=self.geom, table=self.table, fields=fields)) # noqa
166-
167-
if tileset == TileMatrixSetEnum.WORLDCRS84QUAD.value.tileMatrixSet:
168-
if not self.is_in_limits(TileMatrixSetEnum.WORLDCRS84QUAD.value, z, x, y): # noqa
169-
raise ProviderTileNotFoundError
170-
171-
query = text("""
172-
WITH
173-
bounds AS (
174-
SELECT ST_TileEnvelope(:z, :x, :y,
175-
'SRID=4326;POLYGON((-180 -90,-180 90,180 90,180 -90,-180 -90))'::geometry) AS boundgeom
176-
),
177-
mvtgeom AS (
178-
SELECT ST_AsMVTGeom(ST_CurveToLine({geom}), bounds.boundgeom) AS geom {fields}
179-
FROM "{table}", bounds
180-
WHERE ST_Intersects({geom}, bounds.boundgeom)
181-
)
182-
SELECT ST_AsMVT(mvtgeom, 'default') FROM mvtgeom;
183-
""".format(geom=self.geom, table=self.table, fields=fields)) # noqa
184-
185-
with self.postgres._engine.connect() as session:
186-
result = session.execute(query, {
187-
'z': z,
188-
'y': y,
189-
'x': x
190-
}).fetchone()
191-
192-
if len(bytes(result[0])) == 0:
193-
return None
194-
return bytes(result[0])
139+
geom_column = getattr(self.table_model, self.geom)
140+
tile_envelope = func.ST_TileEnvelope(z, x, y)
141+
[tileset_schema] = [
142+
schema for schema in self.get_tiling_schemes()
143+
if tileset == schema.tileMatrixSet
144+
]
145+
146+
if not self.is_in_limits(tileset_schema, z, x, y):
147+
return ProviderTileNotFoundError
148+
149+
if tileset_schema.crs != self.storage_crs:
150+
LOGGER.debug('Transforming geometry')
151+
tile_envelope = func.ST_Transform(
152+
tile_envelope, CRS_CODES[tileset_schema.crs]
153+
)
154+
geom_column = func.ST_Transform(
155+
geom_column, CRS_CODES[tileset_schema.crs]
156+
)
157+
158+
all_columns = [
159+
c for c in self.table_model.__table__.columns if c.name != 'geom'
160+
]
161+
all_columns.append(
162+
func.ST_AsMVTGeom(geom_column, tile_envelope).label('mvt')
163+
)
164+
tile_query = select(
165+
func.ST_AsMVT(
166+
select(*all_columns)
167+
.select_from(self.table_model)
168+
.cte('tile')
169+
.table_valued(),
170+
layer
171+
)
172+
)
173+
174+
with Session(self._engine) as session:
175+
result = session.execute(tile_query).scalar()
176+
return bytes(result) or None
195177

196178
def get_html_metadata(self, dataset, server_url, layer, tileset,
197179
title, description, keywords, **kwargs):
198180

199181
service_url = url_join(
200182
server_url,
201-
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
183+
f'collections/{dataset}/tiles/{tileset}'
184+
'{tileMatrix}/{tileRow}/{tileCol}?f=mvt')
202185
metadata_url = url_join(
203186
server_url,
204187
f'collections/{dataset}/tiles/{tileset}/metadata')
@@ -217,9 +200,10 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
217200

218201
service_url = url_join(
219202
server_url,
220-
f'collections/{dataset}/tiles/{tileset}/{{tileMatrix}}/{{tileRow}}/{{tileCol}}?f=mvt') # noqa
203+
f'collections/{dataset}/tiles/{tileset}',
204+
'{tileMatrix}/{tileRow}/{tileCol}?f=mvt'
205+
)
221206

222-
content = {}
223207
tiling_schemes = self.get_tiling_schemes()
224208
# Default values
225209
tileMatrixSetURI = tiling_schemes[0].tileMatrixSetURI
@@ -231,17 +215,18 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
231215
tileMatrixSetURI = schema.tileMatrixSetURI
232216

233217
tiling_scheme_url = url_join(
234-
server_url, f'/TileMatrixSets/{schema.tileMatrixSet}')
235-
tiling_scheme_url_type = "application/json"
218+
server_url, 'TileMatrixSets', schema.tileMatrixSet)
219+
tiling_scheme_url_type = 'application/json'
236220
tiling_scheme_url_title = f'{schema.tileMatrixSet} tile matrix set definition' # noqa
237221

238-
tiling_scheme = LinkType(href=tiling_scheme_url,
239-
rel="http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme", # noqa
240-
type_=tiling_scheme_url_type,
241-
title=tiling_scheme_url_title)
222+
tiling_scheme = LinkType(
223+
href=tiling_scheme_url,
224+
rel='http://www.opengis.net/def/rel/ogc/1.0/tiling-scheme',
225+
type_=tiling_scheme_url_type,
226+
title=tiling_scheme_url_title)
242227

243228
if tiling_scheme is None:
244-
msg = f'Could not identify a valid tiling schema' # noqa
229+
msg = 'Could not identify a valid tiling schema'
245230
LOGGER.error(msg)
246231
raise ProviderConnectionError(msg)
247232

@@ -250,9 +235,9 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
250235
tileMatrixSetURI=tileMatrixSetURI)
251236

252237
links = []
253-
service_url_link_type = "application/vnd.mapbox-vector-tile"
238+
service_url_link_type = 'application/vnd.mapbox-vector-tile'
254239
service_url_link_title = f'{tileset} vector tiles for {layer}'
255-
service_url_link = LinkType(href=service_url, rel="item",
240+
service_url_link = LinkType(href=service_url, rel='item',
256241
type_=service_url_link_type,
257242
title=service_url_link_title)
258243

@@ -261,4 +246,7 @@ def get_default_metadata(self, dataset, server_url, layer, tileset,
261246

262247
content.links = links
263248

264-
return content.dict(exclude_none=True)
249+
return content.model_dump(exclude_none=True)
250+
251+
def __repr__(self):
252+
return f'<MVTPostgreSQLProvider> {self.data}'

0 commit comments

Comments
 (0)