Import the result table and we're live

This commit is contained in:
Kane York 2015-06-30 15:12:12 -07:00
parent dba181d92e
commit c56a40cacd
10 changed files with 446 additions and 35 deletions

View File

@ -0,0 +1,8 @@
export default Ember.TextField.extend({
value: function(key, value, previousValue) {
if (arguments.length > 1) {
this.get('params')[this.get('pname')] = value;
}
return this.get('params')[this.get('pname')];
}.property()
});

View File

@ -0,0 +1,297 @@
var ColumnHandlers = [];
var AssistedHandlers = {};
const Escape = Handlebars.Utils.escapeExpression;
import avatarTemplate from 'discourse/lib/avatar-template';
import { categoryLinkHTML } from 'discourse/helpers/category-link';
var defaultFallback = function(buffer, content, defaultRender) { defaultRender(buffer, content); };
function isoYMD(date) {
return date.getUTCFullYear() + "-" + date.getUTCMonth() + "-" + date.getUTCDate();
}
const QueryResultComponent = Ember.Component.extend({
layoutName: 'explorer-query-result',
rows: Em.computed.alias('content.rows'),
columns: Em.computed.alias('content.columns'),
params: Em.computed.alias('content.params'),
explainText: Em.computed.alias('content.explain'),
hasExplain: Em.computed.notEmpty('content.explain'),
noParams: Em.computed.empty('params'),
colCount: function() {
return this.get('content.columns').length;
}.property('content.columns.length'),
downloadName: function() {
return this.get('query.name') + "@" + window.location.host + "-" + isoYMD(new Date()) + ".dcqresult.json";
}.property(),
duration: function() {
return I18n.t('explorer.run_time', {value: I18n.toNumber(this.get('content.duration'), {precision: 1})});
}.property('content.duration'),
parameterAry: function() {
let arr = [];
const params = this.get('params');
for (var key in params) {
if (params.hasOwnProperty(key)) {
arr.push({key: key, value: params[key]});
}
}
return arr;
}.property('params.@each'),
columnHandlers: function() {
const self = this;
if (!this.get('content')) {
return;
}
if (self.get('opts.notransform')) {
return this.get('columns').map(function(colName) {
return {
name: colName,
displayName: colName,
render: defaultFallback
};
});
}
return this.get('columns').map(function(colName, idx) {
let handler = defaultFallback;
if (/\$/.test(colName)) {
var match = /(\w+)\$(\w*)/.exec(colName);
if (match[1] && self.get('content.relations')[match[1]] && AssistedHandlers[match[1]]) {
return {
name: colName,
displayName: match[2] || match[1],
render: AssistedHandlers[match[1]]
};
} else if (match[1] == '') {
// Return as "$column" for no special handling
return {
name: colName,
displayName: match[2] || match[1],
render: defaultFallback
}
}
} else if (/\?column\?/.test(colName)) {
return {
name: "generic-column",
displayName: I18n.t('explorer.column', {number: idx+1}),
render: defaultFallback
}
}
ColumnHandlers.forEach(function(handlerInfo) {
if (handlerInfo.regex.test(colName)) {
handler = handlerInfo.render;
}
});
return {
name: colName,
displayName: colName,
render: handler
};
});
}.property('content', 'columns.@each'),
_clickDownloadButton: function() {
const self = this;
const $button = this.$().find("#result-download");
// use $.one to do once
$button.one('mouseover', function(e) {
const a = e.target;
let resultString = "data:text/plain;base64,";
var jsonString = JSON.stringify(self.get('content'));
resultString += btoa(jsonString);
a.href = resultString;
});
}.on('didInsertElement'),
parent: function() { return this; }.property()
});
/**
* ColumnHandler callback arguments:
* buffer: rendering buffer
* content: content of the query result cell
* defaultRender: call this wth (buffer, content) to fall back
* extra: the entire response
*/
ColumnHandlers.push({ regex: /user_id/, render: function(buffer, content, defaultRender) {
if (!/^\d+$/.test(content)) {
return defaultRender(buffer, content);
}
buffer.push("<a href='/users/by-id/");
buffer.push(content);
buffer.push("'>User #");
buffer.push(content);
buffer.push("</a>");
}});
ColumnHandlers.push({ regex: /post_id/, render: function(buffer, content, defaultRender) {
if (!/^\d+$/.test(content)) {
return defaultRender(buffer, content);
}
buffer.push("<a href='/p/");
buffer.push(content);
buffer.push("'>Post #");
buffer.push(content);
buffer.push("</a>");
}});
ColumnHandlers.push({ regex: /badge_id/, render: function(buffer, content, defaultRender) {
if (!/^\d+$/.test(content)) {
return defaultRender(buffer, content);
}
buffer.push("<a href='/badges/");
buffer.push(content);
buffer.push("/-'>Badge #");
buffer.push(content);
buffer.push("</a>");
}});
ColumnHandlers.push({ regex: /topic_id/, render: function(buffer, content, defaultRender) {
if (!/^\d+$/.test(content)) {
return defaultRender(buffer, content);
}
buffer.push("<a href='/t/");
buffer.push(content);
buffer.push("/from-link'>Topic #");
buffer.push(content);
buffer.push("</a>");
}});
AssistedHandlers['reltime'] = function(buffer, content, defaultRender) {
const parsedDate = new Date(content);
if (!parsedDate.getTime()) {
return defaultRender(buffer, content);
}
buffer.push(Discourse.Formatter.relativeAge(parsedDate, {format: 'medium'}));
};
AssistedHandlers['category'] = function(buffer, content, defaultRender) {
const contentId = parseInt(content, 10);
if (isNaN(contentId)) {
return defaultRender(buffer, content);
}
const category = Discourse.Category.findById(contentId);
if (!category) {
return defaultRender(buffer, content);
}
const opts = {
link: true,
allowUncategorized: true
};
buffer.push(categoryLinkHTML(category, opts));
};
/**
* Helper to wrap the handler in a function that fetches the object out of the response.
*
* @param name the part of the column name before the $
* @param callback Function(buffer, object [, defaultRender])
*/
function registerRelationAssistedHandler(name, callback) {
AssistedHandlers[name] = function(buffer, content, defaultRender, response) {
const contentId = parseInt(content, 10);
if (isNaN(contentId)) {
return defaultRender(buffer, content);
}
const relationObject = response.relations[name].find(function(relObj) {
return relObj.id === contentId;
});
if (!relationObject) {
Em.Logger.warn("Couldn't find " + name + " with id " + contentId + " in query response");
return defaultRender(buffer, content);
}
callback(buffer, relationObject, defaultRender);
}
}
registerRelationAssistedHandler('user', function(buffer, obj) {
buffer.push("<a href='/users/");
buffer.push(obj.username);
buffer.push("'>");
buffer.push(Discourse.Utilities.avatarImg({
size: "small",
avatarTemplate: avatarTemplate(obj.username, obj.uploaded_avatar_id)
}));
buffer.push(" ");
buffer.push(obj.username);
buffer.push("</a>");
});
registerRelationAssistedHandler('badge', function(buffer, obj) {
// TODO It would be nice to be able to invoke the {{user-badge}} helper from here.
// Looks like that would need a ContainerView
/*
<span id="ember2197" class="ember-view">
<a id="ember2201" class="ember-view" href="/badges/9/autobiographer">
<span id="ember2221" class="ember-view user-badge badge-type-bronze" data-badge-name="Autobiographer" title="Filled user profile information">
<i class="fa fa-certificate"></i>
Autobiographer
</span></a></span>
*/
if (true) {
buffer.push('<span><a href="/badges/');
buffer.push(obj.id + '/' + Escape(obj.name));
buffer.push('"><span data-badge-name="');
buffer.push(Escape(obj.name));
buffer.push('" class="user-badge badge-type-');
buffer.push(Escape(obj.badge_type.toLowerCase()));
buffer.push('" title="');
buffer.push(Escape(obj.description));
buffer.push('">');
// icon-or-image
if (obj.icon.indexOf('fa-') === 0) {
buffer.push(" <i class='fa " + obj.icon + "'></i> ");
} else {
buffer.push(" <img src='" + obj.icon + "'> ");
}
buffer.push(Escape(obj.name));
buffer.push("</span></a></span>");
}
});
registerRelationAssistedHandler('post', function(buffer, obj) {
/*
<aside class="quote" data-post="35" data-topic="117">
<div class="title" style="cursor: pointer;">
<div class="quote-controls">
<i class="fa fa-chevron-down" title="expand/collapse"></i>
<a href="/t/usability-on-the-cheap-and-easy/117/35" title="go to the quoted post" class="back"></a>
</div>
<img width="20" height="20" src="/user_avatar/localhost/riking/40/75.png" class="avatar">riking:</div>
<blockquote>$EXCERPT</blockquote>
</aside>
*/
buffer.push("<aside class='quote' data-post='" + obj.post_number + "' data-topic='" + obj.topic_id + "'>");
buffer.push('<div class="title" style="cursor: pointer;">' +
'<div class="quote-controls">' +
'<i class="fa" title="expand/collapse"></i>');
buffer.push('<a href="');
buffer.push("/t/" + obj.slug + "/" + obj.topic_id + "/" + obj.post_number);
buffer.push('" title="go to the post" class="quote-other-topic"></a>');
buffer.push('</div>');
buffer.push(Discourse.Utilities.avatarImg({
size: "small",
avatarTemplate: avatarTemplate(obj.username, obj.uploaded_avatar_id)
}));
buffer.push(obj.username + ":");
buffer.push('</div>' +
'<blockquote>');
buffer.push(obj.excerpt);
buffer.push('</blockquote></aside>');
});
export default QueryResultComponent;

