diff --git a/lib/api/map/dataview-layergroup-controller.js b/lib/api/map/dataview-layergroup-controller.js index 446bf8a3..8f849565 100644 --- a/lib/api/map/dataview-layergroup-controller.js +++ b/lib/api/map/dataview-layergroup-controller.js @@ -19,6 +19,7 @@ const ALLOWED_DATAVIEW_QUERY_PARAMS = [ 'no_filters', // 0, 1 'bbox', // w,s,e,n 'circle', // json + 'polygon', // json 'start', // number 'end', // number 'column_type', // string diff --git a/lib/backends/dataview.js b/lib/backends/dataview.js index 555f8e79..f3d05f73 100644 --- a/lib/backends/dataview.js +++ b/lib/backends/dataview.js @@ -4,6 +4,7 @@ var _ = require('underscore'); var PSQL = require('cartodb-psql'); var BBoxFilter = require('../models/filter/bbox'); const CircleFilter = require('../models/filter/circle'); +const PolygonFilter = require('../models/filter/polygon'); var DataviewFactory = require('../models/dataview/factory'); var DataviewFactoryWithOverviews = require('../models/dataview/overviews/factory'); const dbParamsFromReqParams = require('../utils/database-params'); @@ -90,6 +91,9 @@ function getQueryWithFilters (dataviewDefinition, params) { } else if (params.circle) { const circleFilter = new CircleFilter({ column: 'the_geom_webmercator', srid: 3857 }, { circle: params.circle }); query = circleFilter.sql(query); + } else if (params.polygon) { + const polygonFilter = new PolygonFilter({ column: 'the_geom_webmercator', srid: 3857 }, { polygon: params.polygon }); + query = polygonFilter.sql(query); } return query; @@ -204,6 +208,9 @@ function getQueryWithOwnFilters (dataviewDefinition, params) { } else if (params.circle) { const circleFilter = new CircleFilter({ column: 'the_geom', srid: 4326 }, { circle: params.circle }); query = circleFilter.sql(query); + } else if (params.polygon) { + const polygonFilter = new PolygonFilter({ column: 'the_geom', srid: 4326 }, { polygon: params.polygon }); + query = polygonFilter.sql(query); } return query; diff --git a/lib/models/filter/polygon.js b/lib/models/filter/polygon.js new file mode 100644 index 00000000..1dbdcc83 --- /dev/null +++ b/lib/models/filter/polygon.js @@ -0,0 +1,63 @@ +'use strict'; + +const assert = require('assert'); +const debug = require('debug')('windshaft:filter:polygon'); +function filterQueryTpl ({ sql, column, srid, geojson } = {}) { + return ` + SELECT + * + FROM (${sql}) _cdb_polygon_filter + WHERE + ST_Intersects( + ${column}, + ST_Transform( + ST_SetSRID(ST_GeomFromGeoJSON('${JSON.stringify(geojson)}'), 4326), + ${srid} + ) + ) + `; +} + +module.exports = class PolygonFilter { + constructor (filterDefinition, filterParams) { + const { polygon } = filterParams; + + if (!polygon) { + throw new Error('Polygon filter expects to have a "polygon" param'); + } + + const geojson = JSON.parse(polygon); + + if (geojson.type !== 'Polygon') { + throw new Error('Invalid type of geometry. Valid ones: "Polygon"'); + } + + if (!Array.isArray(geojson.coordinates)) { + throw new Error('Invalid geometry, it must be an array of coordinates (long/lat)'); + } + + try { + const length = geojson.coordinates.length; + assert.deepStrictEqual(geojson.coordinates[0], geojson.coordinates[length - 1]); + } catch (error) { + throw new Error('Invalid geometry, it must be a closed polygon'); + } + + this.column = filterDefinition.column || 'the_geom_webmercator'; + this.srid = filterDefinition.srid || 3857; + this.geojson = geojson; + } + + sql (rawSql) { + const polygonSql = filterQueryTpl({ + sql: rawSql, + column: this.column, + srid: this.srid, + geojson: this.geojson + }); + + debug(polygonSql); + + return polygonSql; + } +}; diff --git a/test/acceptance/dataviews/circle-filter-test.js b/test/acceptance/dataviews/spatial-filters-test.js similarity index 73% rename from test/acceptance/dataviews/circle-filter-test.js rename to test/acceptance/dataviews/spatial-filters-test.js index dbb07a0a..26168b13 100644 --- a/test/acceptance/dataviews/circle-filter-test.js +++ b/test/acceptance/dataviews/spatial-filters-test.js @@ -5,7 +5,7 @@ require('../../support/test-helper'); const assert = require('../../support/assert'); const TestClient = require('../../support/test-client'); -describe('circle filter', function () { +describe('spatial filters', function () { const mapConfig = { version: '1.8.0', layers: [ @@ -118,6 +118,49 @@ describe('circle filter', function () { { category: 'category_1', value: 1, agg: false } ] } + }, + { + params: { + polygon: JSON.stringify({ + type: 'Polygon', + coordinates: [ + [ + [ + -9.312286, + 37.907367 + ], + [ + 11.969604, + 6.487254 + ], + [ + -32.217407, + 6.957781 + ], + [ + -9.312286, + 37.907367 + ] + ] + ] + }) + }, + expected: { + type: 'aggregation', + aggregation: 'sum', + count: 3, + nulls: 0, + nans: 0, + infinities: 0, + min: 1, + max: 4, + categoriesCount: 3, + categories: [ + { category: 'category_4', value: 4, agg: false }, + { category: 'category_2', value: 2, agg: false }, + { category: 'category_1', value: 1, agg: false } + ] + } } ]; diff --git a/test/support/test-client.js b/test/support/test-client.js index b09fb552..6f465295 100644 --- a/test/support/test-client.js +++ b/test/support/test-client.js @@ -485,7 +485,7 @@ TestClient.prototype.getDataview = function (dataviewName, params, callback) { urlParams.own_filter = params.own_filter; } - ['bbox', 'circle', 'bins', 'start', 'end', 'aggregation', 'offset', 'categories'].forEach(function (extraParam) { + ['bbox', 'circle', 'polygon', 'bins', 'start', 'end', 'aggregation', 'offset', 'categories'].forEach(function (extraParam) { if (Object.prototype.hasOwnProperty.call(params, extraParam)) { urlParams[extraParam] = params[extraParam]; }