Skip to content
Snippets Groups Projects
Commit b5740c73 authored by Tom Robinson's avatar Tom Robinson Committed by GitHub
Browse files

Merge pull request #3779 from metabase/json-download

Fix params for saved card csv downloading + JSON download widget
parents d2e461cc bd301f85
No related branches found
No related tags found
No related merge requests found
Showing
with 278 additions and 129 deletions
......@@ -115,33 +115,54 @@ NSString *BaseURL() {
[self.webView.mainFrame loadRequest:request];
}
- (void)saveCSV:(NSString *)datasetQuery {
- (void)downloadWithMethod:(NSString *)methodString url:(NSString *)urlString params:(NSDictionary *)paramsDict extensions:(NSArray *)extensions {
NSSavePanel *savePanel = [NSSavePanel savePanel];
savePanel.allowedFileTypes = @[@"csv"];
savePanel.allowsOtherFileTypes = NO;
savePanel.extensionHidden = NO;
savePanel.showsTagField = NO;
if ([extensions count] > 0) {
savePanel.allowedFileTypes = extensions;
savePanel.allowsOtherFileTypes = NO;
}
NSString *downloadsDirectory = NSSearchPathForDirectoriesInDomains(NSDownloadsDirectory, NSUserDomainMask, YES)[0];
savePanel.directoryURL = [NSURL URLWithString:downloadsDirectory];
// TODO: either figure out how to pull default filename from the Content-Disposition header or pass it in from JS land.
NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
dateFormatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
dateFormatter.dateFormat = @"yyyy-MM-dd'T'HH_mm_ss";
savePanel.nameFieldStringValue = [NSString stringWithFormat:@"query_result_%@", [dateFormatter stringFromDate:[NSDate date]]];
if ([savePanel runModal] == NSFileHandlingPanelOKButton) {
NSLog(@"Will save CSV at: %@", savePanel.URL);
NSLog(@"Will save file at: %@", savePanel.URL);
NSURL *url = [NSURL URLWithString:@"/api/dataset/csv" relativeToURL:[NSURL URLWithString:BaseURL()]];
NSString *postBody = [@"query=" stringByAppendingString:datasetQuery];
NSData *data = [postBody dataUsingEncoding:NSUTF8StringEncoding];
NSMutableURLRequest *csvRequest = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:10.0f];
csvRequest.HTTPMethod = @"POST";
[csvRequest setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"];
[csvRequest setValue:[NSString stringWithFormat:@"%lu", (NSUInteger)data.length] forHTTPHeaderField:@"Content-Length"];
csvRequest.HTTPBody = data;
[NSURLConnection sendAsynchronousRequest:csvRequest queue:[[NSOperationQueue alloc] init] completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) {
NSString *method = [methodString uppercaseString];
NSURL *url = [NSURL URLWithString:urlString relativeToURL:[NSURL URLWithString:BaseURL()]];
NSMutableString *query = [NSMutableString string];
for (NSString* key in paramsDict) {
NSString* value = [paramsDict objectForKey:key];
[query appendFormat:@"%@=%@",
[key stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]],
[value stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]];
}
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:10.0f];
request.HTTPMethod = [method uppercaseString];
if ([query length] > 0) {
if ([request.HTTPMethod isEqualToString:@"POST"] || [request.HTTPMethod isEqualToString:@"PUT"]) {
NSData *data = [query dataUsingEncoding:NSUTF8StringEncoding];
[request setValue:@"application/x-www-form-urlencoded" forHTTPHeaderField:@"Content-Type"];
[request setValue:[NSString stringWithFormat:@"%lu", (NSUInteger)data.length] forHTTPHeaderField:@"Content-Length"];
request.HTTPBody = data;
} else {
[request setURL:[NSURL URLWithString:[NSString stringWithFormat:@"%@?%@", urlString, query] relativeToURL:[NSURL URLWithString:BaseURL()]]];
}
}
[NSURLConnection sendAsynchronousRequest:request queue:[[NSOperationQueue alloc] init] completionHandler:^(NSURLResponse *response, NSData *data, NSError *connectionError) {
NSError *writeError = nil;
[data writeToURL:savePanel.URL options:NSDataWritingAtomic error:&writeError];
......@@ -165,11 +186,11 @@ NSString *BaseURL() {
};
// custom functions for OS X integration are available to the frontend as properties of window.OSX
context[@"OSX"] = @{@"saveCSV": ^(JSValue *datasetQuery){
[self saveCSV:datasetQuery.description];
}, @"resetPassword": ^(){
context[@"OSX"] = @{@"download": ^(JSValue *method, JSValue *url, JSValue *params, JSValue *extensions) {
[self downloadWithMethod:[method toString] url:[url toString] params:[params toDictionary] extensions:[extensions toArray]];
}, @"resetPassword": ^(){
[self resetPassword:nil];
}};
}};
}
......
......@@ -4,7 +4,7 @@ import { Link } from "react-router";
import FormLabel from "../components/FormLabel.jsx";
import FormInput from "../components/FormInput.jsx";
import FormTextArea from "../components/FormTextArea.jsx";
import FieldSet from "../components/FieldSet.jsx";
import FieldSet from "metabase/components/FieldSet.jsx";
import PartialQueryBuilder from "../components/PartialQueryBuilder.jsx";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx";
......
......@@ -4,7 +4,7 @@ import { Link } from "react-router";
import FormLabel from "../components/FormLabel.jsx";
import FormInput from "../components/FormInput.jsx";
import FormTextArea from "../components/FormTextArea.jsx";
import FieldSet from "../components/FieldSet.jsx";
import FieldSet from "metabase/components/FieldSet.jsx";
import PartialQueryBuilder from "../components/PartialQueryBuilder.jsx";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx";
......
import React, { Component, PropTypes } from "react";
import Icon from "metabase/components/Icon.jsx";
import cx from "classnames";
import _ from "underscore";
const BUTTON_VARIANTS = [
"small",
"medium",
"large",
"primary",
"warning",
"cancel",
"purple",
"borderless"
];
const Button = ({ className, icon, children, ...props }) => {
let variantClasses = BUTTON_VARIANTS.filter(variant => props[variant]).map(variant => "Button--" + variant);
return (
<button
{..._.omit(props, ...variantClasses)}
className={cx("Button", className, variantClasses)}
>
<div className="flex layout-centered">
{ icon && <Icon name={icon} size={14} className="mr1" />}
<div>{children}</div>
</div>
</button>
);
}
Button.propTypes = {
className: PropTypes.string,
icon: PropTypes.string,
children: PropTypes.any,
small: PropTypes.bool,
medium: PropTypes.bool,
large: PropTypes.bool,
primary: PropTypes.bool,
warning: PropTypes.bool,
cancel: PropTypes.bool,
purple: PropTypes.bool,
borderless: PropTypes.bool
};
export default Button;
import React, { Component, PropTypes } from "react";
import ReactDOM from "react-dom";
import Button from "metabase/components/Button.jsx";
const DownloadButton = ({ className, style, children, method, url, params, extensions, ...props }) =>
<form className={className} style={style} method={method} action={url}>
{ Object.entries(params).map(([name, value]) =>
<input key={name} type="hidden" name={name} value={value} />
)}
<Button
onClick={(e) => {
if (window.OSX) {
// prevent form from being submitted normally
e.preventDefault();
// download using the API provided by the OS X app
window.OSX.download(method, url, params, extensions);
}
}}
{...props}
>
{children}
</Button>
</form>
DownloadButton.propTypes = {
className: PropTypes.string,
style: PropTypes.object,
url: PropTypes.string.isRequired,
method: PropTypes.string,
params: PropTypes.object,
icon: PropTypes.string,
extensions: PropTypes.array,
};
DownloadButton.defaultProps = {
icon: "downarrow",
method: "POST",
params: {},
extensions: []
};
export default DownloadButton;
import React, { Component, PropTypes } from "react";
import cx from "classnames";
export default class FieldSet extends Component {
static propTypes = {};
static defaultProps = {
border: "border-brand"
className: "border-brand"
};
render() {
const { children, legend, border } = this.props;
const { className, children, legend } = this.props;
return (
<fieldset className={"px2 pb2 bordered rounded " + border}>
<fieldset className={cx(className, "px2 pb2 bordered rounded")}>
{legend && <legend className="h5 text-bold text-uppercase px1" style={{ marginLeft: "-0.5rem" }}>{legend}</legend>}
{children}
</fieldset>
......
......@@ -56,6 +56,7 @@ export var ICON_PATHS = {
dashboard: 'M32,29 L32,4 L32,0 L0,0 L0,8 L28,8 L28,28 L4,28 L4,8 L0,8 L0,29.5 L0,32 L32,32 L32,29 Z M7.27272727,18.9090909 L17.4545455,18.9090909 L17.4545455,23.2727273 L7.27272727,23.2727273 L7.27272727,18.9090909 Z M7.27272727,12.0909091 L24.7272727,12.0909091 L24.7272727,16.4545455 L7.27272727,16.4545455 L7.27272727,12.0909091 Z M20.3636364,18.9090909 L24.7272727,18.9090909 L24.7272727,23.2727273 L20.3636364,23.2727273 L20.3636364,18.9090909 Z',
dashboards: 'M17,5.49100518 L17,10.5089948 C17,10.7801695 17.2276528,11 17.5096495,11 L26.4903505,11 C26.7718221,11 27,10.7721195 27,10.5089948 L27,5.49100518 C27,5.21983051 26.7723472,5 26.4903505,5 L17.5096495,5 C17.2281779,5 17,5.22788048 17,5.49100518 Z M18.5017326,14 C18.225722,14 18,13.77328 18,13.4982674 L18,26.5017326 C18,26.225722 18.22672,26 18.5017326,26 L5.49826741,26 C5.77427798,26 6,26.22672 6,26.5017326 L6,13.4982674 C6,13.774278 5.77327997,14 5.49826741,14 L18.5017326,14 Z M14.4903505,6 C14.2278953,6 14,5.78028538 14,5.49100518 L14,10.5089948 C14,10.2167107 14.2224208,10 14.4903505,10 L5.50964952,10 C5.77210473,10 6,10.2197146 6,10.5089948 L6,5.49100518 C6,5.78328929 5.77757924,6 5.50964952,6 L14.4903505,6 Z M26.5089948,22 C26.2251201,22 26,21.7774008 26,21.4910052 L26,26.5089948 C26,26.2251201 26.2225992,26 26.5089948,26 L21.4910052,26 C21.7748799,26 22,26.2225992 22,26.5089948 L22,21.4910052 C22,21.7748799 21.7774008,22 21.4910052,22 L26.5089948,22 Z M26.5089948,14 C26.2251201,14 26,13.7774008 26,13.4910052 L26,18.5089948 C26,18.2251201 26.2225992,18 26.5089948,18 L21.4910052,18 C21.7748799,18 22,18.2225992 22,18.5089948 L22,13.4910052 C22,13.7748799 21.7774008,14 21.4910052,14 L26.5089948,14 Z M26.4903505,6 C26.2278953,6 26,5.78028538 26,5.49100518 L26,10.5089948 C26,10.2167107 26.2224208,10 26.4903505,10 L17.5096495,10 C17.7721047,10 18,10.2197146 18,10.5089948 L18,5.49100518 C18,5.78328929 17.7775792,6 17.5096495,6 L26.4903505,6 Z M5,13.4982674 L5,26.5017326 C5,26.7769181 5.21990657,27 5.49826741,27 L18.5017326,27 C18.7769181,27 19,26.7800934 19,26.5017326 L19,13.4982674 C19,13.2230819 18.7800934,13 18.5017326,13 L5.49826741,13 C5.22308192,13 5,13.2199066 5,13.4982674 Z M5,5.49100518 L5,10.5089948 C5,10.7801695 5.22765279,11 5.50964952,11 L14.4903505,11 C14.7718221,11 15,10.7721195 15,10.5089948 L15,5.49100518 C15,5.21983051 14.7723472,5 14.4903505,5 L5.50964952,5 C5.22817786,5 5,5.22788048 5,5.49100518 Z M21,21.4910052 L21,26.5089948 C21,26.7801695 21.2278805,27 21.4910052,27 L26.5089948,27 C26.7801695,27 27,26.7721195 27,26.5089948 L27,21.4910052 C27,21.2198305 26.7721195,21 26.5089948,21 L21.4910052,21 C21.2198305,21 21,21.2278805 21,21.4910052 Z M21,13.4910052 L21,18.5089948 C21,18.7801695 21.2278805,19 21.4910052,19 L26.5089948,19 C26.7801695,19 27,18.7721195 27,18.5089948 L27,13.4910052 C27,13.2198305 26.7721195,13 26.5089948,13 L21.4910052,13 C21.2198305,13 21,13.2278805 21,13.4910052 Z',
document: 'M29,10.1052632 L29,28.8325291 C29,30.581875 27.5842615,32 25.8337327,32 L7.16626728,32 C5.41758615,32 4,30.5837102 4,28.8441405 L4,3.15585953 C4,1.41292644 5.42339685,9.39605581e-15 7.15970573,8.42009882e-15 L20.713352,8.01767853e-16 L20.713352,8.42105263 L22.3846872,8.42105263 L22.3846872,0.310375032 L28.7849894,8.42105263 L20.713352,8.42105263 L20.713352,10.1052632 L29,10.1052632 Z M7.3426704,12.8000006 L25.7273576,12.8000006 L25.7273576,14.4842112 L7.3426704,14.4842112 L7.3426704,12.8000006 Z M7.3426704,17.3473687 L25.7273576,17.3473687 L25.7273576,19.0315793 L7.3426704,19.0315793 L7.3426704,17.3473687 Z M7.3426704,21.8947352 L25.7273576,21.8947352 L25.7273576,23.5789458 L7.3426704,23.5789458 L7.3426704,21.8947352 Z M7.43137255,26.2736849 L16.535014,26.2736849 L16.535014,27.9578954 L7.43137255,27.9578954 L7.43137255,26.2736849 Z',
downarrow: 'M12.2782161,19.3207547 L12.2782161,0 L19.5564322,0 L19.5564322,19.3207547 L26.8346484,19.3207547 L15.9173242,32 L5,19.3207547 L12.2782161,19.3207547 Z',
download: {
path: 'M4,8 L4,0 L7,0 L7,8 L10,8 L5.5,13.25 L1,8 L4,8 Z M11,14 L0,14 L0,17 L11,17 L11,14 Z',
attrs: { viewBox: '0 0 11 17' }
......
import React, { Component, PropTypes } from "react";
import ReactDOM from "react-dom";
import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx";
import Icon from "metabase/components/Icon.jsx";
import DownloadButton from "metabase/components/DownloadButton.jsx";
import FieldSet from "metabase/components/FieldSet.jsx";
const DownloadWidget = ({ className, card, datasetQuery, isLarge }) =>
<PopoverWithTrigger
triggerElement={<Icon className={className} title="Download this data" name='download' size={16} />}
>
<div className="p2" style={{ maxWidth: 300 }}>
<h4>Download</h4>
{isLarge &&
<FieldSet className="my2 text-gold border-gold" legend="Warning">
<div className="my1">Your answer has a large number of rows so it could take awhile to download.</div>
<div>The maximum download size is 1 million rows.</div>
</FieldSet>
}
<div className="flex flex-row mt2">
{["csv", "json"].map(type =>
<DownloadButton
className="mr1 text-uppercase text-default"
url={card.id != null ?
`/api/card/${card.id}/query/${type}`:
`/api/dataset/${type}`
}
params={card.id != null ?
{ parameters: JSON.stringify(datasetQuery.parameters) } :
{ query: JSON.stringify(datasetQuery) }
}
extensions={[type]}
>
{type}
</DownloadButton>
)}
</div>
</div>
</PopoverWithTrigger>
DownloadWidget.propTypes = {
className: PropTypes.string,
card: PropTypes.object.isRequired,
datasetQuery: PropTypes.object.isRequired,
isLarge: PropTypes.bool
};
export default DownloadWidget;
......@@ -2,7 +2,7 @@ import React, { Component, PropTypes } from "react";
import FilterList from "./filters/FilterList.jsx";
import AggregationWidget from "./AggregationWidget.jsx";
import FieldSet from "metabase/admin/datamodel/components/FieldSet.jsx";
import FieldSet from "metabase/components/FieldSet.jsx";
import Query from "metabase/lib/query";
......@@ -29,7 +29,7 @@ export default class QueryDefinitionTooltip extends Component {
</div>
{ object.definition &&
<div className="mt2">
<FieldSet legend="Definition" border="border-light">
<FieldSet legend="Definition" className="border-light">
<div className="TooltipFilterList">
{ object.definition.aggregation &&
<AggregationWidget
......
......@@ -2,15 +2,13 @@ import React, { Component, PropTypes } from "react";
import ReactDOM from "react-dom";
import { Link } from "react-router";
import Icon from "metabase/components/Icon.jsx";
import LoadingSpinner from 'metabase/components/LoadingSpinner.jsx';
import RunButton from './RunButton.jsx';
import VisualizationSettings from './VisualizationSettings.jsx';
import VisualizationError from "./VisualizationError.jsx";
import VisualizationResult from "./VisualizationResult.jsx";
import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx";
import DownloadWidget from "./DownloadWidget.jsx";
import cx from "classnames";
import _ from "underscore";
......@@ -80,7 +78,7 @@ export default class QueryVisualization extends Component {
}
renderHeader() {
const { isObjectDetail, isRunning } = this.props;
const { isObjectDetail, isRunning, card, result } = this.props;
return (
<div className="relative flex flex-no-shrink mt3 mb1" style={{ minHeight: "2em" }}>
<span className="relative z4">
......@@ -96,8 +94,15 @@ export default class QueryVisualization extends Component {
/>
</div>
<div className="absolute right z4 flex align-center">
{!this.queryIsDirty() && this.renderCount()}
{this.renderDownloadButton()}
{ !this.queryIsDirty() && this.renderCount() }
{ !this.queryIsDirty() && result && !result.error ?
<DownloadWidget
className="mx1"
card={card}
datasetQuery={result.json_query}
isLarge={result.data.rows_truncated != null}
/>
: null }
</div>
</div>
);
......@@ -131,81 +136,6 @@ export default class QueryVisualization extends Component {
}
}
onDownloadCSV() {
const form = ReactDOM.findDOMNode(this._downloadCsvForm);
form.query.value = JSON.stringify(this.props.fullDatasetQuery);
form.submit();
}
renderDownloadButton() {
const { card, result } = this.props;
const csvUrl = card.id != null ? `/api/card/${card.id}/query/csv`: "/api/dataset/csv";
if (result && !result.error) {
if (result && result.data && result.data.rows_truncated) {
// this is a "large" dataset, so show a modal to inform users about this and make them click again to d/l
let downloadButton;
if (window.OSX) {
downloadButton = (<button className="Button Button--primary" onClick={() => {
window.OSX.saveCSV(JSON.stringify(card.dataset_query));
this.refs.downloadModal.toggle()
}}>Download CSV</button>);
} else {
downloadButton = (
<form ref={(c) => this._downloadCsvForm = c} method="POST" action={csvUrl}>
<input type="hidden" name="query" value="" />
<a className="Button Button--primary" onClick={() => {this.onDownloadCSV(); this.refs.downloadModal.toggle();}}>
Download CSV
</a>
</form>
);
}
return (
<ModalWithTrigger
key="download"
ref="downloadModal"
className="Modal Modal--small"
triggerElement={<Icon className="mx1" title="Download this data" name='download' size={16} />}
>
<div style={{width: "480px"}} className="Modal--small p4 text-centered relative">
<span className="absolute top right p4 text-normal text-grey-3 cursor-pointer" onClick={() => this.refs.downloadModal.toggle()}>
<Icon name={'close'} size={16} />
</span>
<div className="p3 text-strong">
<h2 className="text-bold">Download large data set</h2>
<div className="pt2">Your answer has a large amount of data so we wanted to let you know it could take a while to download.</div>
<div className="py4">The maximum download amount is 1 million rows.</div>
{downloadButton}
</div>
</div>
</ModalWithTrigger>
);
} else {
if (window.OSX) {
return (
<a className="mx1" title="Download this data" onClick={function() {
window.OSX.saveCSV(JSON.stringify(card.dataset_query));
}}>
<Icon name='download' size={16} />
</a>
);
} else {
return (
<form ref={(c) => this._downloadCsvForm = c} method="POST" action={csvUrl}>
<input type="hidden" name="query" value="" />
<a className="mx1" title="Download this data" onClick={() => this.onDownloadCSV()}>
<Icon name='download' size={16} />
</a>
</form>
);
}
}
}
}
render() {
const { card, databases, isObjectDetail, isRunning, result } = this.props
let viz;
......
......@@ -35,7 +35,6 @@ import {
getParameters,
getDatabaseFields,
getSampleDatasetId,
getFullDatasetQuery,
getNativeDatabases,
getIsRunnable,
} from "../selectors";
......@@ -89,7 +88,6 @@ const mapStateToProps = (state, props) => {
parameters: getParameters(state),
databaseFields: getDatabaseFields(state),
sampleDatasetId: getSampleDatasetId(state),
fullDatasetQuery: getFullDatasetQuery(state),
isShowingDataReference: state.qb.uiControls.isShowingDataReference,
isShowingTutorial: state.qb.uiControls.isShowingTutorial,
......
......@@ -7,7 +7,6 @@ import { getTemplateTags } from "metabase/meta/Card";
import { isCardDirty, isCardRunnable } from "metabase/lib/card";
import { parseFieldTarget } from "metabase/lib/query_time";
import { isPK } from "metabase/lib/types";
import { applyParameters } from "metabase/meta/Card";
export const uiControls = state => state.qb.uiControls;
......@@ -153,12 +152,6 @@ export const getParameters = createSelector(
(implicitParameters) => implicitParameters
);
export const getFullDatasetQuery = createSelector(
[card, getParameters, parameterValues],
(card, parameters, parameterValues) =>
card && applyParameters(card, parameters, parameterValues)
)
export const getIsRunnable = createSelector(
[card, tableMetadata],
(card, tableMetadata) => isCardRunnable(card, tableMetadata)
......
(ns metabase.api.card
(:require [clojure.data :as data]
[cheshire.core :as json]
[compojure.core :refer [GET POST DELETE PUT]]
[schema.core :as s]
(metabase.api [common :refer :all]
......@@ -273,16 +274,16 @@
(run-query-for-card card-id parameters))
(defendpoint POST "/:card-id/query/csv"
"Run the query associated with a Card, and return its results as CSV."
[card-id :as {{:keys [parameters]} :body}]
(dataset-api/as-csv (run-query-for-card card-id parameters)))
(defendpoint GET "/:card-id/json"
"Fetch the results of a Card as JSON."
[card-id]
(let [{{:keys [columns rows]} :data} (run-query-for-card card-id nil)]
(for [row rows]
(zipmap columns row))))
"Run the query associated with a Card, and return its results as CSV. Note that this expects the parameters as serialized JSON in the 'parameters' parameter"
[card-id parameters]
{parameters (s/maybe su/JSONString)}
(dataset-api/as-csv (run-query-for-card card-id (json/parse-string parameters keyword))))
(defendpoint POST "/:card-id/query/json"
"Run the query associated with a Card, and return its results as JSON. Note that this expects the parameters as serialized JSON in the 'parameters' parameter"
[card-id parameters]
{parameters (s/maybe su/JSONString)}
(dataset-api/as-json (run-query-for-card card-id (json/parse-string parameters keyword))))
(define-routes)
......@@ -65,6 +65,20 @@
{:status 500
:body (:error response)}))
(defn as-json
"Return a JSON response containing the RESULTS of a query."
{:arglists '([results])}
[{{:keys [columns rows]} :data, :keys [status], :as response}]
(if (= status :completed)
;; successful query, send CSV file
{:status 200
:body (for [row rows]
(zipmap columns row))
:headers {"Content-Disposition" (str "attachment; filename=\"query_result_" (u/date->iso-8601) ".json\"")}}
;; failed query, send error message
{:status 500
:body {:error (:error response)}}))
(defendpoint POST "/csv"
"Execute a query and download the result data as a CSV file."
[query]
......@@ -73,5 +87,13 @@
(read-check Database (:database query))
(as-csv (qp/dataset-query query {:executed-by *current-user-id*}))))
(defendpoint POST "/json"
"Execute a query and download the result data as a JSON file."
[query]
{query su/JSONString}
(let [query (json/parse-string query keyword)]
(read-check Database (:database query))
(as-json (qp/dataset-query query {:executed-by *current-user-id*}))))
(define-routes)
(ns metabase.api.card-test
"Tests for /api/card endpoints."
(:require [expectations :refer :all]
(:require [cheshire.core :as json]
[expectations :refer :all]
[metabase.db :as db]
[metabase.http-client :refer :all, :as http]
[metabase.middleware :as middleware]
......@@ -317,8 +318,7 @@
Table [{table-id :id} {:db_id database-id, :name "CATEGORIES"}]
Card [card {:dataset_query {:database database-id
:type :native
:native {:query "SELECT COUNT(*) FROM CATEGORIES;"}
:query {:source-table table-id, :aggregation {:aggregation-type :count}}}}]]
:native {:query "SELECT COUNT(*) FROM CATEGORIES;"}}}]]
;; delete all permissions for this DB
(perms/delete-related-permissions! (perms-group/all-users) (perms/object-path database-id))
(f database-id card)))
......@@ -349,4 +349,40 @@
(do-with-temp-native-card
(fn [database-id card]
(perms/grant-native-read-permissions! (perms-group/all-users) database-id)
((user->client :rasta) :get 200 (format "card/%d/json" (u/get-id card))))))
((user->client :rasta) :post 200 (format "card/%d/query/json" (u/get-id card))))))
;;; Test GET /api/card/:id/query/csv & GET /api/card/:id/json **WITH PARAMETERS**
(defn- do-with-temp-native-card-with-params {:style/indent 0} [f]
(with-temp* [Database [{database-id :id} {:details (:details (Database (id))), :engine :h2}]
Table [{table-id :id} {:db_id database-id, :name "VENUES"}]
Card [card {:dataset_query {:database database-id
:type :native
:native {:query "SELECT COUNT(*) FROM VENUES WHERE CATEGORY_ID = {{category}};"
:template_tags {:category {:id "a9001580-3bcc-b827-ce26-1dbc82429163"
:name "category"
:display_name "Category"
:type "number"
:required true}}}}}]]
(f database-id card)))
(def ^:private ^:const ^String encoded-params
(json/generate-string [{:type :category
:target [:variable [:template-tag :category]]
:value 2}]))
;; CSV
(expect
(str "COUNT(*)\n"
"8\n")
(do-with-temp-native-card-with-params
(fn [database-id card]
((user->client :rasta) :post 200 (format "card/%d/query/csv?parameters=%s" (u/get-id card) encoded-params)))))
;; JSON
(expect
[{(keyword "COUNT(*)") 8}]
(do-with-temp-native-card-with-params
(fn [database-id card]
((user->client :rasta) :post 200 (format "card/%d/query/json?parameters=%s" (u/get-id card) encoded-params)))))
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment