Merge branch 'refactor' into 'master'

Conflicts:
	renamed assets/javascripts/discourse/adapters/query.js.es6
This commit is contained in:
Kane York 2015-08-25 21:39:22 -07:00
commit 7d50fbbdae
12 changed files with 272 additions and 245 deletions

View File

@ -1,3 +1,5 @@
import buildPluginAdapter from 'admin/adapters/build-plugin';
export default buildPluginAdapter('explorer').extend({});
export default buildPluginAdapter('explorer').extend({
});

View File

@ -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("<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

@ -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("<i class='fa " + str + "'></i>");
} else {
return new Handlebars.SafeString("<img src='" + str + "'>");
}
}
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("<td data-column-name=" + Handlebars.Utils.escapeExpression(colRender.name) + ">");
colRender.render(buffer, row[idx], defaultRender, response);
buffer.push("</td>");
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("<td>" + parts.join("</td><td>") + "</td>");
}
});

View File

@ -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;
};
}
}
};

View File

@ -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;
}

View File

@ -16,14 +16,14 @@
<table>
<thead>
<tr class="headers">
{{#each handler in columnHandlers}}
<th>{{handler.displayName}}</th>
{{#each columnDispNames as |col|}}
<th>{{col}}</th>
{{/each}}
</tr>
</thead>
<tbody>
{{#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}}
</tbody>
</table>

View File

@ -0,0 +1,2 @@
{{! source: badge-button component }}
<span class="user-badge {{badge.badgeTypeClassName}}" title="{{badge.displayDescription}}" data-badge-name="{{badge.name}}">{{icon-or-image badge.icon}} <span class="badge-display-name">{{badge.displayName}}</span></span>

View File

@ -0,0 +1 @@
{{{value}}}

View File

@ -0,0 +1,4 @@
<aside class="quote" data-post="{{post.post_number}}" data-topic="{{post.topic_id}}"><div class="title" style="cursor: pointer;">
<div class="quote-controls">{{!<i class="fa fa-chevron-down" title="expand/collapse"></i>}}<a href="/t/via-quote/{{post.topic_id}}/{{post.post_number}}" title="go to the quoted post" class="quote-other-topic"></a></div>
{{avatar post.username post.uploaded_avatar_id}}{{post.username}}:</div>
<blockquote><p>{{{post.excerpt}}}</p></blockquote></aside>

View File

@ -0,0 +1,2 @@
{{value}}
{{! note - this template isn't actually used, it gets short-circuited in query-row-content.js.es6}}

View File

@ -0,0 +1 @@
<a href="/users/{{user.username}}/activity">{{avatar user imageSize="tiny"}} {{user.username}}</a>

View File

@ -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