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
# 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 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 .base import routes
@ -70,15 +72,55 @@ async def get_table(request: web.Request) -> web.Response:
table = tables[request.match_info.get("table", "")]
except KeyError:
return resp.table_not_found
db = instance.inst_db
try:
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]
for column, sort in order]
except KeyError:
order = []
limit = int(request.query.get("limit", 100))
query = table.select().order_by(*order).limit(limit)
data = [[check_type(value) for value in row] for row in db.execute(query)]
return execute_query(instance, table.select().order_by(*order).limit(limit))
@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)

View File

@ -16,7 +16,7 @@
from http import HTTPStatus
from aiohttp import web
from sqlalchemy.exc import OperationalError, IntegrityError
class _Response:
@property
@ -82,6 +82,33 @@ class _Response:
"errcode": "username_or_password_missing",
}, 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
def bad_auth(self) -> web.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 getInstanceDatabase = id => defaultGet(`/instance/${id}/database`)
export const getInstanceDatabaseTable = (id, table, query = []) =>
defaultGet(`/instance/${id}/database/${table}?${query.join("&")}`)
export const queryInstanceDatabase = async (id, query) => {
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 getPlugin = id => defaultGet(`/plugin/${id}`)
@ -207,7 +213,7 @@ export default {
BASE_PATH,
login, ping, openLogSocket, debugOpenFile, debugOpenFileEnabled, updateDebugOpenFileEnabled,
getInstances, getInstance, putInstance, deleteInstance,
getInstanceDatabase, getInstanceDatabaseTable,
getInstanceDatabase, queryInstanceDatabase,
getPlugins, getPlugin, uploadPlugin, deletePlugin,
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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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 OrderDesc } from "../../res/sort-down.svg"
import { ReactComponent as OrderAsc } from "../../res/sort-up.svg"
import api from "../../api"
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 {
constructor(props) {
super(props)
this.state = {
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() {
@ -47,8 +64,8 @@ class InstanceDatabase extends Component {
componentDidUpdate(prevProps) {
if (this.props.location !== prevProps.location) {
this.sortBy = []
this.setState({ tableContent: null })
this.order = new Map()
this.setState({ header: null, content: null })
this.checkLocationTable()
}
}
@ -57,76 +74,108 @@ class InstanceDatabase extends Component {
const prefix = `/instance/${this.props.instanceID}/database/`
if (this.props.location.pathname.startsWith(prefix)) {
const table = this.props.location.pathname.substr(prefix.length)
this.reloadContent(table)
this.setState({ selectedTable: table })
this.buildSQLQuery(table)
}
}
getSortQuery(table) {
const sort = []
for (const column of this.sortBy) {
sort.push(`order=${column.name}:${column.sort}`)
getSortQueryParams(table) {
const order = []
for (const [column, sort] of Array.from(this.order.entries()).reverse()) {
order.push(`order=${column}:${sort}`)
}
return sort
return order
}
async reloadContent(name) {
const table = this.state.tables.get(name)
const query = this.getSortQuery(table)
query.push("limit=100")
buildSQLQuery(table = this.state.selectedTable) {
let query = `SELECT * FROM ${table}`
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({
tableContent: await api.getInstanceDatabaseTable(
this.props.instanceID, table.name, query),
loading: false,
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) {
const index = this.sortBy.indexOf(column)
if (index >= 0) {
this.sortBy.splice(index, 1)
}
switch (column.sort) {
toggleSort(column) {
const oldSort = this.order.get(column) || "auto"
this.order.delete(column)
switch (oldSort) {
case "auto":
this.order.set(column, "DESC")
break
case "DESC":
this.order.set(column, "ASC")
break
case "ASC":
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
}
this.forceUpdate()
this.reloadContent(tableName)
this.buildSQLQuery()
}
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>
{Array.from(table.columns.entries()).map(([name, column]) => (
<td key={name}>
<span onClick={() => this.toggleSort(table.name, column)}>
{name}
{column.sort === "desc" ?
<OrderDesc/> :
column.sort === "asc"
? <OrderAsc/>
: null}
{this.state.header.map(column => (
<td key={column}>
<span onClick={() => this.toggleSort(column)}>
{column}
{this.getSortIcon(column)}
</span>
</td>
))}
</tr>
</thead>
renderTable = ({ match }) => {
const table = this.state.tables.get(match.params.table)
return <div className="table">
{this.state.tableContent ? (
<table>
{this.renderTableHead(table)}
<tbody>
{this.state.tableContent.map(row => (
<tr key={row}>
{this.state.content.map((row, index) => (
<tr key={index}>
{row.map((column, index) => (
<td key={index}>
{column}
@ -136,27 +185,36 @@ class InstanceDatabase extends Component {
))}
</tbody>
</table>
) : <>
<table>
{this.renderTableHead(table)}
</table>
<Spinner/>
</>}
) : this.state.loading ? <Spinner/> : null}
</div>
}
renderContent() {
return <>
<div className="tables">
{Array.from(this.state.tables.keys()).map(key => (
<NavLink key={key} to={`/instance/${this.props.instanceID}/database/${key}`}>
{key}
{this.state.tables.map((_, tbl) => (
<NavLink key={tbl} to={`/instance/${this.props.instanceID}/database/${tbl}`}>
{tbl}
</NavLink>
))}
</div>
<Route path={`/instance/${this.props.instanceID}/database/:table`}
render={this.renderTable}/>
<div className="query">
<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
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
overflow-x: auto
overflow-y: hidden