client/app/services/query.js (337 lines of code) (raw):
import moment from "moment";
import debug from "debug";
import Mustache from "mustache";
import { axios } from "@/services/axios";
import {
zipObject,
isEmpty,
isArray,
map,
filter,
includes,
union,
uniq,
has,
identity,
extend,
each,
some,
clone,
find,
} from "lodash";
import location from "@/services/location";
import { Parameter, createParameter } from "./parameters";
import { currentUser } from "./auth";
import QueryResult from "./query-result";
import localOptions from "@/lib/localOptions";
Mustache.escape = identity; // do not html-escape values
const logger = debug("redash:services:query");
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;
}
export class Query {
constructor(query) {
extend(this, query);
if (!has(this, "options")) {
this.options = {};
}
this.options.apply_auto_limit = !!this.options.apply_auto_limit;
if (!isArray(this.options.parameters)) {
this.options.parameters = [];
}
}
isNew() {
return this.id === undefined;
}
hasDailySchedule() {
return this.schedule && this.schedule.match(/\d\d:\d\d/) !== null;
}
scheduleInLocalTime() {
const parts = this.schedule.split(":");
return moment
.utc()
.hour(parts[0])
.minute(parts[1])
.local()
.format("HH:mm");
}
hasResult() {
return !!(this.latest_query_data || this.latest_query_data_id);
}
paramsRequired() {
return this.getParameters().isRequired();
}
hasParameters() {
return this.getParametersDefs().length > 0;
}
prepareQueryResultExecution(execute, maxAge) {
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.id, this.latest_query_data_id);
}
} else {
this.queryResult = execute();
}
return this.queryResult;
}
getQueryResult(maxAge) {
const execute = () =>
QueryResult.getByQueryId(this.id, this.getParameters().getExecutionValues(), this.getAutoLimit(), maxAge);
return this.prepareQueryResultExecution(execute, maxAge);
}
getQueryResultByText(maxAge, selectedQueryText) {
const queryText = selectedQueryText || this.query;
if (!queryText) {
return new QueryResultError("Can't execute empty query.");
}
const parameters = this.getParameters().getExecutionValues({ joinListValues: true });
const execute = () =>
QueryResult.get(this.data_source_id, queryText, parameters, this.getAutoLimit(), maxAge, this.id);
return this.prepareQueryResultExecution(execute, maxAge);
}
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());
});
}
Object.keys(params).forEach(key => params[key] == null && delete params[key]);
params = map(params, (value, name) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join("&");
if (params !== "") {
url += `?${params}`;
}
if (hash) {
url += `#${hash}`;
}
return url;
}
getQueryResultPromise() {
return this.getQueryResult().toPromise();
}
getParameters() {
if (!this.$parameters) {
this.$parameters = new Parameters(this, location.search);
}
return this.$parameters;
}
getAutoLimit() {
return this.options.apply_auto_limit;
}
getParametersDefs(update = true) {
return this.getParameters().get(update);
}
favorite() {
return Query.favorite(this);
}
unfavorite() {
return Query.unfavorite(this);
}
clone() {
const newQuery = clone(this);
newQuery.$parameters = null;
newQuery.getParameters();
return newQuery;
}
}
class Parameters {
constructor(query, queryString) {
this.query = query;
this.updateParameters();
this.initFromQueryString(queryString);
}
parseQuery() {
const fallback = () => map(this.query.options.parameters, i => i.name);
let parameters = [];
if (this.query.query !== undefined) {
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 = fallback();
}
} else {
parameters = fallback();
}
return parameters;
}
updateParameters(update) {
if (this.query.query === this.cachedQueryText) {
const parameters = this.query.options.parameters;
const hasUnprocessedParameters = find(parameters, p => !(p instanceof Parameter));
if (hasUnprocessedParameters) {
this.query.options.parameters = map(parameters, p =>
p instanceof Parameter ? p : createParameter(p, this.query.id)
);
}
return;
}
this.cachedQueryText = this.query.query;
const parameterNames = update ? this.parseQuery() : map(this.query.options.parameters, p => p.name);
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(
createParameter({
title: param,
name: param,
type: "text",
value: null,
global: false,
})
);
}
});
const parameterExists = p => includes(parameterNames, p.name);
const parameters = this.query.options.parameters;
this.query.options.parameters = parameters
.filter(parameterExists)
.map(p => (p instanceof Parameter ? p : createParameter(p, this.query.id)));
}
initFromQueryString(query) {
this.get().forEach(param => {
param.fromUrlParams(query);
});
}
get(update = true) {
this.updateParameters(update);
return this.query.options.parameters;
}
add(parameterDef) {
this.query.options.parameters = this.query.options.parameters.filter(p => p.name !== parameterDef.name);
const param = createParameter(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());
}
getExecutionValues(extra = {}) {
const params = this.get();
return zipObject(
map(params, i => i.name),
map(params, i => i.getExecutionValue(extra))
);
}
hasPendingValues() {
return some(this.get(), p => p.hasPendingValue);
}
applyPendingValues() {
each(this.get(), p => p.applyPendingValue());
}
toUrlParams() {
if (this.get().length === 0) {
return "";
}
const params = Object.assign(...this.get().map(p => p.toUrlParams()));
Object.keys(params).forEach(key => params[key] == null && delete params[key]);
return Object.keys(params)
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`)
.join("&");
}
}
export class QueryResultError {
constructor(errorMessage) {
this.errorMessage = errorMessage;
this.updatedAt = moment.utc();
}
getUpdatedAt() {
return this.updatedAt;
}
getError() {
return this.errorMessage;
}
toPromise() {
return Promise.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;
}
}
const getQuery = query => new Query(query);
const saveOrCreateUrl = data => (data.id ? `api/queries/${data.id}` : "api/queries");
const mapResults = data => ({ ...data, results: map(data.results, getQuery) });
const QueryService = {
query: params => axios.get("api/queries", { params }).then(mapResults),
get: data => axios.get(`api/queries/${data.id}`, data).then(getQuery),
save: data => axios.post(saveOrCreateUrl(data), data).then(getQuery),
delete: data => axios.delete(`api/queries/${data.id}`),
recent: params => axios.get(`api/queries/recent`, { params }).then(data => map(data, getQuery)),
archive: params => axios.get(`api/queries/archive`, { params }).then(mapResults),
myQueries: params => axios.get("api/queries/my", { params }).then(mapResults),
fork: ({ id }) => axios.post(`api/queries/${id}/fork`, { id }).then(getQuery),
resultById: data => axios.get(`api/queries/${data.id}/results.json`),
asDropdown: ({id, params}) => axios.get(`api/queries/${id}/dropdown`, {params: params}),
associatedDropdown: ({ queryId, dropdownQueryId, params }) =>
axios.get(`api/queries/${queryId}/dropdowns/${dropdownQueryId}`, {params: params}),
favorites: params => axios.get("api/queries/favorites", { params }).then(mapResults),
favorite: data => axios.post(`api/queries/${data.id}/favorite`),
unfavorite: data => axios.delete(`api/queries/${data.id}/favorite`),
};
QueryService.newQuery = function newQuery() {
return new Query({
query: "",
name: "New Query",
schedule: null,
user: currentUser,
options: { apply_auto_limit: localOptions.get("applyAutoLimit", true) },
tags: [],
can_edit: true,
});
};
extend(Query, QueryService);