diff --git a/src/getfeature.js b/src/getfeature.js index 489f85351..d51bb92a5 100644 --- a/src/getfeature.js +++ b/src/getfeature.js @@ -7,7 +7,7 @@ const sourceType = {}; /** * Fetches features from a layer's source but does not add them to the layer. Supports WFS and AGS_FEATURE altough functionality differs. Mainly used by search, but - * is also exposed as an api function that MultiSelect uses. As q quirky bonus it also support fetching features from WMS layers if there is a WFS service at the same endpoint. + * is also exposed as an api function that MultiSelect uses. As a quirky bonus it also supports fetching features from WMS layers if there is a WFS service at the same endpoint. * * @param {any} id Comma separated list of ids. If specified layer's filter and parameter extent is ignored (even configured map and layer extent is ignored). * @param {any} layer Layer instance to fetch from diff --git a/src/layer/wfs.js b/src/layer/wfs.js index 6ee216f14..e75f7e5bb 100644 --- a/src/layer/wfs.js +++ b/src/layer/wfs.js @@ -7,7 +7,9 @@ export default function wfs(layerOptions, viewer) { const wfsDefault = { layerType: 'vector' }; - const sourceDefault = {}; + const sourceDefault = { + filterType: 'cql' + }; const wfsOptions = Object.assign({}, wfsDefault, layerOptions); const sourceOptions = Object.assign({}, sourceDefault, viewer.getMapSource()[layerOptions.sourceName]); sourceOptions.featureType = wfsOptions.id; diff --git a/src/layer/wfssource.js b/src/layer/wfssource.js index f4cacf327..14a287006 100644 --- a/src/layer/wfssource.js +++ b/src/layer/wfssource.js @@ -76,10 +76,10 @@ class WfsSource extends VectorSource { /** * Set filter on source - * @param {any} cql + * @param {any} filter */ - setFilter(cql) { - this._options.filter = cql; + setFilter(filter) { + this._options.filter = filter; this.refresh(); } @@ -92,55 +92,135 @@ class WfsSource extends VectorSource { } /** - * Helper to reuse code. Consider it to be private to this class. - * @param {any} extent Extent to query. If specified the result is limited to the intersection of this parameter and layer's extent configuration. - * @param {any} cql Optional extra cql for this call. - * @param {any} ignoreOriginalFilter true if configured filter should be ignored for this call making parameter cql only filter (if specified) - * @param {any} ids Comma separated list of feature ids. If specified you probably want to call with extent and cql empty and ignoreOriginalFilter = true + * Generate a wfs query filter according to the wfs layer source's `filterType` (`cql`|`qgis`). + * If specified, an extra filter is combined with any layer filter already present. + * Consider this function to be private to this class. + * @param {any} extent Extent to query. If specified the result is limited to the intersection of this parameter and layer's extent configuration + * @param {any} extraFilter Optional extra filter for this call with syntax matching the source's `filterType` (`cql`|`qgis`). Will be combined with any configured layer filter unless ignoreOriginalFilter is true + * @param {any} ignoreOriginalFilter true if configured layer filter should be ignored for this call, making parameter extraFilter the only filter (if specified) */ - async _loaderHelper(extent, cql, ignoreOriginalFilter, ids) { - const serverUrl = this._options.url; - - // Set up the cql filter as a combination of the layer filter and the temporary cql parameter - let cqlfilter = ''; + _createQueryFilter(extent, extraFilter, ignoreOriginalFilter) { + let layerFilter = ''; + let queryFilter = ''; + // Add layer filter unless `ignoreOriginalFilter` is set if (this._options.filter && !ignoreOriginalFilter) { - cqlfilter = replacer.replace(this._options.filter, window); - if (cql) { - cqlfilter += ' AND '; - } - } - if (cql) { - cqlfilter += `${replacer.replace(cql, window)}`; + layerFilter = replacer.replace(this._options.filter, window); } - // Create the complete CQL query string - let queryFilter = ''; - if (!extent || this._options.isTable) { - queryFilter = cqlfilter ? `&CQL_FILTER=${cqlfilter}` : ''; - } else { - // Extent should be used. Depending if there also is a filter, the queryfilter looks different + // Prepare extent if used + let requestExtent; + if (extent && !this._options.isTable) { + // Combine the layer's extent with the extent used for this function call const ext = getIntersection(this._options.customExtent, extent) || extent; - let requestExtent; + // Reproject it if the layer's data projection differs from that of the map if (this._options.dataProjection !== this._options.projectionCode) { requestExtent = transformExtent(ext, this._options.projectionCode, this._options.dataProjection); } else { requestExtent = ext; } - if (cqlfilter) { - queryFilter = `&CQL_FILTER=${cqlfilter} AND BBOX(${this._options.geometryName},${requestExtent.join(',')},'${this._options.dataProjection}')`; - } else { + // If extent is used but no filters are set, just return the BBOX parameter. + if (!layerFilter && !extraFilter) { queryFilter = `&BBOX=${requestExtent.join(',')},${this._options.dataProjection}`; + return queryFilter; + } + } + + // Integrate provided layer filters, `extraFilter` and extent into a single query filter + // Both QGIS and GeoServer treats the WFS parameters `BBOX` and `CQL_FILTER`/`EXP_FILTER` as mutually exclusive, + // so instead of BBOX we use the vendor filters also for the extent filtering. + switch (this._options.filterType) { + case 'cql': { + // Set up the filter as a combination of the layer filter and the extraFilter parameter + let cqlfilter = layerFilter; + if (layerFilter && extraFilter) { + cqlfilter += ' AND '; + } + if (extraFilter) { + cqlfilter += `${replacer.replace(extraFilter, window)}`; + } + + // If using extent, and the layer is not a geometryless table, integrate it into the query filter + if (extent && !this._options.isTable) { + if (cqlfilter) { + cqlfilter += ' AND '; + } + cqlfilter += `BBOX(${this._options.geometryName},${requestExtent.join(',')},'${this._options.dataProjection}')`; + } + + // Create the complete CQL query string + if (cqlfilter) { + queryFilter = `&CQL_FILTER=${cqlfilter}`; + } + break; + } + case 'qgis': { + // Set up the filter as a combination of the layer filter and the extraFilter parameter + let qgisFilter = layerFilter; + if (layerFilter && extraFilter) { + qgisFilter += ' AND '; + } + if (extraFilter) { + qgisFilter += `${replacer.replace(extraFilter, window)}`; + } + + // If using extent, and the layer is not a geometryless table, integrate it into the query filter + if (extent && !this._options.isTable) { + if (qgisFilter) { + qgisFilter += ' AND '; + } + const wktBbox = `POLYGON ((${requestExtent[0]} ${requestExtent[3]},${requestExtent[2]} ${requestExtent[3]},${requestExtent[2]} ${requestExtent[1]},${requestExtent[0]} ${requestExtent[1]},${requestExtent[0]} ${requestExtent[3]}))`; + qgisFilter += `intersects_bbox(@geometry,geom_from_wkt('${wktBbox},${this._options.dataProjection}'))`; + } + + // Create the complete QGIS EXP_FILTER query string + if (qgisFilter) { + queryFilter = `&EXP_FILTER=${qgisFilter}`; + } + break; } + default: break; } + return queryFilter; + } + + /** + * Helper to reuse code. Consider it to be private to this class. + * @param {any} extent Extent to query. If specified the result is limited to the intersection of this parameter and layer's extent configuration + * @param {any} extraFilter Optional extra filter for this call with syntax matching the source's `filterType` (`cql`|`qgis`) + * @param {any} ignoreOriginalFilter true if configured layer filter should be ignored for this call making parameter `extraFilter` the only filter (if specified) + * @param {any} ids Comma separated list of feature ids. If specified, extent and other filters will be ignored. + */ + async _loaderHelper(extent, extraFilter, ignoreOriginalFilter, ids) { + const serverUrl = this._options.url; // Create the complete URL // FIXME: rewrite using URL class let url = [`${serverUrl}${serverUrl.indexOf('?') < 0 ? '?' : '&'}service=WFS`, `&version=1.1.0&request=GetFeature&typeName=${this._options.featureType}&outputFormat=application/json`, `&srsname=${this._options.dataProjection}`].join(''); - url += queryFilter; + // Add FeatureId parameter if there are ids requested. + // FeatureId is incompatible with BBOX and CQL_FILTER (will override QGIS's EXP_FILTER) so they should not be used together. + // QGIS Server expects feature type name to be prepended while GeoServer handles both with and without. if (ids || ids === 0) { - url += `&FeatureId=${ids}`; + switch (this._options.filterType) { + case 'qgis': { + const idArray = ids.toString().split(','); // Split to array + idArray.map(id => { + // Prepend the layername using id if needed (in case the name is using double underscore notation) + if (!id.toString().startsWith(`${this._options.featureType}.`)) { + return `${this._options.featureType}.${id}`; + } + return id; + }); // Join to a comma separated string again + url += `&FeatureId=${idArray.join(',')}`; + break; + } + default: { + url += `&FeatureId=${ids}`; + } + } + } else { // If there are no ids requested, append the query filter + url += this._createQueryFilter(extent, extraFilter, ignoreOriginalFilter); } url = encodeURI(url); @@ -175,12 +255,12 @@ class WfsSource extends VectorSource { } /** - * Makes a call to the server with the provided cql filter and adds all matching records to the layer. + * Makes a call to the server with the provided query filter and adds all matching records to the layer. * If the layer has a filter it is honoured. - * @param {any} cql + * @param {any} filter */ - async ensureLoaded(cql) { - const features = await this._loaderHelper(null, cql, false); + async ensureLoaded(filter) { + const features = await this._loaderHelper(null, filter, false); super.addFeatures(features); } @@ -195,11 +275,11 @@ class WfsSource extends VectorSource { /** * Fetches features from server without adding them to the source. Honors filter configuration unless ignoreOriginalFilter is specified. * @param {any} extent Optional extent - * @param {any} cql Optional additional cql filter for this call - * @param {any} ignoreOriginalFilter true if configured cql filter should be ignored for this request + * @param {any} filter Optional additional filter for this call with syntax matching the source's `filterType` (`cql`|`qgis`) + * @param {any} ignoreOriginalFilter true if configured layer filter should be ignored for this request */ - async getFeaturesFromSource(extent, cql, ignoreOriginalFilter) { - return this._loaderHelper(extent, cql, ignoreOriginalFilter); + async getFeaturesFromSource(extent, filter, ignoreOriginalFilter) { + return this._loaderHelper(extent, filter, ignoreOriginalFilter); } }