minimal sql console API and UI

This commit is contained in:
Gani Georgiev
2026-05-20 16:22:51 +03:00
parent db853850ab
commit b022e138d4
21 changed files with 984 additions and 21 deletions
+1
View File
@@ -48,6 +48,7 @@ func NewRouter(app core.App) (*router.Router[*core.RequestEvent], error) {
bindBatchApi(app, apiGroup)
bindRealtimeApi(app, apiGroup)
bindHealthApi(app, apiGroup)
bindSQLApi(app, apiGroup)
// UI routes
bindUIExtensions(app)
+191
View File
@@ -0,0 +1,191 @@
package apis
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
const (
runSQLMaxRows = 1000
runSQLMaxTimeout = 3 * time.Minute
)
// bindSQLApi registers the SQL api endpoints.
func bindSQLApi(app core.App, rg *router.RouterGroup[*core.RequestEvent]) {
subGroup := rg.Group("/sql").Bind(RequireSuperuserAuth())
subGroup.POST("", runSQL)
}
func runSQL(e *core.RequestEvent) error {
// extra precaution in case manually invoked from somewhere else
if !e.HasSuperuserAuth() {
return e.ForbiddenError("", nil)
}
form := runSQLForm{}
err := e.BindBody(&form)
if err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
}
err = form.validate()
if err != nil {
return firstApiError(err, e.BadRequestError("An error occurred while validating the submitted data.", err))
}
result, err := executeQuery(e.App, form.Query, runSQLMaxRows)
if err != nil {
return firstApiError(err, e.BadRequestError("Failed to execute query. Raw error:\n"+err.Error(), nil))
}
return e.JSON(http.StatusOK, result)
}
type runSQLForm struct {
Query string `form:"query" json:"query"`
}
func (form *runSQLForm) validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Query, validation.Required, validation.Length(0, 3000)),
)
}
type runSQLResultColumn struct {
Name string `json:"name"`
Type string `json:"type"`
Nullable bool `json:"nullable"`
}
type runSQLResult struct {
ExecTime int64 `json:"execTime"`
AffectedRows int64 `json:"affectedRows"`
Columns []runSQLResultColumn `json:"columns"`
Rows [][]any `json:"rows"`
}
var knownWriteQueryPrefixes = []string{"INSERT", "CREATE", "UPDATE", "DELETE", "DROP", "DETACH"}
func executeQuery(app core.App, query string, maxRows int) (*runSQLResult, error) {
query = strings.TrimSpace(query)
if query == "" {
// see https://github.com/mattn/go-sqlite3/issues/950
return nil, errors.New("empty query")
}
var isPossibleWriteQuery bool
// loosely check the query type
ucQuery := strings.ToUpper(query)
if !strings.HasPrefix(ucQuery, "SELECT") {
for _, prefix := range knownWriteQueryPrefixes {
if strings.HasPrefix(ucQuery, prefix) {
isPossibleWriteQuery = true
break
}
}
}
// note: don't extend the request context to minimize the risk of
// causing integrity issues with custom non-transaction mutations
ctx, cancelFunc := context.WithTimeout(context.Background(), runSQLMaxTimeout)
defer cancelFunc()
result := &runSQLResult{
// init empty slices to ensure "[]" serialization
Columns: []runSQLResultColumn{},
Rows: [][]any{},
}
now := time.Now()
defer func() {
result.ExecTime = time.Since(now).Milliseconds()
}()
// assume write/mutation query
// ---------------------------------------------------------------
if isPossibleWriteQuery {
// auto wrap in transaction in case there are multiple inline queries
txErr := app.RunInTransaction(func(txApp core.App) error {
execResult, err := txApp.NonconcurrentDB().NewQuery(query).WithContext(ctx).Execute()
if err != nil {
return err
}
result.AffectedRows, err = execResult.RowsAffected()
if err != nil {
// non-critical error (e.g. not supported by the driver)
txApp.Logger().Debug("Unable to fetch affected rows", slog.String("error", err.Error()))
}
return nil
})
if txErr != nil {
return nil, txErr
}
return result, nil
}
// assume query returning rows
// ---------------------------------------------------------------
rows, err := app.ConcurrentDB().NewQuery(query).WithContext(ctx).Rows()
if err != nil {
return nil, err
}
defer rows.Close()
// populate columns info
// ---
colTypes, err := rows.ColumnTypes()
if err != nil {
return nil, err
}
for _, colType := range colTypes {
col := runSQLResultColumn{
Name: colType.Name(),
Type: colType.DatabaseTypeName(),
}
col.Nullable, _ = colType.Nullable()
result.Columns = append(result.Columns, col)
}
// populate rows
// ---
for rows.Next() {
if len(result.Rows) >= maxRows {
break
}
rowData := make([]any, len(colTypes))
for i := 0; i < len(colTypes); i++ {
var v *string
rowData[i] = &v
}
err := rows.Scan(rowData...)
if err != nil {
return nil, err
}
result.Rows = append(result.Rows, rowData)
}
err = rows.Err()
if err != nil {
return nil, err
}
return result, nil
}
+202
View File
@@ -0,0 +1,202 @@
package apis_test
import (
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/tests"
)
func TestSQLRun(t *testing.T) {
t.Parallel()
scenarios := []tests.ApiScenario{
{
Name: "guest",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1"}`),
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "regular user",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1"}`),
Headers: map[string]string{
// users, test2@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6Im9hcDY0MGNvdDR5cnUycyIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoiX3BiX3VzZXJzX2F1dGhfIiwiZXhwIjoyNTI0NjA0NDYxLCJyZWZyZXNoYWJsZSI6dHJ1ZX0.GfJo6EHIobgas_AXt-M-tj5IoQendPnrkMSe9ExuSEY",
},
ExpectedStatus: 403,
ExpectedContent: []string{`"data":{}`},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "superuser",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":0`,
`"columns":[{"name":"1","type":"","nullable":true}]`,
`"rows":[["1"]]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "empty query",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":""}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"query":{`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "long query",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"` + strings.Repeat("a", 3001) + `"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"query":{`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "invalid query",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"invalid"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
`SQL logic error`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "single write query",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key)"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if !app.HasTable("test_sql_table") {
t.Fatalf("Missing expected new %q table", "test_sql_table")
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":0`,
`"columns":[]`,
`"rows":[]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "multiple write queries",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1)"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
var total int
err := app.DB().NewQuery("select count(*) from test_sql_table").Row(&total)
if err != nil {
t.Fatal(err)
}
if total != 1 {
t.Fatalf("Expected exactly 1 row, found: %d", total)
}
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":1`,
`"columns":[]`,
`"rows":[]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "multiple write queries (transaction rollback)",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"create table test_sql_table(id int primary key);insert into test_sql_table(id)VALUES(1);invalid"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
if app.HasTable("test_sql_table") {
t.Fatalf("Expected table %q to not be created", "test_sql_table")
}
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{}`,
`Raw error:`,
`SQL logic error`,
},
ExpectedEvents: map[string]int{"*": 0},
},
{
Name: "multiple read queries",
Method: http.MethodPost,
URL: "/api/sql",
Body: strings.NewReader(`{"query":"select 1;select 2"}`),
Headers: map[string]string{
// superusers, test@example.com
"Authorization": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6InN5d2JoZWNuaDQ2cmhtMCIsInR5cGUiOiJhdXRoIiwiY29sbGVjdGlvbklkIjoicGJjXzMxNDI2MzU4MjMiLCJleHAiOjI1MjQ2MDQ0NjEsInJlZnJlc2hhYmxlIjp0cnVlfQ.UXgO3j-0BumcugrFjbd7j0M4MQvbrLggLlcu_YNGjoY",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"execTime":`,
`"affectedRows":0`,
// only the result of the last query should be returned
`"columns":[{"name":"2","type":"","nullable":true}]`,
`"rows":[["2"]]`,
},
ExpectedEvents: map[string]int{"*": 0},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}