Skip to content

Commit 945442a

Browse files
author
serhii.zahuba
committed
FEATURE/queries-via-UI
1 parent fcfe382 commit 945442a

File tree

14 files changed

+824
-3
lines changed

14 files changed

+824
-3
lines changed

backend/cmd/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"postgresus-backend/internal/features/restores"
2828
"postgresus-backend/internal/features/storages"
2929
system_healthcheck "postgresus-backend/internal/features/system/healthcheck"
30+
sqlquery "postgresus-backend/internal/features/query/postgresql"
3031
"postgresus-backend/internal/features/users"
3132
env_utils "postgresus-backend/internal/util/env"
3233
files_utils "postgresus-backend/internal/util/files"
@@ -178,6 +179,13 @@ func setUpRoutes(r *gin.Engine) {
178179
backupConfigController.RegisterRoutes(v1)
179180
postgresMonitoringSettingsController.RegisterRoutes(v1)
180181
postgresMonitoringMetricsController.RegisterRoutes(v1)
182+
//registerSQLQuery(
183+
// v1,
184+
// databases.GetDatabaseService(),
185+
// users.GetUserService(),
186+
//)
187+
sqlquery.GetPostgresSqlQueryController().RegisterRoutes(v1)
188+
181189
}
182190

183191
func setUpDependencies() {
@@ -313,3 +321,10 @@ func mountFrontend(ginApp *gin.Engine) {
313321
c.File(filepath.Join(staticDir, "index.html"))
314322
})
315323
}
324+
325+
//func registerSQLQuery(rg *gin.RouterGroup, dbSvc *databases.DatabaseService, userSvc *users.UserService) {
326+
// repo := sqlquery.NewRepository()
327+
// svc := sqlquery.NewService(repo)
328+
// ctrl := sqlquery.NewPostgresSqlQueryController(svc, userSvc, dbSvc)
329+
// ctrl.RegisterRoutes(rg)
330+
//}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package sqlquery
2+
3+
import (
4+
"net/http"
5+
6+
"postgresus-backend/internal/features/databases"
7+
"postgresus-backend/internal/features/users"
8+
9+
"github.com/gin-gonic/gin"
10+
)
11+
12+
type PostgresSqlQueryController struct {
13+
svc *Service
14+
userSvc *users.UserService
15+
dbSvc *databases.DatabaseService
16+
}
17+
18+
func NewPostgresSqlQueryController(svc *Service, userSvc *users.UserService, dbSvc *databases.DatabaseService) *PostgresSqlQueryController {
19+
return &PostgresSqlQueryController{svc: svc, userSvc: userSvc, dbSvc: dbSvc}
20+
}
21+
22+
func (c *PostgresSqlQueryController) RegisterRoutes(router *gin.RouterGroup) {
23+
router.POST("/sqlquery/execute", c.Execute)
24+
}
25+
26+
func (c *PostgresSqlQueryController) Execute(ctx *gin.Context) {
27+
var req ExecuteRequest
28+
if err := ctx.ShouldBindJSON(&req); err != nil {
29+
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
30+
return
31+
}
32+
33+
auth := ctx.GetHeader("Authorization")
34+
if auth == "" {
35+
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"})
36+
return
37+
}
38+
user, err := c.userSvc.GetUserFromToken(auth)
39+
if err != nil {
40+
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
41+
return
42+
}
43+
44+
dbc, err := c.dbSvc.GetDatabase(user, req.DatabaseID)
45+
if err != nil {
46+
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
47+
return
48+
}
49+
50+
resp, err := c.svc.Execute(dbc, &req)
51+
if err != nil {
52+
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
53+
return
54+
}
55+
56+
ctx.JSON(http.StatusOK, resp)
57+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package sqlquery
2+
3+
import (
4+
"postgresus-backend/internal/features/databases"
5+
"postgresus-backend/internal/features/users"
6+
)
7+
8+
var (
9+
sqlRepo *Repository
10+
sqlService *Service
11+
sqlPgController *PostgresSqlQueryController
12+
)
13+
14+
func init() {
15+
// репозиторій та сервіс
16+
sqlRepo = NewRepository()
17+
sqlService = NewService(sqlRepo)
18+
19+
// залежності з сусідніх модулів через їх DI
20+
userSvc := users.GetUserService()
21+
dbSvc := databases.GetDatabaseService()
22+
23+
// контролер
24+
sqlPgController = NewPostgresSqlQueryController(sqlService, userSvc, dbSvc)
25+
}
26+
27+
func GetSqlQueryRepository() *Repository { return sqlRepo }
28+
func GetSqlQueryService() *Service { return sqlService }
29+
30+
// Головне: цей контролер реєструємо у router
31+
func GetPostgresSqlQueryController() *PostgresSqlQueryController {
32+
return sqlPgController
33+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package sqlquery
2+
3+
import (
4+
"time"
5+
6+
"github.com/google/uuid"
7+
)
8+
9+
// ExecuteRequest — query to execute SELECT/CTE
10+
type ExecuteRequest struct {
11+
DatabaseID uuid.UUID `json:"databaseId" binding:"required"`
12+
SQL string `json:"sql" binding:"required"`
13+
MaxRows int `json:"maxRows"`
14+
TimeoutSec int `json:"timeoutSec"`
15+
}
16+
17+
type ExecuteResponse struct {
18+
Columns []string `json:"columns"`
19+
Rows [][]any `json:"rows"`
20+
RowCount int `json:"rowCount"`
21+
Truncated bool `json:"truncated"`
22+
ExecutionMs int64 `json:"executionMs"`
23+
}
24+
25+
// Simple structure for history/logs (for future in the DB)
26+
type QueryAudit struct {
27+
ID uuid.UUID `json:"id"`
28+
UserID uuid.UUID `json:"user_id"`
29+
DatabaseID uuid.UUID `json:"database_id"`
30+
SQL string `json:"sql"`
31+
RowCount int `json:"row_count"`
32+
Duration time.Duration `json:"duration"`
33+
When time.Time `json:"when"`
34+
Ok bool `json:"ok"`
35+
Error string `json:"error,omitempty"`
36+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package sqlquery
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"time"
8+
9+
"postgresus-backend/internal/features/databases"
10+
"postgresus-backend/internal/features/databases/databases/postgresql"
11+
12+
"github.com/jackc/pgx/v5/pgxpool"
13+
)
14+
15+
type Repository struct{}
16+
17+
func NewRepository() *Repository { return &Repository{} }
18+
19+
func (r *Repository) openPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
20+
cfg, err := pgxpool.ParseConfig(dsn)
21+
if err != nil {
22+
return nil, err
23+
}
24+
cfg.MaxConns = 2
25+
cfg.MinConns = 0
26+
cfg.MaxConnIdleTime = 90 * time.Second
27+
return pgxpool.NewWithConfig(ctx, cfg)
28+
}
29+
30+
func buildPostgresDSN(pg *postgresql.PostgresqlDatabase) (string, error) {
31+
if pg == nil {
32+
return "", fmt.Errorf("postgres config is nil")
33+
}
34+
if pg.Database == nil {
35+
return "", fmt.Errorf("database name is nil")
36+
}
37+
38+
db := *pg.Database
39+
40+
ssl := "disable"
41+
if pg.IsHttps {
42+
ssl = "require"
43+
}
44+
45+
return fmt.Sprintf(
46+
"host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
47+
pg.Host,
48+
pg.Port,
49+
pg.Username,
50+
pg.Password,
51+
db,
52+
ssl,
53+
), nil
54+
}
55+
56+
type Result struct {
57+
Columns []string
58+
Rows [][]any
59+
RowCount int
60+
Truncated bool
61+
Duration time.Duration
62+
}
63+
64+
// ExecuteSelect — safe SELECT/CTE with forced LIMIT
65+
// ExecuteSQL — один стейтмент: SELECT/CTE виконуємо з лімітом, інші — через Exec
66+
func (r *Repository) ExecuteSQL(ctx context.Context, dbc *databases.Database, sql string, maxRows int) (*Result, error) {
67+
// підтримуємо лише PostgreSQL (як і раніше)
68+
t := strings.ToUpper(string(dbc.Type))
69+
if t != "POSTGRES" && t != "POSTGRESQL" {
70+
return nil, fmt.Errorf("only PostgreSQL type is supported, got %q", dbc.Type)
71+
}
72+
73+
dsn, err := buildPostgresDSN(dbc.Postgresql)
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
pool, err := r.openPool(ctx, dsn)
79+
if err != nil {
80+
return nil, err
81+
}
82+
defer pool.Close()
83+
84+
stmt := strings.TrimSpace(sql)
85+
isSelect := isSelectLike(stmt)
86+
87+
start := time.Now()
88+
if isSelect {
89+
// SELECT / WITH ... SELECT — обгортаємо лімітом
90+
stmt = ensureLimit(stmt, maxRows)
91+
92+
rows, err := pool.Query(ctx, stmt)
93+
if err != nil {
94+
return nil, err
95+
}
96+
defer rows.Close()
97+
98+
fds := rows.FieldDescriptions()
99+
cols := make([]string, len(fds))
100+
for i, fd := range fds {
101+
cols[i] = fd.Name
102+
}
103+
104+
outRows := make([][]any, 0, 64)
105+
rowCount := 0
106+
for rows.Next() {
107+
vals, err := rows.Values()
108+
if err != nil {
109+
return nil, err
110+
}
111+
dst := make([]any, len(vals))
112+
copy(dst, vals)
113+
outRows = append(outRows, dst)
114+
rowCount++
115+
}
116+
if err := rows.Err(); err != nil {
117+
return nil, err
118+
}
119+
120+
return &Result{
121+
Columns: cols,
122+
Rows: outRows,
123+
RowCount: rowCount, // кількість повернутих рядків
124+
Truncated: rowCount >= maxRows,
125+
Duration: time.Since(start),
126+
}, nil
127+
}
128+
129+
// DML/DDL: UPDATE/INSERT/DELETE/CREATE/... — просто виконуємо
130+
tag, err := pool.Exec(ctx, stmt)
131+
if err != nil {
132+
return nil, err
133+
}
134+
135+
// фронту достатньо часу виконання; таблиця буде порожня
136+
// (за бажанням можна додати RowsAffected в DTO пізніше)
137+
return &Result{
138+
Columns: []string{},
139+
Rows: [][]any{},
140+
RowCount: int(tag.RowsAffected()), // для інфи: скільки рядків зачеплено
141+
Truncated: false,
142+
Duration: time.Since(start),
143+
}, nil
144+
}
145+
146+
// дуже проста евристика: SELECT або WITH без DML-ключових слів
147+
func isSelectLike(sql string) bool {
148+
up := strings.ToUpper(strings.TrimSpace(sql))
149+
if strings.HasPrefix(up, "SELECT ") {
150+
return true
151+
}
152+
if strings.HasPrefix(up, "WITH ") {
153+
// якщо це WITH ... INSERT/UPDATE/DELETE/... — вважаємо не-SELECT
154+
for _, k := range []string{" INSERT ", " UPDATE ", " DELETE ", " CREATE ", " ALTER ", " DROP ", " TRUNCATE ", " GRANT ", " REVOKE "} {
155+
if strings.Contains(up, k) {
156+
return false
157+
}
158+
}
159+
return true
160+
}
161+
return false
162+
}
163+
164+
// ensureLimit — guaranteed to wrap in a subscript with LIMIT
165+
func ensureLimit(sql string, maxRows int) string {
166+
s := strings.TrimSpace(sql)
167+
if maxRows <= 0 {
168+
maxRows = 1000
169+
}
170+
return fmt.Sprintf("SELECT * FROM (%s) __q LIMIT %d", s, maxRows)
171+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package sqlquery
2+
3+
import (
4+
"context"
5+
"errors"
6+
"time"
7+
8+
"postgresus-backend/internal/features/databases"
9+
)
10+
11+
type Service struct {
12+
repo *Repository
13+
defaultRows int
14+
defaultTimeout time.Duration
15+
}
16+
17+
func NewService(repo *Repository) *Service {
18+
return &Service{
19+
repo: repo,
20+
defaultRows: 1000,
21+
defaultTimeout: 30 * time.Second,
22+
}
23+
}
24+
25+
func (s *Service) Execute(dbc *databases.Database, req *ExecuteRequest) (*ExecuteResponse, error) {
26+
if req == nil {
27+
return nil, errors.New("empty request")
28+
}
29+
//if !IsSafeSelect(req.SQL) {
30+
// return nil, errors.New("only single SELECT/CTE statements are allowed")
31+
//}
32+
33+
maxRows := req.MaxRows
34+
if maxRows <= 0 || maxRows > 10000 {
35+
maxRows = s.defaultRows
36+
}
37+
38+
timeout := s.defaultTimeout
39+
if req.TimeoutSec > 0 && req.TimeoutSec < 120 {
40+
timeout = time.Duration(req.TimeoutSec) * time.Second
41+
}
42+
43+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
44+
defer cancel()
45+
46+
//res, err := s.repo.ExecuteSelect(ctx, dbc, req.SQL, maxRows)
47+
res, err := s.repo.ExecuteSQL(ctx, dbc, req.SQL, maxRows)
48+
if err != nil {
49+
return nil, err
50+
}
51+
52+
return &ExecuteResponse{
53+
Columns: res.Columns,
54+
Rows: res.Rows,
55+
RowCount: res.RowCount,
56+
Truncated: res.Truncated,
57+
ExecutionMs: res.Duration.Milliseconds(),
58+
}, nil
59+
}

0 commit comments

Comments
 (0)