Files
redash/client/app/services/query.js
Omer Lachish 2a37cb31d9 Fix max-age issues on textless endpoint (#3477)
* max_age should default to -1

* pass maxAge along to `execute`
2019-02-21 16:02:06 +02:00

570 lines
14 KiB
JavaScript

import moment from 'moment';
import debug from 'debug';
import Mustache from 'mustache';
import {
zipObject, isEmpty, map, filter, includes, union, uniq, has,
isNull, isUndefined, isArray, isObject, identity, extend, each,
} from 'lodash';
Mustache.escape = identity; // do not html-escape values
export let Query = null; // eslint-disable-line import/no-mutable-exports
const logger = debug('redash:services:query');
const DATETIME_FORMATS = {
// eslint-disable-next-line quote-props
'date': 'YYYY-MM-DD',
'date-range': 'YYYY-MM-DD',
'datetime-local': 'YYYY-MM-DD HH:mm',
'datetime-range': 'YYYY-MM-DD HH:mm',
'datetime-with-seconds': 'YYYY-MM-DD HH:mm:ss',
'datetime-range-with-seconds': 'YYYY-MM-DD HH:mm:ss',
};
function normalizeNumericValue(value, defaultValue = null) {
const result = parseFloat(value);
return isFinite(result) ? result : defaultValue;
}
function collectParams(parts) {
let parameters = [];
parts.forEach((part) => {
if (part[0] === 'name' || part[0] === '&') {
parameters.push(part[1].split('.')[0]);
} else if (part[0] === '#') {
parameters = union(parameters, collectParams(part[4]));
}
});
return parameters;
}
function isDateParameter(paramType) {
return includes(['date', 'datetime-local', 'datetime-with-seconds'], paramType);
}
function isDateRangeParameter(paramType) {
return includes(['date-range', 'datetime-range', 'datetime-range-with-seconds'], paramType);
}
export class Parameter {
constructor(parameter) {
this.title = parameter.title;
this.name = parameter.name;
this.type = parameter.type;
this.useCurrentDateTime = parameter.useCurrentDateTime;
this.global = parameter.global; // backward compatibility in Widget service
this.enumOptions = parameter.enumOptions;
this.queryId = parameter.queryId;
// Used for meta-parameters (i.e. dashboard-level params)
this.locals = [];
// validate value and init internal state
this.setValue(parameter.value);
// Used for URL serialization
Object.defineProperty(this, 'urlPrefix', {
configurable: true,
enumerable: false, // don't save it
writable: true,
value: 'p_',
});
}
clone() {
return new Parameter(this);
}
get isEmpty() {
return isNull(this.getValue());
}
getValue() {
return this.constructor.getValue(this);
}
static getValue(param) {
const { value, type, useCurrentDateTime } = param;
const isEmptyValue = isNull(value) || isUndefined(value) || (value === '');
if (isEmptyValue) {
if (
includes(['date', 'datetime-local', 'datetime-with-seconds'], type) &&
useCurrentDateTime
) {
return moment().format(DATETIME_FORMATS[type]);
}
return null; // normalize empty value
}
if (type === 'number') {
return normalizeNumericValue(value, null); // normalize empty value
}
return value;
}
setValue(value) {
if (isDateRangeParameter(this.type)) {
this.value = null;
this.$$value = null;
if (isObject(value) && !isArray(value)) {
value = [value.start, value.end];
}
if (isArray(value) && (value.length === 2)) {
value = [moment(value[0]), moment(value[1])];
if (value[0].isValid() && value[1].isValid()) {
this.value = {
start: value[0].format(DATETIME_FORMATS[this.type]),
end: value[1].format(DATETIME_FORMATS[this.type]),
};
this.$$value = value;
}
}
} else if (isDateParameter(this.type)) {
this.value = null;
this.$$value = null;
value = moment(value);
if (value.isValid()) {
this.value = value.format(DATETIME_FORMATS[this.type]);
this.$$value = value;
}
} else if (this.type === 'number') {
this.value = value;
this.$$value = normalizeNumericValue(value, null);
} else {
this.value = value;
this.$$value = value;
}
if (isArray(this.locals)) {
each(this.locals, (local) => {
local.setValue(this.value);
});
}
return this;
}
get normalizedValue() {
return this.$$value;
}
// TODO: Remove this property when finally moved to React
get ngModel() {
return this.normalizedValue;
}
set ngModel(value) {
this.setValue(value);
}
toUrlParams() {
if (this.isEmpty) {
return {};
}
const prefix = this.urlPrefix;
if (isDateRangeParameter(this.type)) {
return {
[`${prefix}${this.name}.start`]: this.value.start,
[`${prefix}${this.name}.end`]: this.value.end,
};
}
return {
[`${prefix}${this.name}`]: this.value,
};
}
fromUrlParams(query) {
const prefix = this.urlPrefix;
if (isDateRangeParameter(this.type)) {
const keyStart = `${prefix}${this.name}.start`;
const keyEnd = `${prefix}${this.name}.end`;
if (has(query, keyStart) && has(query, keyEnd)) {
this.setValue([query[keyStart], query[keyEnd]]);
}
} else {
const key = `${prefix}${this.name}`;
if (has(query, key)) {
this.setValue(query[key]);
}
}
}
toQueryTextFragment() {
if (isDateRangeParameter(this.type)) {
return `{{ ${this.name}.start }} {{ ${this.name}.end }}`;
}
return `{{ ${this.name} }}`;
}
}
class Parameters {
constructor(query, queryString) {
this.query = query;
this.updateParameters();
this.initFromQueryString(queryString);
}
parseQuery() {
let parameters = [];
try {
const parts = Mustache.parse(this.query.query);
parameters = uniq(collectParams(parts));
} catch (e) {
logger('Failed parsing parameters: ', e);
// Return current parameters so we don't reset the list
parameters = map(this.query.options.parameters, i => i.name);
}
return parameters;
}
updateParameters() {
if (this.query.query === this.cachedQueryText) {
return;
}
this.cachedQueryText = this.query.query;
const parameterNames = this.parseQuery();
this.query.options.parameters = this.query.options.parameters || [];
const parametersMap = {};
this.query.options.parameters.forEach((param) => {
parametersMap[param.name] = param;
});
parameterNames.forEach((param) => {
if (!has(parametersMap, param)) {
this.query.options.parameters.push(new Parameter({
title: param,
name: param,
type: 'text',
value: null,
global: false,
}));
}
});
const parameterExists = p => includes(parameterNames, p.name);
this.query.options.parameters = this.query.options.parameters.filter(parameterExists).map(p => new Parameter(p));
}
initFromQueryString(query) {
this.get().forEach((param) => {
param.fromUrlParams(query);
});
}
get() {
this.updateParameters();
return this.query.options.parameters;
}
add(parameterDef) {
this.query.options.parameters = this.query.options.parameters
.filter(p => p.name !== parameterDef.name);
const param = new Parameter(parameterDef);
this.query.options.parameters.push(param);
return param;
}
getMissing() {
return map(filter(this.get(), p => p.isEmpty), i => i.title);
}
isRequired() {
return !isEmpty(this.get());
}
getValues() {
const params = this.get();
return zipObject(map(params, i => i.name), map(params, i => i.getValue()));
}
}
function QueryResultErrorFactory($q) {
class QueryResultError {
constructor(errorMessage) {
this.errorMessage = errorMessage;
this.updatedAt = moment.utc();
}
getUpdatedAt() {
return this.updatedAt;
}
getError() {
return this.errorMessage;
}
toPromise() {
return $q.reject(this);
}
// eslint-disable-next-line class-methods-use-this
getStatus() {
return 'failed';
}
// eslint-disable-next-line class-methods-use-this
getData() {
return null;
}
// eslint-disable-next-line class-methods-use-this
getLog() {
return null;
}
// eslint-disable-next-line class-methods-use-this
getChartData() {
return null;
}
}
return QueryResultError;
}
function QueryResource(
$resource,
$http,
$location,
$q,
currentUser,
QueryResultError,
QueryResult,
) {
const QueryService = $resource(
'api/queries/:id',
{ id: '@id' },
{
recent: {
method: 'get',
isArray: true,
url: 'api/queries/recent',
},
archive: {
method: 'get',
isArray: false,
url: 'api/queries/archive',
},
query: {
isArray: false,
},
myQueries: {
method: 'get',
isArray: false,
url: 'api/queries/my',
},
fork: {
method: 'post',
isArray: false,
url: 'api/queries/:id/fork',
params: { id: '@id' },
},
resultById: {
method: 'get',
isArray: false,
url: 'api/queries/:id/results.json',
},
dropdownOptions: {
method: 'get',
isArray: true,
url: 'api/queries/:id/dropdown',
},
favorites: {
method: 'get',
isArray: false,
url: 'api/queries/favorites',
},
favorite: {
method: 'post',
isArray: false,
url: 'api/queries/:id/favorite',
transformRequest: [() => ''], // body not needed
},
unfavorite: {
method: 'delete',
isArray: false,
url: 'api/queries/:id/favorite',
transformRequest: [() => ''], // body not needed
},
},
);
QueryService.newQuery = function newQuery() {
return new QueryService({
query: '',
name: 'New Query',
schedule: null,
user: currentUser,
options: {},
});
};
QueryService.format = function formatQuery(syntax, query) {
if (syntax === 'json') {
try {
const formatted = JSON.stringify(JSON.parse(query), ' ', 4);
return $q.resolve(formatted);
} catch (err) {
return $q.reject(String(err));
}
} else if (syntax === 'sql') {
return $http.post('api/queries/format', { query }).then(response => response.data.query);
} else {
return $q.reject('Query formatting is not supported for your data source syntax.');
}
};
QueryService.prototype.getSourceLink = function getSourceLink() {
return `/queries/${this.id}/source`;
};
QueryService.prototype.isNew = function isNew() {
return this.id === undefined;
};
QueryService.prototype.hasDailySchedule = function hasDailySchedule() {
return this.schedule && this.schedule.match(/\d\d:\d\d/) !== null;
};
QueryService.prototype.scheduleInLocalTime = function scheduleInLocalTime() {
const parts = this.schedule.split(':');
return moment
.utc()
.hour(parts[0])
.minute(parts[1])
.local()
.format('HH:mm');
};
QueryService.prototype.hasResult = function hasResult() {
return !!(this.latest_query_data || this.latest_query_data_id);
};
QueryService.prototype.paramsRequired = function paramsRequired() {
return this.getParameters().isRequired();
};
QueryService.prototype.prepareQueryResultExecution = function prepareQueryResultExecution(execute, maxAge) {
if (!this.query) {
return new QueryResultError("Can't execute empty query.");
}
const parameters = this.getParameters();
const missingParams = parameters.getMissing();
if (missingParams.length > 0) {
let paramsWord = 'parameter';
let valuesWord = 'value';
if (missingParams.length > 1) {
paramsWord = 'parameters';
valuesWord = 'values';
}
return new QueryResult({
job: {
error: `missing ${valuesWord} for ${missingParams.join(', ')} ${paramsWord}.`,
status: 4,
},
});
}
if (parameters.isRequired()) {
// Need to clear latest results, to make sure we don't use results for different params.
this.latest_query_data = null;
this.latest_query_data_id = null;
}
if (this.latest_query_data && maxAge !== 0) {
if (!this.queryResult) {
this.queryResult = new QueryResult({
query_result: this.latest_query_data,
});
}
} else if (this.latest_query_data_id && maxAge !== 0) {
if (!this.queryResult) {
this.queryResult = QueryResult.getById(this.latest_query_data_id);
}
} else if (this.data_source_id) {
this.queryResult = execute();
} else {
return new QueryResultError('Please select data source to run this query.');
}
return this.queryResult;
};
QueryService.prototype.getQueryResult = function getQueryResult(maxAge) {
const execute = () => QueryResult.getByQueryId(this.id, this.getParameters().getValues(), maxAge);
return this.prepareQueryResultExecution(execute, maxAge);
};
QueryService.prototype.getQueryResultByText = function getQueryResultByText(maxAge, selectedQueryText) {
const queryText = selectedQueryText || this.query;
const parameters = this.getParameters().getValues();
const execute = () => QueryResult.get(this.data_source_id, queryText, parameters, maxAge, this.id);
return this.prepareQueryResultExecution(execute, maxAge);
};
QueryService.prototype.getUrl = function getUrl(source, hash) {
let url = `queries/${this.id}`;
if (source) {
url += '/source';
}
let params = {};
if (this.getParameters().isRequired()) {
this.getParametersDefs().forEach((param) => {
extend(params, param.toUrlParams());
});
}
params = map(params, (value, name) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('&');
if (params !== '') {
url += `?${params}`;
}
if (hash) {
url += `#${hash}`;
}
return url;
};
QueryService.prototype.getQueryResultPromise = function getQueryResultPromise() {
return this.getQueryResult().toPromise();
};
QueryService.prototype.getParameters = function getParameters() {
if (!this.$parameters) {
this.$parameters = new Parameters(this, $location.search());
}
return this.$parameters;
};
QueryService.prototype.getParametersDefs = function getParametersDefs() {
return this.getParameters().get();
};
return QueryService;
}
export default function init(ngModule) {
ngModule.factory('QueryResultError', QueryResultErrorFactory);
ngModule.factory('Query', QueryResource);
ngModule.run(($injector) => {
Query = $injector.get('Query');
});
}
init.init = true;