View File

@ -0,0 +1,20 @@
const defaultRender = function(buffer, content) {
buffer.push(Handlebars.Utils.escapeExpression(content));
};
const QueryRowContentComponent = Ember.Component.extend({
tagName: "tr",
render(buffer) {
const row = this.get('row');
const response = this.get('extra');
this.get('colRenders').forEach(function(colRender, idx) {
buffer.push("<td data-column-name=" + Handlebars.Utils.escapeExpression(colRender.name) + ">");
colRender.render(buffer, row[idx], defaultRender, response);
buffer.push("</td>");
});
}
});
export default QueryRowContentComponent;

View File

@ -5,7 +5,7 @@ import { popupAjaxError } from 'discourse/lib/ajax-error';
export default Ember.ArrayController.extend({
selectedQueryId: null,
results: null,
dirty: false,
showResults: false,
loading: false,
explain: false,
@ -21,10 +21,16 @@ export default Ember.ArrayController.extend({
});
}.property('selectedQueryId'),
clearResults: function() {
this.set('showResults', false);
this.set('results', null);
}.observes('selectedQueryId'),
addCreatedRecord(record) {
this.pushObject(record);
this.set('selectedQueryId', Ember.get(record, 'id'));
this.get('selectedItem').set('dirty', false);
this.set('showResults', false);
this.set('results', null);
},
@ -48,6 +54,14 @@ export default Ember.ArrayController.extend({
window.open(this.get('selectedItem.downloadUrl'), "_blank");
},
resetParams() {
this.get('selectedItem').resetParams();
},
saveDefaults() {
this.get('selectedItem').saveDefaults();
},
create() {
const self = this;
this.set('loading', true);
@ -109,20 +123,25 @@ export default Ember.ArrayController.extend({
},
run() {
const self = this;
if (this.get('selectedItem.dirty')) {
self.set('results', {errors: [I18n.t('errors.explorer.dirty')]});
return;
}
const self = this;
this.set('loading', true);
Discourse.ajax("/admin/plugins/explorer/queries/" + this.get('selectedItem.id') + "/run", {
type: "POST",
data: {
params: JSON.stringify({foo: 34}),
params: JSON.stringify(this.get('selectedItem.params')),
explain: true
}
}).then(function(result) {
if (!result.success) {
return popupAjaxError(result);
}
console.log(result);
self.set('showResults', true);
self.set('results', result);
}).catch(popupAjaxError).finally(function() {
self.set('loading', false);

View File

@ -3,15 +3,45 @@ import RestModel from 'discourse/models/rest';
let Query;
Query = RestModel.extend({
dirty: false,
params: {},
_initParams: function() {
this.resetParams();
}.on('init').observes('param_names'),
markDirty: function() {
this.set('dirty', true);
}.observes('name', 'description', 'sql', 'defaults'),
}.observes('name', 'description', 'sql', 'options', 'options.defaults'),
markNotDirty() {
this.set('dirty', false);
},
resetParams() {
let newParams = {};
let defaults = this.get('options.defaults');
if (!defaults) {
defaults = {};
}
(this.get('param_names') || []).forEach(function(name) {
if (defaults[name]) {
newParams[name] = defaults[name];
} else {
newParams[name] = '';
}
});
this.set('params', newParams);
},
saveDefaults() {
const currentParams = this.get('params');
let defaults = {};
(this.get('param_names') || []).forEach(function(name) {
defaults[name] = currentParams[name];
});
this.set('options.defaults', defaults);
},
downloadUrl: function() {
// TODO - can we change this to use the store/adapter?
return Discourse.getURL("/admin/plugins/explorer/queries/" + this.get('id') + ".json?export=1");
@ -50,7 +80,7 @@ Query = RestModel.extend({
});
Query.reopenClass({
updatePropertyNames: ["name", "description", "sql", "defaults"]
updatePropertyNames: ["name", "description", "sql", "options"]
});
export default Query;

View File

@ -19,7 +19,6 @@
</div>
<div class="left-buttons">
{{d-button action="save" label="explorer.save" disabled=saveDisabled}}
{{d-button action="run" label="explorer.run" disabled=runDisabled}}
{{d-button action="download" label="explorer.export" disabled=runDisabled icon="download"}}
</div>
<div class="right-buttons">

View File

@ -14,8 +14,20 @@
<div class="query-edit">
{{partial "admin/plugins-explorer-show" model=selectedItem}}
</div>
<div class="query-run">
{{#if selectedItem.param_names}}
<div class="query-params">
{{#each selectedItem.param_names as |pname|}}
{{param-field params=selectedItem.params pname=pname}} {{pname}}
{{/each}}
</div>
{{/if}}
{{d-button action="run" label="explorer.run" disabled=runDisabled}}
</div>
<hr>
{{conditional-loading-spinner condition=loading}}
<div class="query-results">
{{results}}
</div>
{{#if results}}
<div class="query-results">
{{query-result query=selectedItem content=results}}
</div>
{{/if}}

View File

@ -0,0 +1,26 @@
<div class="result-info">
<a class="btn" id="result-download" {{bind-attr download=downloadName}} data-auto-route="true">
{{fa-icon "download"}}
{{i18n "explorer.download_json"}}
</a>
<div class="result-about">
{{duration}}
</div>
</div>
{{#if hasExplain}}
<pre><code>{{content.explain}}</code></pre>
{{/if}}
<table>
<thead>
<tr class="headers">
{{#each handler in columnHandlers}}
<th>{{handler.displayName}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#each row in rows}}
{{query-row-content row=row colRenders=columnHandlers extra=content}}
{{/each}}
</tbody>
</table>

View File

@ -34,3 +34,6 @@ en:
undo: "Revert"
delete: "Delete"
recover: "Undelete Query"
download_json: "Save Query Results"
run_time: "Query completed in {{value}} ms."
column: "Column {{number}}"

View File

@ -73,7 +73,7 @@ after_initialize do
return {error: err, duration_nanos: 0}
end
query_args = query.defaults.merge(params)
query_args = (query.qopts[:defaults] || {}).with_indifferent_access.merge(params)
time_start, time_end, explain, err, result = nil
begin
@ -123,13 +123,14 @@ SQL
# Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead
class DataExplorer::Query
attr_accessor :id, :name, :description, :sql, :defaults
attr_accessor :id, :name, :description, :sql
attr_reader :qopts
def initialize
@name = 'Unnamed Query'
@description = 'Enter a description here'
@sql = 'SELECT 1'
@defaults = {}
@qopts = {}
end
def param_names
@ -154,24 +155,27 @@ SQL
end
end
def qopts=(val)
case val
when String
@qopts = HashWithIndifferentAccess.new(MultiJson.load(val))
when HashWithIndifferentAccess
@qopts = val
when Hash
@qopts = val.with_indifferent_access
else
raise ArgumentError.new('invalid type for qopts')
end
end
def self.from_hash(h)
query = DataExplorer::Query.new
[:name, :description, :sql].each do |sym|
[:name, :description, :sql, :qopts].each do |sym|
query.send("#{sym}=", h[sym]) if h[sym]
end
if h[:id]
query.id = h[:id].to_i
end
if h[:defaults]
case h[:defaults]
when String
query.defaults = MultiJson.load(h[:defaults])
when Hash
query.defaults = h[:defaults]
else
raise ArgumentError.new('invalid type for :defaults')
end
end
query
end
@ -181,7 +185,7 @@ SQL
name: @name,
description: @description,
sql: @sql,
defaults: @defaults,
qopts: @qopts.to_hash,
}
end
@ -244,16 +248,10 @@ SQL
render_serialized query, DataExplorer::QuerySerializer, root: 'query'
end
# Helper endpoint for logic
def parse_params
render json: (DataExplorer.extract_params params.require(:sql))[:names]
end
def create
# guardian.ensure_can_create_explorer_query!
query = DataExplorer::Query.from_hash params.require(:query)
binding.pry
query.id = nil # json import will assign an id, which is wrong
query.save
@ -273,7 +271,7 @@ SQL
end
end
[:name, :sql, :defaults, :description].each do |sym|
[:name, :sql, :description, :qopts].each do |sym|
query.send("#{sym}=", hash[sym]) if hash[sym]
end
query.save
@ -328,8 +326,7 @@ SQL
# json[:relations] = DataExplorer.add_extra_data(pg_result)
# end
# TODO - can we tweak this to save network traffic
json[:rows] = pg_result.to_a
json[:rows] = pg_result.values
render json: json
end
@ -337,7 +334,7 @@ SQL
end
class DataExplorer::QuerySerializer < ActiveModel::Serializer
attributes :id, :sql, :name, :description, :defaults
attributes :id, :sql, :name, :description, :qopts, :param_names
end
DataExplorer::Engine.routes.draw do