Add option to write SQL queries in database explorer

This commit is contained in:
Tulir Asokan 2018-12-28 22:33:27 +02:00
parent 88107daa6f
commit 46186452dc
5 changed files with 243 additions and 91 deletions

View file

@ -13,11 +13,13 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import TYPE_CHECKING from typing import Union, TYPE_CHECKING
from datetime import datetime from datetime import datetime
from aiohttp import web from aiohttp import web
from sqlalchemy import Table, Column, asc, desc from sqlalchemy import Table, Column, asc, desc, exc
from sqlalchemy.orm import Query
from sqlalchemy.engine.result import ResultProxy, RowProxy
from ...instance import PluginInstance from ...instance import PluginInstance
from .base import routes from .base import routes
@ -70,15 +72,55 @@ async def get_table(request: web.Request) -> web.Response:
table = tables[request.match_info.get("table", "")] table = tables[request.match_info.get("table", "")]
except KeyError: except KeyError:
return resp.table_not_found return resp.table_not_found
db = instance.inst_db
try: try:
order = [tuple(order.split(":")) for order in request.query.getall("order")] order = [tuple(order.split(":")) for order in request.query.getall("order")]
order = [(asc if sort == "asc" else desc)(table.columns[column]) order = [(asc if sort.lower() == "asc" else desc)(table.columns[column])
if sort else table.columns[column] if sort else table.columns[column]
for column, sort in order] for column, sort in order]
except KeyError: except KeyError:
order = [] order = []
limit = int(request.query.get("limit", 100)) limit = int(request.query.get("limit", 100))
query = table.select().order_by(*order).limit(limit) return execute_query(instance, table.select().order_by(*order).limit(limit))
data = [[check_type(value) for value in row] for row in db.execute(query)]
@routes.post("/instance/{id}/database/query")
async def query(request: web.Request) -> web.Response:
instance_id = request.match_info.get("id", "")
instance = PluginInstance.get(instance_id, None)
if not instance:
return resp.instance_not_found
elif not instance.inst_db:
return resp.plugin_has_no_database
data = await request.json()
try:
sql_query = data["query"]
except KeyError:
return resp.query_missing
return execute_query(instance, sql_query,
rows_as_dict=data.get("rows_as_dict", False))
def execute_query(instance: PluginInstance, sql_query: Union[str, Query],
rows_as_dict: bool = False) -> web.Response:
try:
res: ResultProxy = instance.inst_db.execute(sql_query)
except exc.IntegrityError as e:
return resp.sql_integrity_error(e, sql_query)
except exc.OperationalError as e:
return resp.sql_operational_error(e, sql_query)
data = {
"ok": True,
"query": str(sql_query),
}
if res.returns_rows:
row: RowProxy
data["rows"] = [({key: check_type(value) for key, value in row.items()}
if rows_as_dict
else [check_type(value) for value in row])
for row in res]
data["columns"] = res.keys()
else:
data["rowcount"] = res.rowcount
if res.is_insert:
data["inserted_primary_key"] = res.inserted_primary_key
return web.json_response(data) return web.json_response(data)

View file

