diff --git a/assets/javascripts/discourse/adapters/query.js.es6 b/assets/javascripts/admin/adapters/query.js.es6 similarity index 51% rename from assets/javascripts/discourse/adapters/query.js.es6 rename to assets/javascripts/admin/adapters/query.js.es6 index 3ebcb61..cfd6d75 100644 --- a/assets/javascripts/discourse/adapters/query.js.es6 +++ b/assets/javascripts/admin/adapters/query.js.es6 @@ -1,3 +1,5 @@ import buildPluginAdapter from 'admin/adapters/build-plugin'; -export default buildPluginAdapter('explorer').extend({}); +export default buildPluginAdapter('explorer').extend({ + +}); diff --git a/assets/javascripts/discourse/components/query-result.js.es6 b/assets/javascripts/discourse/components/query-result.js.es6 index e4e02c9..bf0658d 100644 --- a/assets/javascripts/discourse/components/query-result.js.es6 +++ b/assets/javascripts/discourse/components/query-result.js.es6 @@ -4,6 +4,7 @@ const Escape = Handlebars.Utils.escapeExpression; import avatarTemplate from 'discourse/lib/avatar-template'; import { categoryLinkHTML } from 'discourse/helpers/category-link'; +import Badge from 'discourse/models/badge'; var defaultFallback = function(buffer, content, defaultRender) { defaultRender(buffer, content); }; @@ -16,6 +17,18 @@ function randomIdShort() { }); } +function transformedRelTable(table, modelClass) { + const result = {}; + table.forEach(function(item) { + if (modelClass) { + result[item.id] = modelClass.create(item); + } else { + result[item.id] = item; + } + }); + return result; +} + const QueryResultComponent = Ember.Component.extend({ layoutName: 'explorer-query-result', @@ -25,7 +38,6 @@ const QueryResultComponent = Ember.Component.extend({ 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'), @@ -45,61 +57,68 @@ const QueryResultComponent = Ember.Component.extend({ return arr; }.property('params.@each'), - columnHandlers: function() { + columnDispNames: function() { + const templates = this.get('columnTemplates'); const self = this; if (!this.get('columns')) { 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 - } + if (colName.endsWith("_id")) { + return colName.slice(0, -3); } - - ColumnHandlers.forEach(function(handlerInfo) { - if (handlerInfo.regex.test(colName)) { - handler = handlerInfo.render; - } - }); - - return { - name: colName, - displayName: colName, - render: handler - }; + const dIdx = colName.indexOf('$'); + if (dIdx >= 0) { + return colName.substring(dIdx + 1); + } + return colName; }); }.property('content', 'columns.@each'), + columnTemplates: function() { + const self = this; + if (!this.get('columns')) { + return []; + } + return this.get('columns').map(function(colName, idx) { + let viewName = "text"; + if (self.get('content.colrender')[idx]) { + viewName = self.get('content.colrender')[idx]; + } + return {name: viewName, template: self.container.lookup('template:explorer/' + viewName + '.raw')}; + }); + }.property('content', 'columns.@each'), + + transformedUserTable: function() { + return transformedRelTable(this.get('content.relations.user')); + }.property('content.relations.user'), + transformedBadgeTable: function() { + return transformedRelTable(this.get('content.relations.badge'), Badge); + }.property('content.relations.badge'), + transformedPostTable: function() { + return transformedRelTable(this.get('content.relations.post')); + }.property('content.relations.post'), + transformedTopicTable: function() { + return transformedRelTable(this.get('content.relations.topic')); + }.property('content.relations.topic'), + + lookupUser(id) { + return this.get('transformedUserTable')[id]; + }, + lookupBadge(id) { + return this.get('transformedBadgeTable')[id]; + }, + lookupPost(id) { + return this.get('transformedPostTable')[id]; + }, + lookupTopic(id) { + return this.get('transformedTopicTable')[id]; + }, + + lookupCategory(id) { + return this.site.get('categoriesById')[id]; + }, + downloadResult(format) { // Create a frame to submit the form in (?) // to avoid leaving an about:blank behind @@ -151,181 +170,4 @@ const QueryResultComponent = Ember.Component.extend({ }); -/** - * 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 index 93e2d0a..a2dca18 100644 --- a/assets/javascripts/discourse/components/query-row-content.js.es6 +++ b/assets/javascripts/discourse/components/query-row-content.js.es6 @@ -1,19 +1,67 @@ +import binarySearch from 'discourse/plugins/discourse-data-explorer/discourse/lib/binary-search'; +import avatarTemplate from 'discourse/lib/avatar-template'; -const defaultRender = function(buffer, content) { - buffer.push(Handlebars.Utils.escapeExpression(content)); -}; +function icon_or_image_replacement(str, ctx) { + str = Ember.get(ctx.contexts[0], str); + if (Ember.isEmpty(str)) { return ""; } + + if (str.indexOf('fa-') === 0) { + return new Handlebars.SafeString(""); + } else { + return new Handlebars.SafeString(""); + } +} + +function shorthandTinyAvatar(username, uploadId, ctx) { + username = Ember.get(ctx.contexts[0], username); + uploadId = Ember.get(ctx.contexts[0], uploadId); + return new Handlebars.SafeString(Discourse.Utilities.avatarImg({ + size: "tiny", + extraClasses: '', + title: username, + avatarTemplate: avatarTemplate(username, uploadId) + })); +} + +const esc = Handlebars.Utils.escapeExpression; const QueryRowContentComponent = Ember.Component.extend({ tagName: "tr", - render(buffer) { + transformedUserTable: function() { + return transformedRelTable(this.get('extra.relations.user')); + }.property('extra.relations.user'), + + render: function(buffer) { + const self = this; 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(""); + const relations = this.get('extra.relations'); + const parent = self.get('parent'); + + const parts = this.get('columnTemplates').map(function(t, idx) { + const ctx = {}; + const params = {} + if (t.name === "text") { + return esc(row[idx]); + } else if (t.name === "user") { + ctx.user = parent.lookupUser(parseInt(row[idx])); + if (!ctx.user) { + return esc(row[idx]); + } + } else if (t.name === "badge") { + ctx.badge = parent.lookupBadge(parseInt(row[idx])); + params.helpers = {"icon-or-image": icon_or_image_replacement}; + } else if (t.name === "post") { + ctx.post = parent.lookupPost(parseInt(row[idx])); + params.helpers = {avatar: shorthandTinyAvatar}; + } else { + ctx.value = row[idx]; + } + + return new Handlebars.SafeString(t.template(ctx, params)); }); + + buffer.push("" + parts.join("") + ""); } }); diff --git a/assets/javascripts/discourse/initializers/polyfill-string-endswith.js.es6 b/assets/javascripts/discourse/initializers/polyfill-string-endswith.js.es6 new file mode 100644 index 0000000..3fca9cb --- /dev/null +++ b/assets/javascripts/discourse/initializers/polyfill-string-endswith.js.es6 @@ -0,0 +1,16 @@ +export default { + name: 'polyfill-string-endswith', + initialize(container) { + if (!String.prototype.endsWith) { + String.prototype.endsWith = function(searchString, position) { + var subjectString = this.toString(); + if (position === undefined || position > subjectString.length) { + position = subjectString.length; + } + position -= searchString.length; + var lastIndex = subjectString.indexOf(searchString, position); + return lastIndex !== -1 && lastIndex === position; + }; + } + } +}; diff --git a/assets/javascripts/discourse/lib/binary-search.js.es6 b/assets/javascripts/discourse/lib/binary-search.js.es6 new file mode 100644 index 0000000..01927cb --- /dev/null +++ b/assets/javascripts/discourse/lib/binary-search.js.es6 @@ -0,0 +1,29 @@ +// The binarySearch() function is licensed under the UNLICENSE +// https://github.com/Olical/binary-search + +// Modified for use in Discourse + +export default function binarySearch(list, target, keyProp) { + var min = 0; + var max = list.length - 1; + var guess; + var keyProperty = keyProp || "id"; + + while (min <= max) { + guess = Math.floor((min + max) / 2); + + if (Em.get(list[guess], keyProperty) === target) { + return guess; + } + else { + if (Em.get(list[guess], keyProperty) < target) { + min = guess + 1; + } + else { + max = guess - 1; + } + } + } + + return -1; +} diff --git a/assets/javascripts/discourse/templates/explorer-query-result.hbs b/assets/javascripts/discourse/templates/explorer-query-result.hbs index 40a40f2..b3701d4 100644 --- a/assets/javascripts/discourse/templates/explorer-query-result.hbs +++ b/assets/javascripts/discourse/templates/explorer-query-result.hbs @@ -16,14 +16,14 @@ - {{#each handler in columnHandlers}} - + {{#each columnDispNames as |col|}} + {{/each}} - {{#each row in rows}} - {{query-row-content row=row colRenders=columnHandlers extra=content}} - {{/each}} + {{~#each row in rows}} + {{~query-row-content row=row columnTemplates=columnTemplates parent=controller}} + {{~/each}}
{{handler.displayName}}{{col}}
diff --git a/assets/javascripts/discourse/templates/explorer/badge.raw.hbs b/assets/javascripts/discourse/templates/explorer/badge.raw.hbs new file mode 100644 index 0000000..e0d5243 --- /dev/null +++ b/assets/javascripts/discourse/templates/explorer/badge.raw.hbs @@ -0,0 +1,2 @@ +{{! source: badge-button component }} +{{icon-or-image badge.icon}} {{badge.displayName}} diff --git a/assets/javascripts/discourse/templates/explorer/html.raw.hbs b/assets/javascripts/discourse/templates/explorer/html.raw.hbs new file mode 100644 index 0000000..741894a --- /dev/null +++ b/assets/javascripts/discourse/templates/explorer/html.raw.hbs @@ -0,0 +1 @@ +{{{value}}} diff --git a/assets/javascripts/discourse/templates/explorer/post.raw.hbs b/assets/javascripts/discourse/templates/explorer/post.raw.hbs new file mode 100644 index 0000000..6698277 --- /dev/null +++ b/assets/javascripts/discourse/templates/explorer/post.raw.hbs @@ -0,0 +1,4 @@ + diff --git a/assets/javascripts/discourse/templates/explorer/text.raw.hbs b/assets/javascripts/discourse/templates/explorer/text.raw.hbs new file mode 100644 index 0000000..f07ceec --- /dev/null +++ b/assets/javascripts/discourse/templates/explorer/text.raw.hbs @@ -0,0 +1,2 @@ +{{value}} +{{! note - this template isn't actually used, it gets short-circuited in query-row-content.js.es6}} diff --git a/assets/javascripts/discourse/templates/explorer/user.raw.hbs b/assets/javascripts/discourse/templates/explorer/user.raw.hbs new file mode 100644 index 0000000..1b24eea --- /dev/null +++ b/assets/javascripts/discourse/templates/explorer/user.raw.hbs @@ -0,0 +1 @@ +{{avatar user imageSize="tiny"}} {{user.username}} diff --git a/plugin.rb b/plugin.rb index e4e9d03..f771fdf 100644 --- a/plugin.rb +++ b/plugin.rb @@ -28,7 +28,6 @@ module ::DataExplorer end end - after_initialize do module ::DataExplorer @@ -40,6 +39,20 @@ after_initialize do class ValidationError < StandardError; end + class SmallBadgeSerializer < ApplicationSerializer + attributes :id, :name, :badge_type, :description, :icon + end + + class SmallPostWithExcerptSerializer < ApplicationSerializer + attributes :id, :topic_id, :post_number, :excerpt + attributes :username, :uploaded_avatar_id + def excerpt + Post.excerpt(object.cooked, 70) + end + def username; object.user.username; end + def uploaded_avatar_id; object.user.uploaded_avatar_id; end + end + # Run a data explorer query on the currently connected database. # # @param [DataExplorer::Query] query the Query object to run @@ -120,6 +133,75 @@ SQL } end + def self.extra_data_pluck_fields + @extra_data_pluck_fields ||= { + user: {class: User, fields: [:id, :username, :uploaded_avatar_id], serializer: BasicUserSerializer}, + badge: {class: Badge, fields: [:id, :name, :badge_type_id, :description, :icon], include: [:badge_type], serializer: SmallBadgeSerializer}, + post: {class: Post, fields: [:id, :topic_id, :post_number, :cooked, :user_id], include: [:user], serializer: SmallPostWithExcerptSerializer}, + topic: {class: Topic, fields: [:id, :title, :slug, :posts_count], serializer: BasicTopicSerializer}, + category: {class: Category, ignore: true}, + reltime: {ignore: true}, + html: {ignore: true}, + } + end + + def self.column_regexes + @column_regexes ||= + extra_data_pluck_fields.map do |key, val| + if val[:class] + /(#{val[:class].to_s.downcase})_id$/ + end + end.compact + end + + def self.add_extra_data(pg_result) + needed_classes = {} + + pg_result.fields.each_with_index do |col, idx| + rgx = column_regexes.find { |rgx| rgx.match col } + if rgx + cls = (rgx.match col)[1].to_sym + needed_classes[cls] ||= [] + needed_classes[cls] << idx + elsif col =~ /^(\w+)\$/ + cls = $1.to_sym + needed_classes[cls] ||= [] + needed_classes[cls] << idx + end + end + + ret = {} + col_map = {} + needed_classes.each do |cls, column_nums| + next unless column_nums.present? + support_info = extra_data_pluck_fields[cls] + next unless support_info + + column_nums.each do |col_n| + col_map[col_n] = cls + end + + if support_info[:ignore] + ret[cls] = [] + next + end + + ids = Set.new + column_nums.each do |col_n| + ids.merge(pg_result.column_values(col_n)) + end + ids.delete nil + ids.map! &:to_i + + object_class = support_info[:class] + all_objs = object_class.select(support_info[:fields]). + where(id: ids.to_a.sort).includes(support_info[:include]).order(:id) + + ret[cls] = ActiveModel::ArraySerializer.new(all_objs, each_serializer: support_info[:serializer]) + end + [ret, col_map] + end + def self.sensitive_column_names %w( #_IP_Addresses @@ -966,11 +1048,9 @@ SQL columns: cols, } json[:explain] = result[:explain] if opts[:explain] - # TODO - special serialization - # This is dead code in the client right now - # if cols.any? { |col_name| special_serialization? col_name } - # json[:relations] = DataExplorer.add_extra_data(pg_result) - # end + ext = DataExplorer.add_extra_data(pg_result) + json[:colrender] = ext[1] + json[:relations] = ext[0] json[:rows] = pg_result.values