WIP commit

This commit is contained in:
Kane York 2015-08-25 20:48:19 -07:00
parent c4789df9e7
commit e0e7022538
11 changed files with 163 additions and 267 deletions

View File

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

View File

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

View File

@ -16,6 +16,14 @@ function randomIdShort() {
});
}
function transformedRelTable(table) {
const result = {};
table.forEach(function(item) {
result[item.id] = item;
});
return result;
}
const QueryResultComponent = Ember.Component.extend({
layoutName: 'explorer-query-result',
@ -25,7 +33,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 +52,46 @@ 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'),
lookupUser: function(id) {
return this.get('transformedUserTable')[id];
},
downloadResult(format) {
// Create a frame to submit the form in (?)
// to avoid leaving an about:blank behind
@ -151,181 +143,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,32 @@
import binarySearch from 'discourse/plugins/discourse-data-explorer/discourse/lib/binary-search';
const defaultRender = function(buffer, content) {
buffer.push(Handlebars.Utils.escapeExpression(content));
};
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 parts = this.get('columnTemplates').map(function(t, idx) {
const params = {};
if (t.name === "text") {
return row[idx];
} else if (t.name === "user") {
params.user = self.get('parent').lookupUser(parseInt(row[idx]));
} else {
params.value = row[idx];
}
return new Handlebars.SafeString(t.template(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 @@
{{{value}}}

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,5 @@
{{#if user}}
<a href="/users/{{user.username}}/activity">{{avatar user imageSize="tiny"}} {{user.username}}</a>
{{else}}
User #{{value}} (deleted)
{{/if}}

View File

@ -141,32 +141,53 @@ SQL
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}
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|
if col =~ /user_id$/
needed_classes[:user] ||= []
needed_classes[:user] << idx
elsif col =~ /topic_id$/
needed_classes[:topic] ||= []
needed_classes[:topic] << idx
elsif col =~ /badge_id/
needed_classes[:badge] ||= []
needed_classes[:badge] << idx
elsif col =~ /post_id/
needed_classes[:post] ||= []
needed_classes[:post] << 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|
@ -175,14 +196,13 @@ SQL
ids.delete nil
ids.map! &:to_i
support_info = extra_data_pluck_fields[cls]
object_class = support_info[:class]
all_objs = object_class.select(support_info[:fields]).
where(id: ids.to_a.sort).includes(support_info[:include])
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
[ret, col_map]
end
def self.sensitive_column_names
@ -1029,9 +1049,9 @@ SQL
columns: cols,
}
json[:explain] = result[:explain] if opts[:explain]
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
@ -1054,11 +1074,6 @@ SQL
end
end
end
private
def special_serialization?(col_name)
col_name =~ /(user|topic|post|badge)(_id)?$/
end
end
class DataExplorer::QuerySerializer < ActiveModel::Serializer