Add DB schema view beside query editor

This commit is contained in:
Kane York 2015-07-08 13:45:13 -07:00
parent a0f38a2d17
commit 6ac463290e
10 changed files with 385 additions and 33 deletions

View File

@ -0,0 +1,12 @@
export default Ember.Component.extend({
classNameBindings: [':schema-table', 'open'],
open: Em.computed.alias('table.open'),
_bindClicks: function() {
const self = this;
this.$()./*children('.schema-table-name').*/click(function() {
self.set('open', !self.get('open'));
});
}.on('didInsertElement')
});

View File

@ -0,0 +1,108 @@
export default Ember.Component.extend({
transformedSchema: function() {
const schema = this.get('schema');
for (let key in schema) {
if (!schema.hasOwnProperty(key)) {
continue;
}
schema[key].forEach(function(col) {
let notes = false;
if (col.is_nullable) {
notes = "null";
}
if (col.column_default) {
if (notes) {
notes += ", default " + col.column_default;
} else {
notes = "default " + col.column_default;
}
}
if (notes) {
col.notes = notes;
}
});
}
return schema;
}.property('schema'),
rfilter: function() {
if (!Em.isBlank(this.get('filter'))) {
return new RegExp(this.get('filter'));
}
}.property('filter'),
filterTables: function(schema) {
let tables = [];
const filter = this.get('rfilter'),
haveFilter = !!filter;
for (let key in schema) {
if (!schema.hasOwnProperty(key)) {
continue;
}
if (!haveFilter) {
tables.push({
name: key,
columns: schema[key],
open: haveFilter
});
continue;
}
// Check the table name vs the filter
if (filter.source == key || filter.source + "s" == key) {
tables.unshift({
name: key,
columns: schema[key],
open: haveFilter
});
} else if (filter.test(key)) {
// whole table matches
tables.push({
name: key,
columns: schema[key],
open: haveFilter
});
} else {
// filter the columns
let filterCols = [];
schema[key].forEach(function(col) {
if (filter.source == col.column_name) {
filterCols.unshift(col);
} else if (filter.test(col.column_name)) {
filterCols.push(col);
}
});
if (!Em.isEmpty(filterCols)) {
tables.push({
name: key,
columns: filterCols,
open: haveFilter
});
}
}
}
return tables;
},
triggerFilter: Discourse.debounce(function() {
this.set('filteredTables', this.filterTables(this.get('transformedSchema')));
this.set('loading', false);
}, 500).observes('filter'),
setLoading: function() {
this.set('loading', true);
}.observes('filter'),
tables: function() {
if (!this.get('filteredTables')) {
this.set('loading', true);
this.triggerFilter();
return [];
}
return this.get('filteredTables');
}.property('transformedSchema', 'filteredTables')
});

View File

@ -4,6 +4,17 @@ export default Discourse.Route.extend({
queryParams: { id: { replace: true } },
model() {
return this.store.findAll('query');
const p1 = this.store.findAll('query');
const p2 = Discourse.ajax('/admin/plugins/explorer/schema.json', {cache: true});
return p1.then(function(model) {
return p2.then(function(schema) {
return { content: model, schema: schema };
});
});
},
setupController: function(controller, model) {
controller.set('model', model.content);
controller.set('schema', model.schema);
}
});

View File

