diff --git a/assets/javascripts/discourse/components/param-field.js.es6 b/assets/javascripts/discourse/components/param-field.js.es6 new file mode 100644 index 0000000..89d1192 --- /dev/null +++ b/assets/javascripts/discourse/components/param-field.js.es6 @@ -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() +}); diff --git a/assets/javascripts/discourse/components/query-result.js.es6 b/assets/javascripts/discourse/components/query-result.js.es6 new file mode 100644 index 0000000..e795133 --- /dev/null +++ b/assets/javascripts/discourse/components/query-result.js.es6 @@ -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("User #"); + buffer.push(content); + buffer.push(""); +}}); +ColumnHandlers.push({ regex: /post_id/, render: function(buffer, content, defaultRender) { + if (!/^\d+$/.test(content)) { + return defaultRender(buffer, content); + } + buffer.push("Post #"); + buffer.push(content); + buffer.push(""); +}}); +ColumnHandlers.push({ regex: /badge_id/, render: function(buffer, content, defaultRender) { + if (!/^\d+$/.test(content)) { + return defaultRender(buffer, content); + } + buffer.push("Badge #"); + buffer.push(content); + buffer.push(""); +}}); +ColumnHandlers.push({ regex: /topic_id/, render: function(buffer, content, defaultRender) { + if (!/^\d+$/.test(content)) { + return defaultRender(buffer, content); + } + buffer.push("Topic #"); + buffer.push(content); + buffer.push(""); +}}); + +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(""); + buffer.push(Discourse.Utilities.avatarImg({ + size: "small", + avatarTemplate: avatarTemplate(obj.username, obj.uploaded_avatar_id) + })); + buffer.push(" "); + buffer.push(obj.username); + buffer.push(""); +}); + +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 + + /* + + + + + Autobiographer + + */ + + if (true) { + buffer.push(''); + // icon-or-image + if (obj.icon.indexOf('fa-') === 0) { + buffer.push(" "); + } else { + buffer.push(" "); + } + buffer.push(Escape(obj.name)); + buffer.push(""); + } +}); + +registerRelationAssistedHandler('post', function(buffer, obj) { + /* + + */ + buffer.push("'); +}); + +export default QueryResultComponent; diff --git a/assets/javascripts/discourse/components/query-row-content.js.es6 b/assets/javascripts/discourse/components/query-row-content.js.es6 new file mode 100644 index 0000000..93e2d0a --- /dev/null +++ b/assets/javascripts/discourse/components/query-row-content.js.es6 @@ -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(""); + colRender.render(buffer, row[idx], defaultRender, response); + buffer.push(""); + }); + } +}); + +export default QueryRowContentComponent; diff --git a/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 b/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 index 4118918..920ab44 100644 --- a/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 +++ b/assets/javascripts/discourse/controllers/admin-plugins-explorer.js.es6 @@ -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); diff --git a/assets/javascripts/discourse/models/query.js.es6 b/assets/javascripts/discourse/models/query.js.es6 index ddb878c..a6bb643 100644 --- a/assets/javascripts/discourse/models/query.js.es6 +++ b/assets/javascripts/discourse/models/query.js.es6 @@ -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; diff --git a/assets/javascripts/discourse/templates/admin/plugins-explorer-show.hbs b/assets/javascripts/discourse/templates/admin/plugins-explorer-show.hbs index 535941c..e8aa3f1 100644 --- a/assets/javascripts/discourse/templates/admin/plugins-explorer-show.hbs +++ b/assets/javascripts/discourse/templates/admin/plugins-explorer-show.hbs @@ -19,7 +19,6 @@
{{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"}}
diff --git a/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs b/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs index 519ea19..7f035cc 100644 --- a/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs +++ b/assets/javascripts/discourse/templates/admin/plugins-explorer.hbs @@ -14,8 +14,20 @@
{{partial "admin/plugins-explorer-show" model=selectedItem}}
+
+ {{#if selectedItem.param_names}} +
+ {{#each selectedItem.param_names as |pname|}} + {{param-field params=selectedItem.params pname=pname}} {{pname}} + {{/each}} +
+ {{/if}} + {{d-button action="run" label="explorer.run" disabled=runDisabled}} +

{{conditional-loading-spinner condition=loading}} -
- {{results}} -
+{{#if results}} +
+ {{query-result query=selectedItem content=results}} +
+{{/if}} diff --git a/assets/javascripts/discourse/templates/explorer-query-result.hbs b/assets/javascripts/discourse/templates/explorer-query-result.hbs new file mode 100644 index 0000000..e6b0d65 --- /dev/null +++ b/assets/javascripts/discourse/templates/explorer-query-result.hbs @@ -0,0 +1,26 @@ +
+ + {{fa-icon "download"}} + {{i18n "explorer.download_json"}} + +
+ {{duration}} +
+
+{{#if hasExplain}} +
{{content.explain}}
+{{/if}} + + + + {{#each handler in columnHandlers}} + + {{/each}} + + + + {{#each row in rows}} + {{query-row-content row=row colRenders=columnHandlers extra=content}} + {{/each}} + +
{{handler.displayName}}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 750fc87..239942d 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -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}}" diff --git a/plugin.rb b/plugin.rb index 61f29de..4fdd666 100644 --- a/plugin.rb +++ b/plugin.rb @@ -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