Skip to content

Commit cf366d9

Browse files
committed
fix: respect SRSNAME in PostGIS WFS path and accept WFS 1.1.0 BBOX format
Make the PostGIS direct WFS path honour the SRSNAME parameter: geometries and bounding boxes are now transformed to the requested output CRS instead of being hardcoded to EPSG:4326. Also parse the WFS 1.1.0 five-element BBOX (x,y,x,y,EPSG:XXXX) so the spatial filter works for cross-CRS layers. Includes unit tests, e2e tests, and a dedicated QGIS test project with an EPSG:2154 snap layer to exercise datum-shift reprojection.
1 parent 0a9a855 commit cf366d9

File tree

11 files changed

+1880
-26
lines changed

11 files changed

+1880
-26
lines changed

.github/workflows/e2e_tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
env:
5454
ACTIONS_STEP_DEBUG: true
5555
with:
56-
username: "3liz"
56+
username: "meyerlor"
5757
password: ${{ secrets.DOCKERHUB_TOKEN }}
5858

5959
- name: Checkout

lizmap/modules/lizmap/lib/Request/WFSRequest.php

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -746,34 +746,40 @@ protected function getBboxSql(array $params): string
746746
}
747747

748748
// Check the BBOX parameter
749-
// It has to contain 4 numeric separated by comma
749+
// WFS 1.0.0 sends 4 numeric values; WFS 1.1.0 appends the CRS as a 5th element
750+
// e.g. "xmin,ymin,xmax,ymax,EPSG:3857"
750751
$bboxitem = explode(',', $bbox);
751-
if (count($bboxitem) !== 4) {
752-
// BBOX parameter does not contain 4 elements
752+
$bboxCount = count($bboxitem);
753+
if ($bboxCount !== 4 && $bboxCount !== 5) {
754+
// BBOX parameter does not contain 4 or 5 elements
753755
return '';
754756
}
755757

756-
// Check numeric elements
757-
foreach ($bboxitem as $coord) {
758-
if (!is_numeric(trim($coord))) {
758+
// Check the 4 coordinate elements are numeric
759+
for ($i = 0; $i < 4; $i++) {
760+
if (!is_numeric(trim($bboxitem[$i]))) {
759761
return '';
760762
}
761763
}
762764

763765
$layerSrid = $this->qgisLayer->getSrid();
764766
$srid = $this->qgisLayer->getSrid();
765-
if (array_key_exists('srsname', $params)) {
767+
768+
// CRS priority: explicit SRSNAME param > 5th BBOX element
769+
$srsname = null;
770+
if (array_key_exists('srsname', $params) && !empty($params['srsname'])) {
766771
$srsname = $params['srsname'];
767-
if (!empty($srsname)) {
768-
// SRSNAME parameter is not empty
769-
// extracting srid
770-
$exp_srsname = explode(':', $srsname);
771-
$srsname_id = end($exp_srsname);
772-
if (ctype_digit($srsname_id)) {
773-
$srid = intval($srsname_id);
774-
} else {
775-
return '';
776-
}
772+
} elseif ($bboxCount === 5) {
773+
$srsname = trim($bboxitem[4]);
774+
}
775+
776+
if ($srsname !== null) {
777+
$exp_srsname = explode(':', $srsname);
778+
$srsname_id = end($exp_srsname);
779+
if (ctype_digit($srsname_id)) {
780+
$srid = intval($srsname_id);
781+
} else {
782+
return '';
777783
}
778784
}
779785

@@ -1023,6 +1029,16 @@ protected function getfeaturePostgres(): OGCResponse
10231029
$geometryname = strtolower($params['geometryname']);
10241030
}
10251031

1032+
// Determine the output SRID from SRSNAME, defaulting to 4326
1033+
$outputSrid = 4326;
1034+
if (array_key_exists('srsname', $params) && !empty($params['srsname'])) {
1035+
$exp_srsname = explode(':', $params['srsname']);
1036+
$srsname_id = end($exp_srsname);
1037+
if (ctype_digit($srsname_id)) {
1038+
$outputSrid = intval($srsname_id);
1039+
}
1040+
}
1041+
10261042
// For getFeature with parameter RESULTTYPE=hits
10271043
// We should just return the number of features
10281044
if ($isHitsRequest) {
@@ -1031,7 +1047,7 @@ protected function getfeaturePostgres(): OGCResponse
10311047
// Else we should return all corresponding features
10321048
// $this->appContext->logMessage($sql);
10331049
// Use PostgreSQL method to export geojson
1034-
$sql = $this->setGeojsonSql($sql, $cnx, $typename, $geometryname);
1050+
$sql = $this->setGeojsonSql($sql, $cnx, $typename, $geometryname, $outputSrid);
10351051
}
10361052

10371053
// $this->appContext->logMessage($sql);
@@ -1124,7 +1140,7 @@ protected function validateFilter(string $filter): false|string
11241140
* @param mixed $typename
11251141
* @param mixed $geometryname
11261142
*/
1127-
private function setGeojsonSql($sql, $cnx, $typename, $geometryname): string
1143+
protected function setGeojsonSql($sql, $cnx, $typename, $geometryname, int $outputSrid = 4326): string
11281144
{
11291145
$sql = '
11301146
WITH source AS (
@@ -1179,10 +1195,9 @@ private function setGeojsonSql($sql, $cnx, $typename, $geometryname): string
11791195
if (!empty($geosql)) {
11801196
// Define BBOX SQL
11811197
$bboxsql = 'ST_Envelope(lg.geosource::geometry)';
1182-
// For new QGIS versions, export into EPSG:4326
1183-
$lizservices = $this->services;
1184-
$geosql = 'ST_Transform('.$geosql.', 4326)';
1185-
$bboxsql = 'ST_Transform('.$bboxsql.', 4326)';
1198+
// Export geometry in the requested output CRS (defaults to EPSG:4326)
1199+
$geosql = 'ST_Transform('.$geosql.', '.$outputSrid.')';
1200+
$bboxsql = 'ST_Transform('.$bboxsql.', '.$outputSrid.')';
11861201

11871202
// Transform BBOX into JSON
11881203
$sql .= '

tests/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ SHELL:=bash
66
.ONESHELL:
77
.PHONY: env
88

9-
export LZMBRANCH ?= $(shell git rev-parse --abbrev-ref HEAD | tr '[:upper:]' '[:lower:]')
9+
export LZMBRANCH ?= $(shell git rev-parse --abbrev-ref HEAD | tr '[:upper:]/' '[:lower:]-')
1010

1111
PREFIX:=lizmap-$(LZMBRANCH)-tests
1212

tests/end2end/playwright/snap.spec.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,112 @@ test.describe('Snap on edition', () => {
5656
expect(describeFeatureTypeSent).toBeFalsy();
5757
});
5858

59+
test('Snap WFS GetFeature uses map projection SRSNAME for cross-CRS layer', async ({ page }, testInfo) => {
60+
// Project is in EPSG:3857; snap target is stored in EPSG:2154 (Lambert-93).
61+
// The WFS request must ask for features in the MAP projection (SRSNAME=EPSG:3857)
62+
// and include a 5-element BBOX so the server can apply the spatial filter correctly.
63+
const project = new ProjectPage(page, 'form_edition_snap_datum_shift');
64+
await project.open();
65+
66+
const snapWfsRequestPromise = page.waitForRequest(
67+
request => request.method() === 'POST'
68+
&& request.postData()?.includes('GetFeature') === true
69+
&& request.postData()?.includes('snap_datum_shift_target') === true
70+
);
71+
72+
await project.openEditingFormWithLayer('snap_datum_shift_edit');
73+
await page.getByRole('tab', { name: 'Digitization' }).click();
74+
75+
const snapWfsRequest = await snapWfsRequestPromise;
76+
const rawPostData = snapWfsRequest.postData() ?? '';
77+
const postData = new URLSearchParams(rawPostData);
78+
79+
// Attach the full POST body to the test report for easy inspection on failure.
80+
await testInfo.attach('snap-wfs-request-post-data', {
81+
body: rawPostData,
82+
contentType: 'application/x-www-form-urlencoded',
83+
});
84+
85+
console.log('[snap datum-shift] WFS request parameters:');
86+
for (const [key, value] of postData.entries()) {
87+
console.log(` ${key} = ${value}`);
88+
}
89+
90+
requestExpect(snapWfsRequest).toContainParametersInPostData({
91+
SERVICE: 'WFS',
92+
VERSION: '1.1.0',
93+
REQUEST: 'GetFeature',
94+
TYPENAME: 'snap_datum_shift_target',
95+
SRSNAME: 'EPSG:3857',
96+
});
97+
98+
// BBOX must be the 5-element WFS 1.1.0 format ending with the CRS code.
99+
const bbox = postData.get('BBOX') ?? '';
100+
console.log('[snap datum-shift] BBOX value:', bbox);
101+
expect(bbox, `BBOX must be 5-element WFS 1.1.0 format (x,y,x,y,EPSG:3857), got: "${bbox}"`
102+
).toMatch(/^-?[\d.]+,-?[\d.]+,-?[\d.]+,-?[\d.]+,EPSG:3857$/);
103+
});
104+
105+
test('Snap features from EPSG:2154 layer arrive in EPSG:3857 coordinates', async ({ page }, testInfo) => {
106+
// The PostGIS WFS path must transform geometries into the requested output CRS
107+
// (SRSNAME=EPSG:3857). Without the fix, coordinates would come back in EPSG:4326
108+
// (~3.86–3.92 / 43.61–43.64) or the layer native CRS EPSG:2154 (~769000–774000 /
109+
// 6280000–6283000), both obviously outside the valid EPSG:3857 range for this area
110+
// (~430000–435000 / 5406000–5408000).
111+
const project = new ProjectPage(page, 'form_edition_snap_datum_shift');
112+
await project.open();
113+
114+
const snapWfsResponsePromise = page.waitForResponse(
115+
response => response.request().method() === 'POST'
116+
&& response.request().postData()?.includes('GetFeature') === true
117+
&& response.request().postData()?.includes('snap_datum_shift_target') === true
118+
);
119+
120+
await project.openEditingFormWithLayer('snap_datum_shift_edit');
121+
await page.getByRole('tab', { name: 'Digitization' }).click();
122+
123+
const snapWfsResponse = await snapWfsResponsePromise;
124+
const responseBody = await snapWfsResponse.text();
125+
126+
// Attach the full server response to the test report.
127+
await testInfo.attach('snap-wfs-response-body', {
128+
body: responseBody,
129+
contentType: 'application/json',
130+
});
131+
132+
const geojson = JSON.parse(responseBody);
133+
134+
console.log('[snap datum-shift] WFS response status:', snapWfsResponse.status());
135+
console.log('[snap datum-shift] Feature count:', geojson.features?.length ?? 0);
136+
137+
expect(geojson.features, 'Response must contain a features array').toBeDefined();
138+
expect(geojson.features.length, 'At least one snap target feature must be returned').toBeGreaterThan(0);
139+
140+
for (const feature of geojson.features) {
141+
const [x, y] = feature.geometry.coordinates;
142+
143+
console.log(`[snap datum-shift] Feature id=${feature.id} coords: x=${x.toFixed(2)}, y=${y.toFixed(2)}`);
144+
145+
// Diagnose likely failure modes in the message for faster debugging:
146+
// 4326 → x ≈ 3.86, y ≈ 43.6
147+
// 2154 → x ≈ 769 000, y ≈ 6 280 000
148+
// 3857 → x ≈ 431 000, y ≈ 5 407 000 ✓
149+
const hint = x < 10
150+
? `looks like EPSG:4326 (lon/lat) — server did not transform to 3857`
151+
: x > 500000
152+
? `looks like EPSG:2154 (Lambert-93) — server returned native CRS instead of 3857`
153+
: '';
154+
expect(x, `x=${x.toFixed(2)} out of EPSG:3857 range for this area [420000–445000]${hint ? ' — ' + hint : ''}`
155+
).toBeGreaterThan(420000);
156+
expect(x, `x=${x.toFixed(2)} out of EPSG:3857 range for this area [420000–445000]${hint ? ' — ' + hint : ''}`
157+
).toBeLessThan(445000);
158+
expect(y, `y=${y.toFixed(2)} out of EPSG:3857 range for this area [5390000–5420000]`
159+
).toBeGreaterThan(5390000);
160+
expect(y, `y=${y.toFixed(2)} out of EPSG:3857 range for this area [5390000–5420000]`
161+
).toBeLessThan(5420000);
162+
}
163+
});
164+
59165
test('Snap panel functionalities', async ({ page }) => {
60166
const project = new ProjectPage(page, 'form_edition_multilayer_snap');
61167

0 commit comments

Comments
 (0)