@ -1,27 +1,39 @@
{{#if selectedItem}}
<div class="name">
{{#if editName}}
{{text-field value=selectedItem.name}}
{{else}}
<h2>{{selectedItem.name}}</h2>
{{d-button action="editName" icon="pencil" class="no-text btn-small"}}
{{/if}}
<div class="pull-right">
<span class="schema-title">
{{i18n "explorer.schema.title"}}<br>
{{{i18n "explorer.schema.type_help"}}}
</span>
<div class="schema">
{{explorer-schema schema=schema}}
</div>
</div>
<div class="desc">
{{#if controller.editName}}
{{textarea value=selectedItem.description}}
{{else}}
{{selectedItem.description}}
{{/if}}
<div class="pull-left">
<div class="name">
{{#if editName}}
{{text-field value=selectedItem.name}}
{{else}}
<h2>{{selectedItem.name}}</h2>
{{d-button action="editName" icon="pencil" class="no-text btn-small"}}
{{/if}}
</div>
<div class="desc">
{{#if controller.editName}}
{{textarea value=selectedItem.description}}
{{else}}
{{selectedItem.description}}
{{/if}}
</div>
<div class="sql">
{{textarea value=selectedItem.sql}}
</div>
</div>
<div class="sql">
{{textarea value=selectedItem.sql}}
</div>
<div class="left-buttons">
<div class="clear"></div>
<div class="pull-left">
{{d-button action="save" label="explorer.save" disabled=saveDisabled}}
{{d-button action="download" label="explorer.export" disabled=runDisabled icon="download"}}
</div>
<div class="right-buttons">
<div class="pull-right">
{{#if selectedItem.destroyed}}
{{d-button action="recover" class="" icon="undo" label="explorer.recover"}}
{{else}}

View File

@ -26,7 +26,7 @@
{{i18n "explorer.no_queries"}} <a {{action "showCreate"}}>{{i18n "explorer.no_queries_hook"}}</a>
{{else}}
<div class="query-edit {{if editName "editing"}}">
{{partial "admin/plugins-explorer-show" model=selectedItem}}
{{partial "admin/plugins-explorer-show" model=selectedItem schema=schema}}
</div>
<div class="query-run">

View File

@ -0,0 +1,35 @@
<div class="schema-table-name">
{{#if table.open}}
<i class="fa fa-caret-down"></i>
{{else}}
<i class="fa fa-caret-right"></i>
{{/if}}
{{table.name}}
</div>
<div class="schema-table-cols">
{{#if table.open}}
{{#each table.columns as |col|}}
<div class="schema-table-col">
{{#if col.sensitive}}
<span class="schema-colname sensitive" title="{{i18n "explorer.schema.sensitive"}}">
<i class="fa fa-warning"></i>
{{col.column_name}}
</span>
{{else}}
<span class="schema-colname" >
{{col.column_name}}
</span>
{{/if}}
<span class="schema-type">
{{col.data_type}}
{{#if col.notes}}
<span class="schema-typenotes">
{{col.notes}}
</span>
{{/if}}
</span>
</div>
{{/each}}
{{/if}}
</div>

View File

@ -0,0 +1,7 @@
{{text-field value=filter placeholderKey="explorer.schema.filter"}}
{{conditional-loading-spinner condition=loading}}
<div class="schema-container">
{{#each tables as |table|}}
{{explorer-schema-onetable table=table}}
{{/each}}
</div>

View File

@ -13,20 +13,76 @@
margin: 10px 0;
}
.sql textarea {
width: calc(100% - 8px);
height: 100px;
width: 500px;
height: 350px;
font-family: monospace;
border-color: $tertiary;
}
.left-buttons {
float: left;
}
.right-buttons {
float: right;
}
.clear { clear: both; }
}
.schema {
border: 1px solid black;
overflow-y: scroll;
> div {
width: 306px;
}
max-height: 400px;
margin-bottom: 10px;
}
.schema-title {
display: block;
margin: auto;
text-align: center;
}
.schema input {
padding: 4px;
margin: 3px;
width: calc(100% - 7px - 7px - 1px);
}
.schema .schema-table:first-child {
border-top: 1px solid;
}
.schema-table-name {
background: dark-light-diff($primary, $secondary, 80%, -20%);
border-color: dark-light-diff($primary, $secondary, 60%, -20%);
font-weight: bold;
border-bottom: 1px solid;
padding-left: 5px;
}
.schema-table-cols {
}
.schema-table-col {
background: $secondary;
border-color: dark-light-diff($primary, $secondary, 60%, -20%);
border-bottom: 1px solid;
padding-left: 10px;
}
.schema-colname {
font-weight: bold;
display: inline-block;
width: 150px;
word-wrap: break-word;
vertical-align: text-top;
background: dark-light-diff($primary, $secondary, 98%, -20%);
&.sensitive {
color: $danger;
}
}
.schema-type {
display: inline-block;
max-width: 140px;
vertical-align: text-top;
}
.schema-typenotes {
color: dark-light-diff($primary, $secondary, 50%, -20%);
font-style: italic;
}
.query-params {
border: 1px solid dark-light-diff($primary, $secondary, 60%, -20%);
input {

View File

@ -28,6 +28,11 @@ en:
import:
label: "Import"
modal: "Import A Query"
schema:
title: "Database Schema"
filter: "Search..."
sensitive: "The contents of this column may contain particularly sensitive or private information."
type_help: "<a href='http://www.postgresql.org/docs/9.3/static/datatype.html' target='_blank'>Types explanation</a>"
export: "Export"
save: "Save Changes"
run: "Run Query"

116
plugin.rb
View File

@ -37,7 +37,8 @@ after_initialize do
isolate_namespace DataExplorer
end
class ValidationError < StandardError; end
class ValidationError < StandardError;
end
# Extract :colon-style parameters from the SQL query and replace them with
# $1-style parameters.
@ -134,6 +135,97 @@ SQL
}
end
def self.sensitive_column_names
%w(
#_IP_Addresses
topic_views.ip_address
users.ip_address
users.registration_ip_address
incoming_links.ip_address
topic_link_clicks.ip_address
user_histories.ip_address
#_Emails
email_tokens.email
users.email
invites.email
user_histories.email
email_logs.to_address
posts.raw_email
badge_posts.raw_email
#_Secret_Tokens
email_tokens.token
email_logs.reply_key
api_keys.key
site_settings.value
users.password_hash
users.salt
#_Authentication_Info
user_open_ids.email
oauth2_user_infos.uid
oauth2_user_infos.email
facebook_user_infos.facebook_user_id
facebook_user_infos.email
twitter_user_infos.twitter_user_id
github_user_infos.github_user_id
single_sign_on_records.external_email
single_sign_on_records.external_id
google_user_infos.google_user_id
google_user_infos.email
)
end
def self.schema
# refer user to http://www.postgresql.org/docs/9.3/static/datatype.html
@schema ||= begin
results = ActiveRecord::Base.exec_sql <<SQL
select column_name, data_type, character_maximum_length, is_nullable, column_default, table_name
from INFORMATION_SCHEMA.COLUMNS where table_schema = 'public'
SQL
by_table = {}
# Massage the results into a nicer form
results.each do |hash|
if hash['is_nullable'] == "YES"
hash['is_nullable'] = true
else
hash.delete('is_nullable')
end
clen = hash.delete 'character_maximum_length'
dt = hash['data_type']
if dt == 'character varying'
hash['data_type'] = "varchar(#{clen.to_i})"
elsif dt == 'timestamp without time zone'
hash['data_type'] = 'timestamp'
elsif dt == 'double precision'
hash['data_type'] = 'double'
end
default = hash['column_default']
if default.nil? || default =~ /^nextval\(/
hash.delete 'column_default'
elsif default =~ /^'(.*)'::(character varying|text)/
hash['column_default'] = $1
end
if sensitive_column_names.include? "#{hash['table_name']}.#{hash['column_name']}"
hash['sensitive'] = true
end
tname = hash.delete('table_name')
by_table[tname] ||= []
by_table[tname] << hash
end
# this works for now, but no big loss if the tables aren't quite sorted
sorted_by_table = {}
by_table.keys.sort.each do |tbl|
sorted_by_table[tbl] = by_table[tbl]
end
sorted_by_table
end
end
end
# Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead
@ -230,9 +322,9 @@ SQL
def self.all
PluginStoreRow.where(plugin_name: DataExplorer.plugin_name)
.where("key LIKE 'q:%'")
.where("key != 'q:_id'")
.map do |psr|
.where("key LIKE 'q:%'")
.where("key != 'q:_id'")
.map do |psr|
DataExplorer::Query.from_hash PluginStore.cast_value(psr.type_name, psr.value)
end
end
@ -242,6 +334,12 @@ SQL
class DataExplorer::QueryController < ::ApplicationController
requires_plugin DataExplorer.plugin_name
before_filter :check_enabled
def check_enabled
raise Discourse::NotFound unless SiteSetting.data_explorer_enabled?
end
def index
# guardian.ensure_can_use_data_explorer!
queries = DataExplorer::Query.all
@ -249,6 +347,7 @@ SQL
end
skip_before_filter :check_xhr, only: [:show]
def show
check_xhr unless params[:export]
@ -301,6 +400,13 @@ SQL
render json: {success: true, errors: []}
end
def schema
schema_version = ActiveRecord::Base.exec_sql("SELECT max(version) AS tag FROM schema_migrations").first['tag']
if stale?(public: true, etag: schema_version)
render json: DataExplorer.schema
end
end
# Return value:
# success - true/false. if false, inspect the errors value.
# errors - array of strings.
@ -363,7 +469,7 @@ SQL
DataExplorer::Engine.routes.draw do
root to: "query#index"
get 'schema' => "query#schema"
get 'queries' => "query#index"
post 'queries' => "query#create"
get 'queries/:id' => "query#show"