Add DB schema view beside query editor
This commit is contained in:
parent
a0f38a2d17
commit
6ac463290e
|
@ -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')
|
||||||
|
});
|
|
@ -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')
|
||||||
|
});
|
|
@ -4,6 +4,17 @@ export default Discourse.Route.extend({
|
||||||
queryParams: { id: { replace: true } },
|
queryParams: { id: { replace: true } },
|
||||||
|
|
||||||
model() {
|
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
{{#if selectedItem}}
|
{{#if selectedItem}}
|
||||||
|
<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="pull-left">
|
||||||
<div class="name">
|
<div class="name">
|
||||||
{{#if editName}}
|
{{#if editName}}
|
||||||
{{text-field value=selectedItem.name}}
|
{{text-field value=selectedItem.name}}
|
||||||
|
@ -17,11 +27,13 @@
|
||||||
<div class="sql">
|
<div class="sql">
|
||||||
{{textarea value=selectedItem.sql}}
|
{{textarea value=selectedItem.sql}}
|
||||||
</div>
|
</div>
|
||||||
<div class="left-buttons">
|
</div>
|
||||||
|
<div class="clear"></div>
|
||||||
|
<div class="pull-left">
|
||||||
{{d-button action="save" label="explorer.save" disabled=saveDisabled}}
|
{{d-button action="save" label="explorer.save" disabled=saveDisabled}}
|
||||||
{{d-button action="download" label="explorer.export" disabled=runDisabled icon="download"}}
|
{{d-button action="download" label="explorer.export" disabled=runDisabled icon="download"}}
|
||||||
</div>
|
</div>
|
||||||
<div class="right-buttons">
|
<div class="pull-right">
|
||||||
{{#if selectedItem.destroyed}}
|
{{#if selectedItem.destroyed}}
|
||||||
{{d-button action="recover" class="" icon="undo" label="explorer.recover"}}
|
{{d-button action="recover" class="" icon="undo" label="explorer.recover"}}
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
{{i18n "explorer.no_queries"}} <a {{action "showCreate"}}>{{i18n "explorer.no_queries_hook"}}</a>
|
{{i18n "explorer.no_queries"}} <a {{action "showCreate"}}>{{i18n "explorer.no_queries_hook"}}</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="query-edit {{if editName "editing"}}">
|
<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>
|
||||||
|
|
||||||
<div class="query-run">
|
<div class="query-run">
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -13,20 +13,76 @@
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
}
|
}
|
||||||
.sql textarea {
|
.sql textarea {
|
||||||
width: calc(100% - 8px);
|
width: 500px;
|
||||||
height: 100px;
|
height: 350px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
border-color: $tertiary;
|
border-color: $tertiary;
|
||||||
}
|
}
|
||||||
.left-buttons {
|
|
||||||
float: left;
|
|
||||||
}
|
|
||||||
.right-buttons {
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
.clear { clear: both; }
|
.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 {
|
.query-params {
|
||||||
border: 1px solid dark-light-diff($primary, $secondary, 60%, -20%);
|
border: 1px solid dark-light-diff($primary, $secondary, 60%, -20%);
|
||||||
input {
|
input {
|
||||||
|
|
|
@ -28,6 +28,11 @@ en:
|
||||||
import:
|
import:
|
||||||
label: "Import"
|
label: "Import"
|
||||||
modal: "Import A Query"
|
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"
|
export: "Export"
|
||||||
save: "Save Changes"
|
save: "Save Changes"
|
||||||
run: "Run Query"
|
run: "Run Query"
|
||||||
|
|
110
plugin.rb
110
plugin.rb
|
@ -37,7 +37,8 @@ after_initialize do
|
||||||
isolate_namespace DataExplorer
|
isolate_namespace DataExplorer
|
||||||
end
|
end
|
||||||
|
|
||||||
class ValidationError < StandardError; end
|
class ValidationError < StandardError;
|
||||||
|
end
|
||||||
|
|
||||||
# Extract :colon-style parameters from the SQL query and replace them with
|
# Extract :colon-style parameters from the SQL query and replace them with
|
||||||
# $1-style parameters.
|
# $1-style parameters.
|
||||||
|
@ -134,6 +135,97 @@ SQL
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
# Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead
|
# Reimplement a couple ActiveRecord methods, but use PluginStore for storage instead
|
||||||
|
@ -242,6 +334,12 @@ SQL
|
||||||
class DataExplorer::QueryController < ::ApplicationController
|
class DataExplorer::QueryController < ::ApplicationController
|
||||||
requires_plugin DataExplorer.plugin_name
|
requires_plugin DataExplorer.plugin_name
|
||||||
|
|
||||||
|
before_filter :check_enabled
|
||||||
|
|
||||||
|
def check_enabled
|
||||||
|
raise Discourse::NotFound unless SiteSetting.data_explorer_enabled?
|
||||||
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
# guardian.ensure_can_use_data_explorer!
|
# guardian.ensure_can_use_data_explorer!
|
||||||
queries = DataExplorer::Query.all
|
queries = DataExplorer::Query.all
|
||||||
|
@ -249,6 +347,7 @@ SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
skip_before_filter :check_xhr, only: [:show]
|
skip_before_filter :check_xhr, only: [:show]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
check_xhr unless params[:export]
|
check_xhr unless params[:export]
|
||||||
|
|
||||||
|
@ -301,6 +400,13 @@ SQL
|
||||||
render json: {success: true, errors: []}
|
render json: {success: true, errors: []}
|
||||||
end
|
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:
|
# Return value:
|
||||||
# success - true/false. if false, inspect the errors value.
|
# success - true/false. if false, inspect the errors value.
|
||||||
# errors - array of strings.
|
# errors - array of strings.
|
||||||
|
@ -363,7 +469,7 @@ SQL
|
||||||
|
|
||||||
DataExplorer::Engine.routes.draw do
|
DataExplorer::Engine.routes.draw do
|
||||||
root to: "query#index"
|
root to: "query#index"
|
||||||
|
get 'schema' => "query#schema"
|
||||||
get 'queries' => "query#index"
|
get 'queries' => "query#index"
|
||||||
post 'queries' => "query#create"
|
post 'queries' => "query#create"
|
||||||
get 'queries/:id' => "query#show"
|
get 'queries/:id' => "query#show"
|
||||||
|
|
Loading…
Reference in New Issue