Skip to content

Commit c2095e7

Browse files
committed
Implement SQLAlchemy native MVT query
Fix typo Rename base tile provider Fix Tile HTML highlighting Fix missing tile rename Update CRS conversion Change fallback for vector tile id Undo rename tile provider Reduce diff Cleanup mvt-postgres provider Re-add ST_CurveToLine 97ba024
1 parent 8346115 commit c2095e7

File tree

5 files changed

+91
-107
lines changed

5 files changed

+91
-107
lines changed

docs/source/data-publishing/ogcapi-tiles.rst

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,6 @@ MVT-postgresql
140140
.. note::
141141
Must have PostGIS installed with protobuf-c support
142142

143-
.. note::
144-
Geometry must be using EPSG:4326
145-
146143
This provider gives support to serving tiles generated using `PostgreSQL <https://www.postgresql.org/>`_ with `PostGIS <https://postgis.net/>`_.
147144
The tiles are rendered on-the-fly using `ST_AsMVT <https://postgis.net/docs/ST_AsMVT.html>`_ and related methods.
148145

pygeoapi/api/tiles.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def get_collection_tiles_data(
246246
if content is None:
247247
msg = 'identifier not found'
248248
return api.get_exception(
249-
HTTPStatus.NO_CONTENT, headers, format_, 'NocContent', msg)
249+
HTTPStatus.NO_CONTENT, headers, format_, 'NoContent', msg)
250250
else:
251251
return headers, HTTPStatus.OK, content
252252

pygeoapi/provider/mvt_postgresql.py

Lines changed: 88 additions & 100 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,20 +36,20 @@
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
4244
from pygeoapi.provider.base_mvt import BaseMVTProvider
4345
from pygeoapi.provider.sql import PostgreSQLProvider
4446
from pygeoapi.provider.tile import ProviderTileNotFoundError
45-
from pygeoapi.util import url_join
47+
from pygeoapi.util import url_join, get_crs_from_uri
4648

4749
LOGGER = logging.getLogger(__name__)
4850

4951

50-
class MVTPostgreSQLProvider(BaseMVTProvider):
52+
class MVTPostgreSQLProvider(PostgreSQLProvider, BaseMVTProvider):
5153
"""
5254
MVT PostgreSQL Provider
5355
Provider for serving tiles rendered on-the-fly from
@@ -62,48 +64,37 @@ def __init__(self, provider_def):
6264
6365
:returns: pygeoapi.provider.MVT.MVTPostgreSQLProvider
6466
"""
65-
66-
super().__init__(provider_def)
67-
6867
pg_def = deepcopy(provider_def)
6968
# delete the zoom option before initializing the PostgreSQL provider
7069
# that provider breaks otherwise
71-
del pg_def["options"]["zoom"]
72-
self.postgres = PostgreSQLProvider(pg_def)
73-
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}'
70+
del pg_def['options']['zoom']
71+
PostgreSQLProvider.__init__(self, pg_def)
72+
BaseMVTProvider.__init__(self, provider_def)
8373

84-
@property
85-
def service_url(self):
86-
return self._service_url
74+
def get_fields(self):
75+
"""
76+
Get Postgres columns
8777
88-
@property
89-
def service_metadata_url(self):
90-
return self._service_metadata_url
78+
:returns: `list` of columns
79+
"""
80+
return [
81+
column for column in self.table_model.__table__.columns
82+
if column.name != self.geom
83+
]
9184

9285
def get_layer(self):
9386
"""
94-
Extracts layer name from url
87+
Use table name as layer name
9588
96-
:returns: layer name
89+
:returns: `str` of layer name
9790
"""
98-
99-
return self.layer_name
91+
return self.table
10092

10193
def get_tiling_schemes(self):
102-
10394
return [
104-
TileMatrixSetEnum.WEBMERCATORQUAD.value,
105-
TileMatrixSetEnum.WORLDCRS84QUAD.value
106-
]
95+
TileMatrixSetEnum.WEBMERCATORQUAD.value,
96+
TileMatrixSetEnum.WORLDCRS84QUAD.value
97+
]
10798

