diff --git a/lib/cartodb/backends/dataview.js b/lib/cartodb/backends/dataview.js new file mode 100644 index 00000000..0e52aea3 --- /dev/null +++ b/lib/cartodb/backends/dataview.js @@ -0,0 +1,167 @@ +var assert = require('assert'); + +var _ = require('underscore'); +var PSQL = require('cartodb-psql'); +var camshaft = require('camshaft'); +var step = require('step'); + +var Timer = require('../stats/timer'); + +var BBoxFilter = require('../models/filter/bbox'); +var Histogram = require('../models/dataview/histogram'); + +function DataviewBackend() { +} + +module.exports = DataviewBackend; + + +DataviewBackend.prototype.getDataview = function (mapConfigProvider, user, params, callback) { + var timer = new Timer(); + + var mapConfig; + var dataviewDefinition; + step( + function getMapConfig() { + mapConfigProvider.getMapConfig(this); + }, + function getWidget(err, _mapConfig) { + assert.ifError(err); + + mapConfig = _mapConfig; + + var _dataviewDefinition = getDataviewDefinition(mapConfig.obj(), params.dataviewName); + if (!_dataviewDefinition) { + throw new Error("Dataview '" + params.dataviewName + "' does not exists"); + } + + dataviewDefinition = _dataviewDefinition; + + return dataviewDefinition; + }, + function loadAnalysis(err) { + assert.ifError(err); + + var analysisConfiguration = { + db: { + host: params.dbhost, + port: params.dbport, + dbname: params.dbname, + user: params.dbuser, + pass: params.dbpassword + }, + batch: { + // TODO load this from configuration + endpoint: 'http://127.0.0.1:8080/api/v1/sql/job', + username: user, + apiKey: params.api_key + } + }; + + var sourceId = dataviewDefinition.source.id; + var analysisDefinition = getAnalysisDefinition(mapConfig.obj().analyses, sourceId); + + var next = this; + + camshaft.create(analysisConfiguration, analysisDefinition, function(err, analysis) { + if (err) { + return next(err); + } + + var sourceId2Node = {}; + var rootNode = analysis.getRoot(); + if (rootNode.params && rootNode.params.id) { + sourceId2Node[rootNode.params.id] = rootNode; + } + + analysis.getSortedNodes().forEach(function(node) { + if (node.params && node.params.id) { + sourceId2Node[node.params.id] = node; + } + }); + + var node = sourceId2Node[sourceId]; + + if (!node) { + return next(new Error('Analysis node not found for dataview')); + } + + return next(null, node); + }); + }, + function runDataviewQuery(err, node) { + assert.ifError(err); + + var pg = new PSQL(dbParamsFromReqParams(params)); + + var ownFilter = +params.own_filter; + ownFilter = !!ownFilter; + + var query; + if (ownFilter) { + query = node.getQuery(); + } else { + var applyFilters = {}; + applyFilters[params.dataviewName] = false; + query = node.getQuery(applyFilters); + } + + if (params.bbox) { + var bboxFilter = new BBoxFilter({column: 'the_geom', srid: 4326}, {bbox: params.bbox}); + query = bboxFilter.sql(query); + } + + + var overrideParams = _.reduce(_.pick(params, 'start', 'end', 'bins'), + function castNumbers(overrides, val, k) { + overrides[k] = Number.isFinite(+val) ? +val : val; + return overrides; + }, + {ownFilter: ownFilter} + ); + + var histogramDataview = new Histogram(query, dataviewDefinition.options); + histogramDataview.getResult(pg, overrideParams, this); + }, + function returnCallback(err, result) { + return callback(err, result, timer.getTimes()); + } + ); +}; + +function getAnalysisDefinition(mapConfigAnalyses, sourceId) { + mapConfigAnalyses = mapConfigAnalyses || []; + for (var i = 0; i < mapConfigAnalyses.length; i++) { + var analysisGraph = new camshaft.reference.AnalysisGraph(mapConfigAnalyses[i]); + var nodes = analysisGraph.getNodesWithId(); + if (nodes.hasOwnProperty(sourceId)) { + return mapConfigAnalyses[i]; + } + } + throw new Error('There is no associated analysis for the dataview source id'); +} + +function getDataviewDefinition(mapConfig, dataviewName) { + var dataviews = mapConfig.dataviews || {}; + return dataviews[dataviewName]; +} + +function dbParamsFromReqParams(params) { + var dbParams = {}; + if ( params.dbuser ) { + dbParams.user = params.dbuser; + } + if ( params.dbpassword ) { + dbParams.pass = params.dbpassword; + } + if ( params.dbhost ) { + dbParams.host = params.dbhost; + } + if ( params.dbport ) { + dbParams.port = params.dbport; + } + if ( params.dbname ) { + dbParams.dbname = params.dbname; + } + return dbParams; +} \ No newline at end of file diff --git a/lib/cartodb/controllers/layergroup.js b/lib/cartodb/controllers/layergroup.js index b6b4d640..fbf8da92 100644 --- a/lib/cartodb/controllers/layergroup.js +++ b/lib/cartodb/controllers/layergroup.js @@ -7,6 +7,8 @@ var BaseController = require('./base'); var cors = require('../middleware/cors'); var userMiddleware = require('../middleware/user'); +var DataviewBackend = require('../backends/dataview'); + var MapStoreMapConfigProvider = require('../models/mapconfig/map_store_provider'); var QueryTables = require('cartodb-query-tables'); @@ -37,6 +39,8 @@ function LayergroupController(authApi, pgConnection, mapStore, tileBackend, prev this.surrogateKeysCache = surrogateKeysCache; this.userLimitsApi = userLimitsApi; this.layergroupAffectedTables = layergroupAffectedTables; + + this.dataviewBackend = new DataviewBackend(); } util.inherits(LayergroupController, BaseController); @@ -78,6 +82,38 @@ LayergroupController.prototype.register = function(app) { app.get(app.base_url_mapconfig + '/:token/:layer/widget/:widgetName/search', cors(), userMiddleware, this.widgetSearch.bind(this)); + + app.get(app.base_url_mapconfig + + '/:token/dataview/:dataviewName', cors(), userMiddleware, + this.dataview.bind(this)); +}; + +LayergroupController.prototype.dataview = function(req, res) { + var self = this; + + step( + function setupParams() { + self.req2params(req, this); + }, + function retrieveList(err) { + assert.ifError(err); + + var mapConfigProvider = new MapStoreMapConfigProvider( + self.mapStore, req.context.user, self.userLimitsApi, req.params + ); + self.dataviewBackend.getDataview(mapConfigProvider, req.context.user, req.params, this); + }, + function finish(err, tile, stats) { + req.profiler.add(stats || {}); + + if (err) { + self.sendError(req, res, err, 'GET WIDGET'); + } else { + self.sendResponse(req, res, tile, 200); + } + } + ); + }; LayergroupController.prototype.widget = function(req, res) { diff --git a/lib/cartodb/controllers/map.js b/lib/cartodb/controllers/map.js index 749a10a9..0b793735 100644 --- a/lib/cartodb/controllers/map.js +++ b/lib/cartodb/controllers/map.js @@ -155,7 +155,16 @@ MapController.prototype.create = function(req, res, prepareConfigFn) { apiKey: req.params.api_key } }; - self.analysisMapConfigAdapter.getLayers(analysisConfiguration, requestMapConfig, this); + + var filters = {}; + if (req.params.filters) { + try { + filters = JSON.parse(req.params.filters); + } catch (e) { + // ignore + } + } + self.analysisMapConfigAdapter.getLayers(analysisConfiguration, requestMapConfig, filters, this); }, function beforeLayergroupCreate(err, requestMapConfig) { assert.ifError(err); diff --git a/lib/cartodb/models/analysis_mapconfig_adapter.js b/lib/cartodb/models/analysis_mapconfig_adapter.js index 6c437849..dbc55284 100644 --- a/lib/cartodb/models/analysis_mapconfig_adapter.js +++ b/lib/cartodb/models/analysis_mapconfig_adapter.js @@ -31,19 +31,13 @@ function layerQuery(query, columnNames) { } function replaceSourceRootQueries(requestMapConfig) { - var analysisToRemove = {}; - var analysisSourceRootsIds = requestMapConfig.analyses.reduce(function(sourceRootsIds, analysis, analysisIndex) { + var analysisSourceRootsIds = requestMapConfig.analyses.reduce(function(sourceRootsIds, analysis) { if (analysis.type === 'source' && !!analysis.id) { sourceRootsIds[analysis.id] = analysis; - analysisToRemove[analysisIndex] = true; } return sourceRootsIds; }, {}); - requestMapConfig.analyses = requestMapConfig.analyses.filter(function(analysis, index) { - return !analysisToRemove.hasOwnProperty(index); - }); - requestMapConfig.layers = requestMapConfig.layers.map(function(layer) { var sourceId = layer.options && layer.options.source && layer.options.source.id; if (sourceId && analysisSourceRootsIds.hasOwnProperty(sourceId)) { @@ -56,17 +50,61 @@ function replaceSourceRootQueries(requestMapConfig) { return requestMapConfig; } +function appendFiltersToNodes(requestMapConfig, dataviewsFiltersBySourceId) { + var analyses = requestMapConfig.analyses || []; + + debug(JSON.stringify(requestMapConfig, null, 4)); + + requestMapConfig.analyses = analyses.map(function(analysisDefinition) { + var analysisGraph = new camshaft.reference.AnalysisGraph(analysisDefinition); + var definition = analysisDefinition; + Object.keys(dataviewsFiltersBySourceId).forEach(function(sourceId) { + definition = analysisGraph.getDefinitionWith(sourceId, {filters: dataviewsFiltersBySourceId[sourceId] }); + }); + + return definition; + }); + + debug(JSON.stringify(requestMapConfig, null, 4)); + + return requestMapConfig; +} + function shouldAdaptLayers(requestMapConfig) { return Array.isArray(requestMapConfig.layers) && Array.isArray(requestMapConfig.analyses) && requestMapConfig.analyses.length > 0; } -AnalysisMapConfigAdapter.prototype.getLayers = function(analysisConfiguration, requestMapConfig, callback) { +AnalysisMapConfigAdapter.prototype.getLayers = function(analysisConfiguration, requestMapConfig, filters, callback) { + filters = filters || {}; + var dataviewsFilters = filters.dataviews || {}; + debug(dataviewsFilters); + var dataviews = requestMapConfig.dataviews || {}; + + var dataviewsFiltersBySourceId = Object.keys(dataviewsFilters).reduce(function(bySourceId, dataviewName) { + var dataview = dataviews[dataviewName]; + if (dataview) { + var sourceId = dataview.source.id; + if (!bySourceId.hasOwnProperty(sourceId)) { + bySourceId[sourceId] = {}; + } + + bySourceId[sourceId][dataviewName] = { + type: 'range', + column: dataview.options.column, + params: dataviewsFilters[dataviewName] + }; + } + return bySourceId; + }, {}); + + debug(dataviewsFiltersBySourceId); debug('mapconfig input', JSON.stringify(requestMapConfig, null, 4)); if (Array.isArray(requestMapConfig.analyses)) { requestMapConfig = replaceSourceRootQueries(requestMapConfig); + requestMapConfig = appendFiltersToNodes(requestMapConfig, dataviewsFiltersBySourceId); } if (!shouldAdaptLayers(requestMapConfig)) { diff --git a/lib/cartodb/models/dataview/histogram.js b/lib/cartodb/models/dataview/histogram.js new file mode 100644 index 00000000..e5a74d36 --- /dev/null +++ b/lib/cartodb/models/dataview/histogram.js @@ -0,0 +1,318 @@ +var _ = require('underscore'); +var debug = require('debug')('windshaft:dataview:histogram'); + +var dot = require('dot'); +dot.templateSettings.strip = false; + +var columnTypeQueryTpl = dot.template( + 'SELECT pg_typeof({{=it.column}})::oid FROM ({{=it.query}}) _cdb_histogram_column_type limit 1' +); +var columnCastTpl = dot.template("date_part('epoch', {{=it.column}})"); + +var BIN_MIN_NUMBER = 6; +var BIN_MAX_NUMBER = 48; + +var basicsQueryTpl = dot.template([ + 'basics AS (', + ' SELECT', + ' max({{=it._column}}) AS max_val, min({{=it._column}}) AS min_val,', + ' avg({{=it._column}}) AS avg_val, count(1) AS total_rows', + ' FROM ({{=it._query}}) _cdb_basics', + ')' +].join(' \n')); + +var overrideBasicsQueryTpl = dot.template([ + 'basics AS (', + ' SELECT', + ' max({{=it._end}}) AS max_val, min({{=it._start}}) AS min_val,', + ' avg({{=it._column}}) AS avg_val, count(1) AS total_rows', + ' FROM ({{=it._query}}) _cdb_basics', + ')' +].join('\n')); + +var iqrQueryTpl = dot.template([ + 'iqrange AS (', + ' SELECT max(quartile_max) - min(quartile_max) AS iqr', + ' FROM (', + ' SELECT quartile, max(_cdb_iqr_column) AS quartile_max from (', + ' SELECT {{=it._column}} AS _cdb_iqr_column, ntile(4) over (order by {{=it._column}}', + ' ) AS quartile', + ' FROM ({{=it._query}}) _cdb_rank) _cdb_quartiles', + ' WHERE quartile = 1 or quartile = 3', + ' GROUP BY quartile', + ' ) _cdb_iqr', + ')' +].join('\n')); + +var binsQueryTpl = dot.template([ + 'bins AS (', + ' SELECT CASE WHEN total_rows = 0 OR iqr = 0', + ' THEN 1', + ' ELSE GREATEST(', + ' LEAST({{=it._minBins}}, CAST(total_rows AS INT)),', + ' LEAST(', + ' CAST(((max_val - min_val) / (2 * iqr * power(total_rows, 1/3))) AS INT),', + ' {{=it._maxBins}}', + ' )', + ' )', + ' END AS bins_number', + ' FROM basics, iqrange, ({{=it._query}}) _cdb_bins', + ' LIMIT 1', + ')' +].join('\n')); + +var overrideBinsQueryTpl = dot.template([ + 'bins AS (', + ' SELECT {{=it._bins}} AS bins_number', + ')' +].join('\n')); + +var nullsQueryTpl = dot.template([ + 'nulls AS (', + ' SELECT', + ' count(*) AS nulls_count', + ' FROM ({{=it._query}}) _cdb_histogram_nulls', + ' WHERE {{=it._column}} IS NULL', + ')' +].join('\n')); + +var histogramQueryTpl = dot.template([ + 'SELECT', + ' (max_val - min_val) / cast(bins_number as float) AS bin_width,', + ' bins_number,', + ' nulls_count,', + ' avg_val,', + ' CASE WHEN min_val = max_val', + ' THEN 0', + ' ELSE GREATEST(1, LEAST(WIDTH_BUCKET({{=it._column}}, min_val, max_val, bins_number), bins_number)) - 1', + ' END AS bin,', + ' min({{=it._column}})::numeric AS min,', + ' max({{=it._column}})::numeric AS max,', + ' avg({{=it._column}})::numeric AS avg,', + ' count(*) AS freq', + 'FROM ({{=it._query}}) _cdb_histogram, basics, nulls, bins', + 'WHERE {{=it._column}} IS NOT NULL', + 'GROUP BY bin, bins_number, bin_width, nulls_count, avg_val', + 'ORDER BY bin' +].join('\n')); + + +var TYPE = 'histogram'; + +/** + { + type: 'histogram', + options: { + column: 'name', + bins: 10 // OPTIONAL + } + } + */ +function Histogram(query, options) { + if (!_.isString(options.column)) { + throw new Error('Histogram expects `column` in widget options'); + } + + this.query = query; + this.column = options.column; + this.bins = options.bins; + + this._columnType = null; +} + +module.exports = Histogram; + +var DATE_OIDS = { + 1082: true, + 1114: true, + 1184: true +}; + + +Histogram.prototype.getResult = function(psql, override, callback) { + var self = this; + this.sql(psql, override, function(err, query) { + psql.query(query, function(err, result) { + + if (err) { + return callback(err, result); + } + + result = self.format(result, override); + result.type = self.getType(); + + return callback(null, result); + + }, true); // use read-only transaction + }); + +}; + +Histogram.prototype.search = function(psql, filters, userQuery, callback) { + return callback(null, this.format({ rows: [] })); +}; + + +Histogram.prototype.sql = function(psql, override, callback) { + if (!callback) { + callback = override; + override = {}; + } + + var self = this; + + var _column = this.column; + + var columnTypeQuery = columnTypeQueryTpl({ + column: _column, query: this.query + }); + + if (this._columnType === null) { + psql.query(columnTypeQuery, function(err, result) { + // assume numeric, will fail later + self._columnType = 'numeric'; + if (!err && !!result.rows[0]) { + var pgType = result.rows[0].pg_typeof; + if (DATE_OIDS.hasOwnProperty(pgType)) { + self._columnType = 'date'; + } + } + self.sql(psql, override, callback); + }, true); // use read-only transaction + return null; + } + + if (this._columnType === 'date') { + _column = columnCastTpl({column: _column}); + } + + var _query = this.query; + + var basicsQuery, binsQuery; + + if (override && _.has(override, 'start') && _.has(override, 'end') && _.has(override, 'bins')) { + debug('overriding with %j', override); + basicsQuery = overrideBasicsQueryTpl({ + _query: _query, + _column: _column, + _start: override.start, + _end: override.end + }); + + binsQuery = [ + overrideBinsQueryTpl({ + _bins: override.bins + }) + ].join(',\n'); + } else { + basicsQuery = basicsQueryTpl({ + _query: _query, + _column: _column + }); + + if (override && _.has(override, 'bins')) { + binsQuery = [ + overrideBinsQueryTpl({ + _bins: override.bins + }) + ].join(',\n'); + } else { + binsQuery = [ + iqrQueryTpl({ + _query: _query, + _column: _column + }), + binsQueryTpl({ + _query: _query, + _minBins: BIN_MIN_NUMBER, + _maxBins: BIN_MAX_NUMBER + }) + ].join(',\n'); + } + } + + + var histogramSql = [ + "WITH", + [ + basicsQuery, + binsQuery, + nullsQueryTpl({ + _query: _query, + _column: _column + }) + ].join(',\n'), + histogramQueryTpl({ + _query: _query, + _column: _column + }) + ].join('\n'); + + debug(histogramSql); + + return callback(null, histogramSql); +}; + +Histogram.prototype.format = function(result, override) { + override = override || {}; + var buckets = []; + + var binsCount = getBinsCount(override); + var width = getWidth(override); + var binsStart = getBinStart(override); + var nulls = 0; + var avg; + + if (result.rows.length) { + var firstRow = result.rows[0]; + binsCount = firstRow.bins_number; + width = firstRow.bin_width || width; + avg = firstRow.avg_val; + nulls = firstRow.nulls_count; + binsStart = override.hasOwnProperty('start') ? override.start : firstRow.min; + + buckets = result.rows.map(function(row) { + return _.omit(row, 'bins_number', 'bin_width', 'nulls_count', 'avg_val'); + }); + } + + return { + bin_width: width, + bins_count: binsCount, + bins_start: binsStart, + nulls: nulls, + avg: avg, + bins: buckets + }; +}; + +function getBinStart(override) { + return override.start || 0; +} + +function getBinsCount(override) { + return override.bins || 0; +} + +function getWidth(override) { + var width = 0; + var binsCount = override.bins; + + if (binsCount && Number.isFinite(override.start) && Number.isFinite(override.end)) { + width = (override.end - override.start) / binsCount; + } + + return width; +} + +Histogram.prototype.getType = function() { + return TYPE; +}; + +Histogram.prototype.toString = function() { + return JSON.stringify({ + _type: TYPE, + _column: this.column, + _query: this.query + }); +}; diff --git a/lib/cartodb/models/filter/bbox.js b/lib/cartodb/models/filter/bbox.js new file mode 100644 index 00000000..b7a3c249 --- /dev/null +++ b/lib/cartodb/models/filter/bbox.js @@ -0,0 +1,120 @@ +var debug = require('debug')('windshaft:filter:bbox'); +var dot = require('dot'); +dot.templateSettings.strip = false; + +var filterQueryTpl = dot.template([ + 'SELECT * FROM ({{=it._sql}}) _cdb_bbox_filter', + 'WHERE {{=it._filters}}' +].join('\n')); + +var bboxFilterTpl = '{{=it._column}} && ST_Transform(ST_MakeEnvelope({{=it._bbox}}, 4326), {{=it._srid}})'; + +var LATITUDE_MAX_VALUE = 85.0511287798066; +var LONGITUDE_LOWER_BOUND = -180; +var LONGITUDE_UPPER_BOUND = 180; +var LONGITUDE_RANGE = LONGITUDE_UPPER_BOUND - LONGITUDE_LOWER_BOUND; + +/** + Definition + { + "type”: "bbox", + "options": { + "column": "the_geom_webmercator", + "srid": 3857 + } + } + + Params + { + “bbox”: "west,south,east,north" + } + */ +function BBox(filterDefinition, filterParams) { + var bbox = filterParams.bbox; + + if (!bbox) { + throw new Error('BBox filter expects to have a bbox param'); + } + + var bboxElements = bbox.split(',').map(function(e) { return +e; }); + + validateBboxElements(bboxElements); + + this.column = filterDefinition.column || 'the_geom_webmercator'; + this.srid = filterDefinition.srid || 3857; + + // Latitudes must be within max extent + var south = Math.max(bboxElements[1], -LATITUDE_MAX_VALUE); + var north = Math.min(bboxElements[3], LATITUDE_MAX_VALUE); + + // Longitudes crossing 180º need another approach + var adjustedLongitudeRange = adjustLongitudeRange([bboxElements[0], bboxElements[2]]); + var west = adjustedLongitudeRange[0]; + var east = adjustedLongitudeRange[1]; + + this.bboxes = getBoundingBoxes(west, south, east, north); +} + +function getBoundingBoxes(west, south, east, north) { + var bboxes = []; + + if (east - west >= 360) { + bboxes.push([-180, south, 180, north]); + } else if (west >= -180 && east <= 180) { + bboxes.push([west, south, east, north]); + } else { + bboxes.push([west, south, 180, north]); + bboxes.push([-180, south, east % 180, north]); + } + + return bboxes; +} + +function validateBboxElements(bboxElements) { + var isNumericBbox = bboxElements + .map(function(n) { return Number.isFinite(n); }) + .reduce(function(allFinite, isFinite) { + if (!allFinite) { + return false; + } + return isFinite; + }, true); + + if (bboxElements.length !== 4 || !isNumericBbox) { + throw new Error('Invalid bbox filter, expected format="west,south,east,north"'); + } +} + +function adjustLongitudeRange(we) { + var west = we[0]; + west -= LONGITUDE_LOWER_BOUND; + west = west - (LONGITUDE_RANGE * Math.floor(west / LONGITUDE_RANGE)) + LONGITUDE_LOWER_BOUND; + + var longitudeRange = Math.min(we[1] - we[0], 360); + + return [west, west + longitudeRange]; +} + +module.exports = BBox; + +module.exports.adjustLongitudeRange = adjustLongitudeRange; +module.exports.LATITUDE_MAX_VALUE = LATITUDE_MAX_VALUE; +module.exports.LONGITUDE_MAX_VALUE = LONGITUDE_UPPER_BOUND; + + +BBox.prototype.sql = function(rawSql) { + var bboxSql = filterQueryTpl({ + _sql: rawSql, + _filters: this.bboxes.map(function(bbox) { + return bboxFilterTpl({ + _column: this.column, + _bbox: bbox.join(','), + _srid: this.srid + }); + }.bind(this)).join(' OR ') + }); + + debug(bboxSql); + + return bboxSql; +}; diff --git a/test/acceptance/analysis/analysis-layers-dataviews.js b/test/acceptance/analysis/analysis-layers-dataviews.js new file mode 100644 index 00000000..1a2307f6 --- /dev/null +++ b/test/acceptance/analysis/analysis-layers-dataviews.js @@ -0,0 +1,124 @@ +require('../../support/test_helper'); + +var assert = require('../../support/assert'); +var TestClient = require('../../support/test-client'); +var dot = require('dot'); + +describe('analysis-layers-dataviews', function() { + + var multitypeStyleTemplate = dot.template([ + "#points['mapnik::geometry_type'=1] {", + " marker-fill-opacity: {{=it._opacity}};", + " marker-line-color: #FFF;", + " marker-line-width: 0.5;", + " marker-line-opacity: {{=it._opacity}};", + " marker-placement: point;", + " marker-type: ellipse;", + " marker-width: 8;", + " marker-fill: {{=it._color}};", + " marker-allow-overlap: true;", + "}", + "#lines['mapnik::geometry_type'=2] {", + " line-color: {{=it._color}};", + " line-width: 2;", + " line-opacity: {{=it._opacity}};", + "}", + "#polygons['mapnik::geometry_type'=3] {", + " polygon-fill: {{=it._color}};", + " polygon-opacity: {{=it._opacity}};", + " line-color: #FFF;", + " line-width: 0.5;", + " line-opacity: {{=it._opacity}};", + "}" + ].join('\n')); + + + function cartocss(color, opacity) { + return multitypeStyleTemplate({ + _color: color || '#F11810', + _opacity: Number.isFinite(opacity) ? opacity : 1 + }); + } + + function createMapConfig(layers, dataviews, analysis) { + return { + version: '1.5.0', + layers: layers, + dataviews: dataviews || {}, + analyses: analysis || [] + }; + } + + var DEFAULT_MULTITYPE_STYLE = cartocss(); + + var mapConfig = createMapConfig( + [ + { + "type": "cartodb", + "options": { + "source": { + "id": "2570e105-7b37-40d2-bdf4-1af889598745" + }, + "cartocss": DEFAULT_MULTITYPE_STYLE, + "cartocss_version": "2.3.0" + } + } + ], + { + pop_max_histogram: { + source: { + id: '2570e105-7b37-40d2-bdf4-1af889598745' + }, + type: 'histogram', + options: { + column: 'pop_max' + } + } + }, + [ + { + "id": "2570e105-7b37-40d2-bdf4-1af889598745", + "type": "source", + "params": { + "query": "select * from populated_places_simple_reduced" + } + } + ] + ); + + it('should get histogram dataview', function(done) { + var testClient = new TestClient(mapConfig, 1234); + + testClient.getDataview('pop_max_histogram', function(err, dataview) { + assert.ok(!err, err); + + assert.equal(dataview.type, 'histogram'); + assert.equal(dataview.bins_start, 0); + + testClient.drain(done); + }); + }); + + it('should get a filtered histogram dataview', function(done) { + var testClient = new TestClient(mapConfig, 1234); + + var params = { + filters: { + dataviews: { + pop_max_histogram: { + min: 2e6 + } + } + } + }; + + testClient.getDataview('pop_max_histogram', params, function(err, dataview) { + assert.ok(!err, err); + + assert.equal(dataview.type, 'histogram'); + assert.equal(dataview.bins_start, 2008000); + + testClient.drain(done); + }); + }); +}); diff --git a/test/support/test-client.js b/test/support/test-client.js index b79ce9e7..2122c273 100644 --- a/test/support/test-client.js +++ b/test/support/test-client.js @@ -117,6 +117,113 @@ TestClient.prototype.getWidget = function(widgetName, params, callback) { ); }; +TestClient.prototype.getDataview = function(dataviewName, params, callback) { + var self = this; + + if (!callback) { + callback = params; + params = {}; + } + + var extraParams = {}; + if (this.apiKey) { + extraParams.api_key = this.apiKey; + } + if (params && params.filters) { + extraParams.filters = JSON.stringify(params.filters); + } + + var url = '/api/v1/map'; + if (Object.keys(extraParams).length > 0) { + url += '?' + qs.stringify(extraParams); + } + + console.log(url); + + var layergroupId; + step( + function createLayergroup() { + var next = this; + assert.response(server, + { + url: url, + method: 'POST', + headers: { + host: 'localhost', + 'Content-Type': 'application/json' + }, + data: JSON.stringify(self.mapConfig) + }, + { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + }, + function(res, err) { + if (err) { + return next(err); + } + var parsedBody = JSON.parse(res.body); +// var expectedWidgetURLS = { +// http: "/api/v1/map/" + parsedBody.layergroupid + "/dataview/" + dataviewName +// }; +// assert.ok(parsedBody.metadata.dataviews[dataviewName]); +// assert.ok( +// parsedBody.metadata.dataviews[dataviewName].url.http.match(expectedWidgetURLS.http) +// ); + return next(null, parsedBody.layergroupid); + } + ); + }, + function getDataviewResult(err, _layergroupId) { + assert.ifError(err); + + var next = this; + layergroupId = _layergroupId; + + var urlParams = { + own_filter: params.hasOwnProperty('own_filter') ? params.own_filter : 1 + }; + if (params && params.bbox) { + urlParams.bbox = params.bbox; + } + if (self.apiKey) { + urlParams.api_key = self.apiKey; + } + url = '/api/v1/map/' + layergroupId + '/dataview/' + dataviewName + '?' + qs.stringify(urlParams); + + assert.response(server, + { + url: url, + method: 'GET', + headers: { + host: 'localhost' + } + }, + { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8' + } + }, + function(res, err) { + if (err) { + return next(err); + } + + next(null, JSON.parse(res.body)); + } + ); + }, + function finish(err, res) { + self.keysToDelete['map_cfg|' + LayergroupToken.parse(layergroupId).token] = 0; + self.keysToDelete['user:localhost:mapviews:global'] = 5; + return callback(err, res); + } + ); +}; + TestClient.prototype.getTile = function(z, x, y, params, callback) { var self = this;