Add option to write SQL queries in database explorer
This commit is contained in:
parent
88107daa6f
commit
46186452dc
@ -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)
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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()}
|
||||
</>
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user