CartoDB/Windshaft

View on GitHub
lib/backends/attributes.js

Summary

Maintainability
A
2 hrs
Test Coverage
'use strict';

const PSQL = require('cartodb-psql');
const parseDbParams = require('../renderers/renderer-params');
const Timer = require('../stats/timer');
const SubstitutionTokens = require('cartodb-query-tables').utils.substitutionTokens;

module.exports = class AttributesBackend {
    // Gets attributes for a given layer feature
    //
    // * token - MapConfig identifier
    // * layer - Layer number
    // * fid   - Feature identifier
    //
    // The referenced layer must have been configured
    // to allow for attributes fetching.
    // See https://github.com/CartoDB/Windshaft/wiki/MapConfig-1.1.0
    //
    // @param testMode if true generates a call returning requested
    //                 columns plus the fid column of the first record
    //                 it is only meant to check validity of configuration
    //
    getFeatureAttributes (mapConfigProvider, params, testMode, callback) {
        const timer = new Timer();

        mapConfigProvider.getMapConfig((err, mapConfig) => {
            if (err) {
                return callback(err);
            }

            const layer = mapConfig.getLayer(params.layer);
            if (!layer) {
                const error = new Error(`Map ${params.token} has no layer number ${params.layer}`);
                return callback(error);
            }

            timer.start('getAttributes');
            const attributes = layer.options.attributes;
            if (!attributes) {
                const error = new Error(`Layer ${params.layer} has no exposed attributes`);
                return callback(error);
            }

            const dbParams = Object.assign(
                {},
                parseDbParams(params),
                mapConfig.getLayerDatasource(params.layer)
            );

            let pg;
            try {
                pg = new PSQL(dbParams);
            } catch (error) {
                return callback(error);
            }

            const sql = getSQL(attributes, pg, layer, params, testMode);

            pg.query(sql, (err, data) => {
                timer.end('getAttributes');

                if (err) {
                    return callback(err);
                }

                if (testMode) {
                    return callback(null, null, timer.getTimes());
                }

                const featureAttributes = extractFeatureAttributes(data.rows);

                if (!featureAttributes) {
                    const rowsLengthError = new Error(
                        `Multiple features (${data.rows.length}) identified by ` +
                        `'${attributes.id}' = ${params.fid} in layer ${params.layer}`
                    );
                    if (!data.rows.length) {
                        rowsLengthError.http_status = 404;
                    }
                    return callback(rowsLengthError);
                }

                return callback(null, featureAttributes, timer.getTimes());
            }, true); // use read-only transaction
        });
    }
};

function getSQL (attributes, pg, layer, params, testMode) {
    // NOTE: we're assuming that the presence of "attributes"
    //       means it is well-formed (should be checked at
    //       MapConfig construction/validation time).

    // prepare columns with double quotes
    let quotedAttCols = attributes.columns
        .map(n => pg.quoteIdentifier(n))
        .join(',');

    if (testMode) {
        quotedAttCols += ',' + pg.quoteIdentifier(attributes.id);
    }

    const layerSql = SubstitutionTokens.replaceXYZ(layer.options.sql);

    let sql = `SELECT ${quotedAttCols} FROM (${layerSql}) AS _windshaft_subquery`;
    if (!testMode) {
        sql += ` WHERE ${pg.quoteIdentifier(attributes.id)} = ${params.fid}`;
    } else {
        sql += ' LIMIT 1';
    }

    return sql;
}

function extractFeatureAttributes (data) {
    if (data.length === 1) {
        return data[0];
    }

    if (data.length > 1) {
        // If we receive more than one row for the id (usually `cartodb_id`)
        // we want to check that the attributes received are truly different before
        // returning an error.
        const uniqueAttributes = [...new Set(data.map(r => JSON.stringify(r)))];
        if (uniqueAttributes.length === 1) {
            return data[0];
        }
    }

    return null;
}