2019-02-27 02:19:44 +08:00
|
|
|
'use strict';
|
|
|
|
|
|
|
|
const PSQL = require('cartodb-psql');
|
|
|
|
const dbParamsFromReqParams = require('../utils/database-params');
|
|
|
|
const debug = require('debug')('backend:cluster');
|
2019-02-28 01:54:21 +08:00
|
|
|
const AggregationMapConfig = require('../models/aggregation/aggregation-mapconfig');
|
2019-02-27 02:19:44 +08:00
|
|
|
|
|
|
|
module.exports = class ClusterBackend {
|
2019-03-02 00:49:26 +08:00
|
|
|
// TODO: reduce complexity
|
2019-03-12 00:14:07 +08:00
|
|
|
// jshint maxcomplexity: 13
|
2019-02-27 02:19:44 +08:00
|
|
|
getClusterFeatures (mapConfigProvider, params, callback) {
|
2019-02-28 01:54:21 +08:00
|
|
|
mapConfigProvider.getMapConfig((err, _mapConfig) => {
|
2019-02-27 02:19:44 +08:00
|
|
|
if (err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
let pg;
|
|
|
|
try {
|
|
|
|
pg = new PSQL(dbParamsFromReqParams(params));
|
|
|
|
} catch (error) {
|
|
|
|
return callback(error);
|
|
|
|
}
|
|
|
|
|
2019-02-28 01:54:21 +08:00
|
|
|
const { user, token, layer: layerIndex } = params;
|
|
|
|
const mapConfig = new AggregationMapConfig(user, _mapConfig.obj(), pg);
|
|
|
|
|
2019-02-28 19:13:15 +08:00
|
|
|
const layer = mapConfig.getLayer(layerIndex);
|
|
|
|
|
2019-03-12 00:01:13 +08:00
|
|
|
if (!hasAggregationLayer(mapConfig, layerIndex)) {
|
2019-02-28 01:54:21 +08:00
|
|
|
const error = new Error(`Map ${token} has no aggregation defined for layer ${layerIndex}`);
|
2019-02-28 19:13:15 +08:00
|
|
|
error.http_status = 400;
|
|
|
|
error.type = 'layer';
|
|
|
|
error.subtype = 'aggregation';
|
|
|
|
error.layer = {
|
|
|
|
index: layerIndex,
|
|
|
|
type: layer.type
|
|
|
|
};
|
|
|
|
|
2019-02-28 01:54:21 +08:00
|
|
|
debug(error);
|
2019-03-12 00:01:13 +08:00
|
|
|
|
2019-02-28 01:54:21 +08:00
|
|
|
return callback(error);
|
|
|
|
}
|
|
|
|
|
2019-03-01 22:17:22 +08:00
|
|
|
let { aggregation } = params;
|
|
|
|
|
|
|
|
if ( aggregation !== undefined) {
|
|
|
|
try {
|
|
|
|
aggregation = JSON.parse(aggregation);
|
|
|
|
} catch (err) {
|
|
|
|
const error = new Error(`Invalid aggregation input, should be a a valid JSON`);
|
|
|
|
error.http_status = 400;
|
|
|
|
error.type = 'layer';
|
|
|
|
error.subtype = 'aggregation';
|
|
|
|
error.layer = {
|
|
|
|
index: layerIndex,
|
|
|
|
type: layer.type
|
|
|
|
};
|
|
|
|
|
|
|
|
return callback(error);
|
|
|
|
}
|
|
|
|
|
|
|
|
const { columns, expressions } = aggregation;
|
|
|
|
|
2019-03-12 00:06:04 +08:00
|
|
|
if (!hasColumns(columns)) {
|
2019-03-01 22:17:22 +08:00
|
|
|
const error = new Error(
|
|
|
|
`Invalid aggregation input, columns should be and array of column names`
|
|
|
|
);
|
|
|
|
error.http_status = 400;
|
|
|
|
error.type = 'layer';
|
|
|
|
error.subtype = 'aggregation';
|
|
|
|
error.layer = {
|
|
|
|
index: layerIndex,
|
|
|
|
type: layer.type
|
|
|
|
};
|
|
|
|
|
|
|
|
return callback(error);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (expressions !== undefined) {
|
2019-03-12 00:14:07 +08:00
|
|
|
if (!isValidExpression(expressions)) {
|
2019-03-01 22:17:22 +08:00
|
|
|
const error = new Error(
|
2019-03-01 22:45:38 +08:00
|
|
|
`Invalid aggregation input, expressions should be and object with valid functions`
|
2019-03-01 22:17:22 +08:00
|
|
|
);
|
|
|
|
error.http_status = 400;
|
|
|
|
error.type = 'layer';
|
|
|
|
error.subtype = 'aggregation';
|
|
|
|
error.layer = {
|
|
|
|
index: layerIndex,
|
|
|
|
type: layer.type
|
|
|
|
};
|
|
|
|
|
|
|
|
return callback(error);
|
|
|
|
}
|
2019-03-02 00:02:06 +08:00
|
|
|
|
2019-03-02 00:05:35 +08:00
|
|
|
for (const { aggregate_function, aggregated_column } of Object.values(expressions)) {
|
2019-03-02 00:02:06 +08:00
|
|
|
if (typeof aggregated_column !== 'string') {
|
|
|
|
const error = new Error(`Invalid aggregation input, aggregated column should be an string`);
|
|
|
|
error.http_status = 400;
|
|
|
|
error.type = 'layer';
|
|
|
|
error.subtype = 'aggregation';
|
|
|
|
error.layer = {
|
|
|
|
index: layerIndex,
|
|
|
|
type: layer.type
|
|
|
|
};
|
|
|
|
|
|
|
|
return callback(error);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (typeof aggregate_function !== 'string') {
|
2019-03-02 00:05:35 +08:00
|
|
|
const error = new Error(
|
|
|
|
`Invalid aggregation input, aggregate function should be an string`
|
|
|
|
);
|
2019-03-02 00:02:06 +08:00
|
|
|
error.http_status = 400;
|
|
|
|
error.type = 'layer';
|
|
|
|
error.subtype = 'aggregation';
|
|
|
|
error.layer = {
|
|
|
|
index: layerIndex,
|
|
|
|
type: layer.type
|
|
|
|
};
|
|
|
|
|
|
|
|
return callback(error);
|
|
|
|
}
|
|
|
|
}
|
2019-03-01 22:17:22 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-02-27 19:42:54 +08:00
|
|
|
const query = layer.options.sql_raw;
|
2019-02-27 02:19:44 +08:00
|
|
|
const resolution = layer.options.aggregation.resolution || 1;
|
|
|
|
|
|
|
|
getColumnsName(pg, query, (err, columns) => {
|
|
|
|
if (err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
2019-02-27 19:42:54 +08:00
|
|
|
const { zoom, clusterId } = params;
|
2019-02-27 02:19:44 +08:00
|
|
|
|
2019-03-01 18:21:18 +08:00
|
|
|
getClusterFeatures(pg, zoom, clusterId, columns, query, resolution, aggregation, (err, features) => {
|
2019-02-27 02:19:44 +08:00
|
|
|
if (err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
return callback(null, features);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
const SKIP_COLUMNS = {
|
|
|
|
'the_geom': true,
|
|
|
|
'the_geom_webmercator': true
|
|
|
|
};
|
|
|
|
|
|
|
|
function getColumnsName (pg, query, callback) {
|
2019-02-27 19:47:20 +08:00
|
|
|
const sql = limitedQuery({
|
2019-02-27 02:19:44 +08:00
|
|
|
query: query
|
2019-02-27 19:47:20 +08:00
|
|
|
});
|
2019-02-27 02:19:44 +08:00
|
|
|
|
2019-02-27 19:42:54 +08:00
|
|
|
debug('> getColumnsName:', sql);
|
2019-02-27 02:19:44 +08:00
|
|
|
|
|
|
|
pg.query(sql, function (err, resultSet) {
|
|
|
|
if (err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
const fields = resultSet.fields || [];
|
|
|
|
const columnNames = fields.map(field => field.name)
|
|
|
|
.filter(columnName => !SKIP_COLUMNS[columnName]);
|
|
|
|
|
|
|
|
return callback(null, columnNames);
|
|
|
|
}, true);
|
|
|
|
}
|
|
|
|
|
2019-03-01 18:21:18 +08:00
|
|
|
function getClusterFeatures (pg, zoom, clusterId, columns, query, resolution, aggregation, callback) {
|
|
|
|
let sql = clusterFeaturesQuery({
|
2019-02-27 19:42:54 +08:00
|
|
|
zoom: zoom,
|
2019-02-27 02:19:44 +08:00
|
|
|
id: clusterId,
|
|
|
|
query: query,
|
2019-02-27 19:42:54 +08:00
|
|
|
res: 256/resolution,
|
2019-02-27 02:19:44 +08:00
|
|
|
columns: columns
|
2019-02-27 19:47:20 +08:00
|
|
|
});
|
2019-02-27 02:19:44 +08:00
|
|
|
|
2019-03-01 18:21:18 +08:00
|
|
|
if (aggregation !== undefined) {
|
2019-03-01 22:45:38 +08:00
|
|
|
let { columns = [], expressions = [] } = aggregation;
|
|
|
|
|
|
|
|
if (expressions) {
|
2019-03-02 00:02:06 +08:00
|
|
|
expressions = Object.entries(expressions)
|
|
|
|
.map(([columnName, exp]) => `${exp.aggregate_function}(${exp.aggregated_column}) AS ${columnName}`);
|
2019-03-01 22:45:38 +08:00
|
|
|
}
|
2019-03-01 18:21:18 +08:00
|
|
|
|
|
|
|
sql = aggregationQuery({
|
|
|
|
columns,
|
2019-03-01 22:45:38 +08:00
|
|
|
expressions,
|
|
|
|
query: sql
|
2019-03-01 18:21:18 +08:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2019-02-27 19:42:54 +08:00
|
|
|
debug('> getClusterFeatures:', sql);
|
2019-02-27 02:19:44 +08:00
|
|
|
|
|
|
|
pg.query(sql, (err, data) => {
|
|
|
|
if (err) {
|
|
|
|
return callback(err);
|
|
|
|
}
|
|
|
|
|
|
|
|
return callback(null, data);
|
|
|
|
} , true); // use read-only transaction
|
|
|
|
}
|
|
|
|
|
|
|
|
const limitedQuery = ctx => `SELECT * FROM (${ctx.query}) __cdb_schema LIMIT 0`;
|
|
|
|
const clusterFeaturesQuery = ctx => `
|
|
|
|
WITH
|
|
|
|
_cdb_params AS (
|
|
|
|
SELECT
|
|
|
|
${gridResolution(ctx)} AS res
|
|
|
|
),
|
|
|
|
_cell AS (
|
|
|
|
SELECT
|
|
|
|
ST_MakeEnvelope(
|
|
|
|
Floor(ST_X(_cdb_query.the_geom_webmercator)/_cdb_params.res)*_cdb_params.res,
|
|
|
|
Floor(ST_Y(_cdb_query.the_geom_webmercator)/_cdb_params.res)*_cdb_params.res,
|
|
|
|
Floor(ST_X(_cdb_query.the_geom_webmercator)/_cdb_params.res + 1)*_cdb_params.res,
|
|
|
|
Floor(ST_Y(_cdb_query.the_geom_webmercator)/_cdb_params.res + 1)*_cdb_params.res,
|
|
|
|
3857
|
|
|
|
) AS bbox
|
|
|
|
FROM (${ctx.query}) _cdb_query, _cdb_params
|
|
|
|
WHERE _cdb_query.cartodb_id = ${ctx.id}
|
|
|
|
)
|
2019-02-27 19:42:54 +08:00
|
|
|
SELECT
|
|
|
|
${ctx.columns.join(', ')}
|
|
|
|
FROM (
|
|
|
|
SELECT _cdb_query.*
|
|
|
|
FROM _cell, (${ctx.query}) _cdb_query
|
|
|
|
WHERE ST_Intersects(_cdb_query.the_geom_webmercator, _cell.bbox)
|
2019-02-27 02:19:44 +08:00
|
|
|
) __cdb_non_geoms_query
|
|
|
|
`;
|
|
|
|
|
|
|
|
const gridResolution = ctx => {
|
|
|
|
const minimumResolution = 2*Math.PI*6378137/Math.pow(2,38);
|
2019-02-27 19:42:54 +08:00
|
|
|
const pixelSize = `CDB_XYZ_Resolution(${ctx.zoom})`;
|
2019-02-27 02:19:44 +08:00
|
|
|
return `GREATEST(${256/ctx.res}*${pixelSize}, ${minimumResolution})::double precision`;
|
|
|
|
};
|
2019-03-01 18:21:18 +08:00
|
|
|
|
|
|
|
const aggregationQuery = ctx => `
|
|
|
|
SELECT
|
|
|
|
count(1) as _cdb_feature_count
|
|
|
|
${ctx.columns.length ? `,${ctx.columns.join(', ')}` : ''}
|
2019-03-01 22:17:22 +08:00
|
|
|
${ctx.expressions.length ? `,${ctx.expressions.join(', ')}` : ''}
|
2019-03-01 18:21:18 +08:00
|
|
|
FROM (${ctx.query}) __cdb_aggregation
|
|
|
|
${ctx.columns.length ? `GROUP BY ${ctx.columns.join(', ')}` : ''}
|
|
|
|
`;
|
2019-03-12 00:01:13 +08:00
|
|
|
|
|
|
|
// TODO: update when https://github.com/CartoDB/Windshaft-cartodb/pull/1082 is merged
|
|
|
|
function hasAggregationLayer (mapConfig, layerIndex) {
|
|
|
|
const layer = mapConfig.getLayer(layerIndex);
|
|
|
|
|
|
|
|
if (typeof layer.options.aggregation === 'boolean') {
|
|
|
|
return layer.options.aggregation;
|
|
|
|
}
|
|
|
|
|
|
|
|
return mapConfig.isAggregationLayer(layerIndex);
|
|
|
|
}
|
2019-03-12 00:06:04 +08:00
|
|
|
|
|
|
|
function hasColumns (columns) {
|
|
|
|
return Array.isArray(columns) && columns.length;
|
|
|
|
}
|
2019-03-12 00:14:07 +08:00
|
|
|
|
|
|
|
function isValidExpression (expressions) {
|
|
|
|
const invalidTypes = ['string', 'number', 'boolean'];
|
|
|
|
|
|
|
|
return expressions !== null && !Array.isArray(expressions) && !invalidTypes.includes(typeof expressions);
|
|
|
|
}
|