10899
def get_tiles_service(self, baseurl=None, servicepath=None,
109100
dirpath=None, tile_type=None):
@@ -118,13 +109,14 @@ def get_tiles_service(self, baseurl=None, servicepath=None,
118109
:returns: `dict` of item tile service
119110
"""
120111

121-
super().get_tiles_service(baseurl, servicepath,
122-
dirpath, tile_type)
112+
BaseMVTProvider.get_tiles_service(self,
113+
baseurl, servicepath,
114+
dirpath, tile_type)
123115

124116
self._service_url = servicepath
125117
return self.get_tms_links()
126118

127-
def get_tiles(self, layer=None, tileset=None,
119+
def get_tiles(self, layer='default', tileset=None,
128120
z=None, y=None, x=None, format_=None):
129121
"""
130122
Gets tile
@@ -141,64 +133,55 @@ def get_tiles(self, layer=None, tileset=None,
141133
if format_ == 'mvt':
142134
format_ = self.format_type
143135

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])
136+
[tileset_schema] = [
137+
schema for schema in self.get_tiling_schemes()
138+
if tileset == schema.tileMatrixSet
139+
]
140+
if not self.is_in_limits(tileset_schema, z, x, y):
141+
LOGGER.warning(f'Tile {z}/{x}/{y} not found')
142+
return ProviderTileNotFoundError
143+
144+
storage_srid = get_crs_from_uri(self.storage_crs).to_string()
145+
out_srid = get_crs_from_uri(tileset_schema.crs).to_string()
146+
147+
tile_envelope = func.ST_TileEnvelope(z, x, y)
148+
envelope = (
149+
select(tile_envelope.label('bounds'))
150+
.cte('envelope')
151+
)
152+
153+
geom_column = getattr(self.table_model, self.geom)
154+
mvtgeom = (
155+
func.ST_AsMVTGeom(
156+
func.ST_Transform(geom_column, out_srid),
157+
func.ST_Transform(envelope.c.bounds, out_srid))
158+
.label('mvtgeom')
159+
)
160+
161+
geom_filter = geom_column.intersects(
162+
func.ST_Transform(tile_envelope, storage_srid))
163+
mvtrow = (
164+
select(*self.get_fields(), mvtgeom)
165+
.filter(geom_filter)
166+
.select_from(self.table_model)
167+
.cte('mvtrow')
168+
.table_valued()
169+
)
170+
171+
mvt_query = select(func.ST_AsMVT(mvtrow, layer))
172+
173+
with Session(self._engine) as session:
174+
result = session.execute(mvt_query).scalar()
175+
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}'

pygeoapi/provider/tile.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ def __init__(self, provider_def):
5656
self.mimetype = provider_def['format']['mimetype']
5757
self.options = provider_def.get('options')
5858
self.tile_type = None
59-
self.fields = {}
6059

6160
def get_layer(self):
6261
"""

pygeoapi/templates/collections/tiles/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ <h3>Tiles</h3>
108108
maxZoom: {{ data['maxzoom'] }},
109109
indexMaxZoom: 5,
110110
getFeatureId: function(feat) {
111-
return feat.properties["id"]
111+
return feat.properties.id || feat.properties.fid || feat.properties.uri;
112112
}
113113
};
114114

@@ -129,7 +129,7 @@ <h3>Tiles</h3>
129129
.openOn(map);
130130

131131
clearHighlight();
132-
highlight = e.layer.properties.id;
132+
highlight = e.layer.properties.id || e.layer.properties.fid || e.layer.properties.uri;
133133
tilesPbfLayer.setFeatureStyle(highlight, {
134134
weight: 2,
135135
color: 'red',

0 commit comments

Comments
 (0)