Files
Primora/apps/backend/internal/handlers/databases.go
T
Tomas Dvorak d57f30cd59 feat: schema graph via FK introspection
- GET .../db-connections/:id/foreign-keys — per-dialect
  information_schema/pragma query through dbx_execute_query,
  identifier-whitelisted, read-role gated
- Databases page: Graph tab renders tables as radial SVG nodes with
  FK edges; hover highlights adjacency, click inspects the table
- demo stubs for edges + wider demo table set

Authored By: TDvorak <[email protected]>
2026-09-16 15:27:28 +02:00

249 lines
6.8 KiB
Go

package handlers
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
"github.com/tdvorak/primora/apps/backend/internal/models"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) dbxStatus(c *gin.Context) {
actor, ok := middleware.RequireActor(c)
if !ok {
return
}
result, err := h.Platform.DBXStatus(c.Request.Context(), actor)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) listDBConnections(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListDBConnections(c.Request.Context(), actor, projectID)
if err != nil {
h.handleError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) createDBConnection(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.CreateDBConnectionInput
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.CreateDBConnection(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
if strings.Contains(err.Error(), "already exists") {
apperrors.Abort(c, http.StatusConflict, "name_conflict", err.Error())
return
}
h.handleError(c, err)
return
}
c.JSON(http.StatusCreated, result)
}
func (h *HTTPHandler) deleteDBConnection(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
if err := h.Platform.DeleteDBConnection(c.Request.Context(), actor, projectID, connectionID, middleware.RequestIDFromContext(c)); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "connection not found")
return
}
if strings.Contains(err.Error(), "platform-managed") {
apperrors.Abort(c, http.StatusConflict, "managed_connection", err.Error())
return
}
h.handleError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) testDBConnection(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
result, err := h.Platform.TestDBConnection(c.Request.Context(), actor, projectID, connectionID)
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) dbxDatabases(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
result, err := h.Platform.ListDBXDatabases(c.Request.Context(), actor, projectID, connectionID)
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) dbxTables(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
result, err := h.Platform.ListDBXTables(c.Request.Context(), actor, projectID, connectionID,
strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) dbxDescribe(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
result, err := h.Platform.DescribeDBXTable(c.Request.Context(), actor, projectID, connectionID,
strings.TrimSpace(c.Query("table")), strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) dbxSchema(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
result, err := h.Platform.DBXSchemaContext(c.Request.Context(), actor, projectID, connectionID,
strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) dbxForeignKeys(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
result, err := h.Platform.ListDBXForeignKeys(c.Request.Context(), actor, projectID, connectionID,
strings.TrimSpace(c.Query("database")), strings.TrimSpace(c.Query("schema")))
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) dbxQuery(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
var body struct {
SQL string `json:"sql" validate:"required"`
Database string `json:"database"`
}
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.ExecuteDBXQuery(c.Request.Context(), actor, projectID, connectionID, body.SQL, strings.TrimSpace(body.Database))
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) dbxRedis(c *gin.Context) {
actor, projectID, connectionID, ok := h.actorProjectConn(c)
if !ok {
return
}
var body struct {
Command string `json:"command" validate:"required"`
DB *int `json:"db"`
}
if !h.bindAndValidate(c, &body) {
return
}
result, err := h.Platform.ExecuteDBXRedis(c.Request.Context(), actor, projectID, connectionID, body.Command, body.DB)
if err != nil {
h.dbxError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
// actorAndProject resolves the actor and :projectID param.
func (h *HTTPHandler) actorAndProject(c *gin.Context) (*models.Actor, uuid.UUID, bool) {
actor, ok := middleware.RequireActor(c)
if !ok {
return nil, uuid.Nil, false
}
projectID, ok := parseUUIDParam(c, "projectID")
if !ok {
return nil, uuid.Nil, false
}
return actor, projectID, true
}
// actorProjectConn resolves the actor, :projectID and :connectionID params.
func (h *HTTPHandler) actorProjectConn(c *gin.Context) (*models.Actor, uuid.UUID, uuid.UUID, bool) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
connectionID, ok := parseUUIDParam(c, "connectionID")
if !ok {
return nil, uuid.Nil, uuid.Nil, false
}
return actor, projectID, connectionID, true
}
// dbxError maps DBX subprocess/tool failures to 502, not-found rows to 404.
func (h *HTTPHandler) dbxError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "connection not found")
return
}
if strings.Contains(err.Error(), "access denied") || strings.Contains(err.Error(), "insufficient") || strings.Contains(err.Error(), "authentication required") {
h.handleError(c, err)
return
}
if strings.Contains(err.Error(), "table required") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", err.Error())
return
}
apperrors.Abort(c, http.StatusBadGateway, "dbx_error", err.Error())
}