@ -16,7 +16,7 @@
from http import HTTPStatus from http import HTTPStatus
from aiohttp import web from aiohttp import web
from sqlalchemy.exc import OperationalError, IntegrityError
class _Response: class _Response:
@property @property
@ -82,6 +82,33 @@ class _Response:
"errcode": "username_or_password_missing", "errcode": "username_or_password_missing",
}, status=HTTPStatus.BAD_REQUEST) }, status=HTTPStatus.BAD_REQUEST)
@property
def query_missing(self) -> web.Response:
return web.json_response({
"error": "Query missing",
"errcode": "query_missing",
}, status=HTTPStatus.BAD_REQUEST)
@staticmethod
def sql_operational_error(error: OperationalError, query: str) -> web.Response:
return web.json_response({
"ok": False,
"query": query,
"error": str(error.orig),
"full_error": str(error),
"errcode": "sql_operational_error",
}, status=HTTPStatus.BAD_REQUEST)
@staticmethod
def sql_integrity_error(error: IntegrityError, query: str) -> web.Response:
return web.json_response({
"ok": False,
"query": query,
"error": str(error.orig),
"full_error": str(error),
"errcode": "sql_integrity_error",
}, status=HTTPStatus.BAD_REQUEST)
@property @property
def bad_auth(self) -> web.Response: def bad_auth(self) -> web.Response:
return web.json_response({ return web.json_response({

View file

@ -154,8 +154,14 @@ export const putInstance = (instance, id) => defaultPut("instance", instance, id
export const deleteInstance = id => defaultDelete("instance", id) export const deleteInstance = id => defaultDelete("instance", id)
export const getInstanceDatabase = id => defaultGet(`/instance/${id}/database`) export const getInstanceDatabase = id => defaultGet(`/instance/${id}/database`)
export const getInstanceDatabaseTable = (id, table, query = []) => export const queryInstanceDatabase = async (id, query) => {
defaultGet(`/instance/${id}/database/${table}?${query.join("&")}`) const resp = await fetch(`${BASE_PATH}/instance/${id}/database/query`, {
headers: getHeaders(),
body: JSON.stringify({ query }),
method: "POST",
})
return await resp.json()
}
export const getPlugins = () => defaultGet("/plugins") export const getPlugins = () => defaultGet("/plugins")
export const getPlugin = id => defaultGet(`/plugin/${id}`) export const getPlugin = id => defaultGet(`/plugin/${id}`)
@ -207,7 +213,7 @@ export default {
BASE_PATH, BASE_PATH,
login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled, login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance, getInstances, getInstance, putInstance, deleteInstance,
getInstanceDatabase, getInstanceDatabaseTable, getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin, getPlugins, getPlugin, uploadPlugin, deletePlugin,
getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient, getClients, getClient, uploadAvatar, getAvatarURL, putClient, deleteClient,
} }

View file

@ -14,21 +14,38 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react" import React, { Component } from "react"
import { NavLink, Link, Route, withRouter } from "react-router-dom" import { NavLink, Link, withRouter } from "react-router-dom"
import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg" import { ReactComponent as ChevronLeft } from "../../res/chevron-left.svg"
import { ReactComponent as OrderDesc } from "../../res/sort-down.svg" import { ReactComponent as OrderDesc } from "../../res/sort-down.svg"
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg" import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
import api from "../../api" import api from "../../api"
import Spinner from "../../components/Spinner" import Spinner from "../../components/Spinner"
Map.prototype.map = function(func) {
const res = []
for (const [key, value] of this) {
res.push(func(value, key, this))
}
return res
}
class InstanceDatabase extends Component { class InstanceDatabase extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
tables: null, tables: null,
tableContent: null, header: null,
content: null,
query: "",
selectedTable: null,
error: null,
prevQuery: null,
rowCount: null,
insertedPrimaryKey: null,
} }
this.sortBy = [] this.order = new Map()
} }
async componentWillMount() { async componentWillMount() {
@ -47,8 +64,8 @@ class InstanceDatabase extends Component {
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) { if (this.props.location !== prevProps.location) {
this.sortBy = [] this.order = new Map()
this.setState({ tableContent: null }) this.setState({ header: null, content: null })
this.checkLocationTable() this.checkLocationTable()
} }
} }
@ -57,76 +74,108 @@ class InstanceDatabase extends Component {
const prefix = `/instance/${this.props.instanceID}/database/` const prefix = `/instance/${this.props.instanceID}/database/`
if (this.props.location.pathname.startsWith(prefix)) { if (this.props.location.pathname.startsWith(prefix)) {
const table = this.props.location.pathname.substr(prefix.length) const table = this.props.location.pathname.substr(prefix.length)
this.reloadContent(table) this.setState({ selectedTable: table })
this.buildSQLQuery(table)
} }
} }
getSortQuery(table) { getSortQueryParams(table) {
const sort = [] const order = []
for (const column of this.sortBy) { for (const [column, sort] of Array.from(this.order.entries()).reverse()) {
sort.push(`order=${column.name}:${column.sort}`) order.push(`order=${column}:${sort}`)
} }
return sort return order
} }
async reloadContent(name) { buildSQLQuery(table = this.state.selectedTable) {
const table = this.state.tables.get(name) let query = `SELECT * FROM ${table}`
const query = this.getSortQuery(table)
query.push("limit=100") if (this.order.size > 0) {
const order = Array.from(this.order.entries()).reverse()
.map(([column, sort]) => `${column} ${sort}`)
query += ` ORDER BY ${order.join(", ")}`
}
query += " LIMIT 100"
this.setState({ query }, this.reloadContent)
}
reloadContent = async () => {
this.setState({ loading: true })
const res = await api.queryInstanceDatabase(this.props.instanceID, this.state.query)
this.setState({ this.setState({
tableContent: await api.getInstanceDatabaseTable( loading: false,
this.props.instanceID, table.name, query), prevQuery: null,
rowCount: null,
insertedPrimaryKey: null,
error: null,
}) })
if (!res.ok) {
this.setState({
error: res.error,
})
this.buildSQLQuery()
} else if (res.rows) {
this.setState({
header: res.columns,
content: res.rows,
})
} else {
this.setState({
prevQuery: res.query,
rowCount: res.rowcount,
insertedPrimaryKey: res.insertedPrimaryKey,
})
this.buildSQLQuery()
}
} }
toggleSort(tableName, column) { toggleSort(column) {
const index = this.sortBy.indexOf(column) const oldSort = this.order.get(column) || "auto"
if (index >= 0) { this.order.delete(column)
this.sortBy.splice(index, 1) switch (oldSort) {
} case "auto":
switch (column.sort) { this.order.set(column, "DESC")
break
case "DESC":
this.order.set(column, "ASC")
break
case "ASC":
default: default:
column.sort = "desc"
this.sortBy.unshift(column)
break
case "desc":
column.sort = "asc"
this.sortBy.unshift(column)
break
case "asc":
column.sort = null
break break
} }
this.forceUpdate() this.buildSQLQuery()
this.reloadContent(tableName)
} }
renderTableHead = table => <thead> getSortIcon(column) {
switch (this.order.get(column)) {
case "DESC":
return <OrderDesc/>
case "ASC":
return <OrderAsc/>
default:
return null
}
}
renderTable = () => <div className="table">
{this.state.header ? (
<table>
<thead>
<tr> <tr>
{Array.from(table.columns.entries()).map(([name, column]) => ( {this.state.header.map(column => (
<td key={name}> <td key={column}>
<span onClick={() => this.toggleSort(table.name, column)}> <span onClick={() => this.toggleSort(column)}>
{name} {column}
{column.sort === "desc" ? {this.getSortIcon(column)}
<OrderDesc/> :
column.sort === "asc"
? <OrderAsc/>
: null}
</span> </span>
</td> </td>
))} ))}
</tr> </tr>
</thead> </thead>
renderTable = ({ match }) => {
const table = this.state.tables.get(match.params.table)
return <div className="table">
{this.state.tableContent ? (
<table>
{this.renderTableHead(table)}
<tbody> <tbody>
{this.state.tableContent.map(row => ( {this.state.content.map((row, index) => (
<tr key={row}> <tr key={index}>
{row.map((column, index) => ( {row.map((column, index) => (
<td key={index}> <td key={index}>
{column} {column}
@ -136,27 +185,36 @@ class InstanceDatabase extends Component {
))} ))}
</tbody> </tbody>
</table> </table>
) : <> ) : this.state.loading ? <Spinner/> : null}
<table>
{this.renderTableHead(table)}
</table>
<Spinner/>
</>}
</div> </div>
}
renderContent() { renderContent() {
return <> return <>
<div className="tables"> <div className="tables">
{Array.from(this.state.tables.keys()).map(key => ( {this.state.tables.map((_, tbl) => (
<NavLink key={key} to={`/instance/${this.props.instanceID}/database/${key}`}> <NavLink key={tbl} to={`/instance/${this.props.instanceID}/database/${tbl}`}>
{key} {tbl}
</NavLink> </NavLink>
))} ))}
</div> </div>
<Route path={`/instance/${this.props.instanceID}/database/:table`} <div className="query">
render={this.renderTable}/> <input type="text" value={this.state.query} name="query"
onChange={evt => this.setState({ query: evt.target.value })}/>
<button type="submit" onClick={this.reloadContent}>Query</button>
</div>
{this.state.error && <div className="error">
{this.state.error}
</div>}
{this.state.prevQuery && <div className="prev-query">
<p>
Executed <span className="query">{this.state.prevQuery}</span> -
affected <strong>{this.state.rowCount} rows</strong>.
</p>
{this.state.insertedPrimaryKey && <p className="inserted-primary-key">
Inserted primary key: {this.state.insertedPrimaryKey}
</p>}
</div>}
{this.renderTable()}
</> </>
} }

View file

@ -61,6 +61,25 @@
&.active &.active
background-color: $primary background-color: $primary
> div.query
display: flex
> input
+input
font-family: "Fira Code", monospace
flex: 1
margin-right: .5rem
> button
+button
+main-color-button
> div.prev-query
+notification($primary, $primary-light)
span.query
font-family: "Fira Code", monospace
> div.table > div.table
overflow-x: auto overflow-x: auto
overflow-y: hidden overflow-y: hidden