Skip to content

Commit 50bad75

Browse files
authored
Merge pull request #41 from flatrun/feat/table-schema-api
feat(api): Add table schema endpoint
2 parents 29b375e + 88cea65 commit 50bad75

File tree

2 files changed

+272
-0
lines changed

2 files changed

+272
-0
lines changed

internal/api/server.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ func (s *Server) setupRoutes() {
221221
protected.POST("/databases/list", s.listDatabasesInServer)
222222
protected.POST("/databases/tables", s.listDatabaseTables)
223223
protected.POST("/databases/tables/data", s.queryTableData)
224+
protected.POST("/databases/tables/schema", s.describeTable)
224225
protected.POST("/databases/query", s.executeDatabaseQuery)
225226
protected.POST("/databases/users", s.listDatabaseUsers)
226227
protected.POST("/databases/create", s.createDatabaseInServer)
@@ -3620,6 +3621,30 @@ func (s *Server) queryTableData(c *gin.Context) {
36203621
c.JSON(http.StatusOK, result)
36213622
}
36223623

3624+
func (s *Server) describeTable(c *gin.Context) {
3625+
var req struct {
3626+
database.ConnectionConfig
3627+
Database string `json:"database" binding:"required"`
3628+
Table string `json:"table" binding:"required"`
3629+
}
3630+
if err := c.ShouldBindJSON(&req); err != nil {
3631+
c.JSON(http.StatusBadRequest, gin.H{
3632+
"error": err.Error(),
3633+
})
3634+
return
3635+
}
3636+
3637+
schema, err := s.databaseManager.DescribeTable(&req.ConnectionConfig, req.Database, req.Table)
3638+
if err != nil {
3639+
c.JSON(http.StatusInternalServerError, gin.H{
3640+
"error": err.Error(),
3641+
})
3642+
return
3643+
}
3644+
3645+
c.JSON(http.StatusOK, schema)
3646+
}
3647+
36233648
func (s *Server) executeDatabaseQuery(c *gin.Context) {
36243649
var req struct {
36253650
database.ConnectionConfig

internal/database/manager.go

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,3 +711,250 @@ func (m *Manager) ExecuteQuery(cfg *ConnectionConfig, database, query string) (*
711711
result.Count = len(result.Rows)
712712
return result, nil
713713
}
714+
715+
type ColumnSchema struct {
716+
Name string `json:"name"`
717+
Type string `json:"type"`
718+
Nullable bool `json:"nullable"`
719+
Default interface{} `json:"default"`
720+
Key string `json:"key"`
721+
Extra string `json:"extra"`
722+
}
723+
724+
type IndexSchema struct {
725+
Name string `json:"name"`
726+
Columns []string `json:"columns"`
727+
Unique bool `json:"unique"`
728+
Primary bool `json:"primary"`
729+
}
730+
731+
type TableSchema struct {
732+
Columns []ColumnSchema `json:"columns"`
733+
Indexes []IndexSchema `json:"indexes"`
734+
}
735+
736+
func (m *Manager) DescribeTable(cfg *ConnectionConfig, database, table string) (*TableSchema, error) {
737+
driver := m.getDriver(cfg.Type)
738+
if driver == "" {
739+
return nil, fmt.Errorf("unsupported database type: %s", cfg.Type)
740+
}
741+
742+
cfgCopy := *cfg
743+
cfgCopy.Database = database
744+
745+
dsn, err := m.buildDSN(&cfgCopy)
746+
if err != nil {
747+
return nil, err
748+
}
749+
750+
db, err := sql.Open(driver, dsn)
751+
if err != nil {
752+
return nil, err
753+
}
754+
defer db.Close()
755+
756+
table = strings.ReplaceAll(table, "`", "")
757+
table = strings.ReplaceAll(table, "'", "")
758+
table = strings.ReplaceAll(table, "\"", "")
759+
table = strings.ReplaceAll(table, ";", "")
760+
761+
schema := &TableSchema{
762+
Columns: []ColumnSchema{},
763+
Indexes: []IndexSchema{},
764+
}
765+
766+
switch cfg.Type {
767+
case "mysql", "mariadb":
768+
if err := m.describeMySQLTable(db, table, schema); err != nil {
769+
return nil, err
770+
}
771+
case "postgresql":
772+
if err := m.describePostgresTable(db, table, schema); err != nil {
773+
return nil, err
774+
}
775+
default:
776+
return nil, fmt.Errorf("unsupported database type: %s", cfg.Type)
777+
}
778+
779+
return schema, nil
780+
}
781+
782+
func (m *Manager) describeMySQLTable(db *sql.DB, table string, schema *TableSchema) error {
783+
rows, err := db.Query(fmt.Sprintf("DESCRIBE `%s`", table))
784+
if err != nil {
785+
return err
786+
}
787+
defer rows.Close()
788+
789+
for rows.Next() {
790+
var field, colType, null, key string
791+
var defaultVal, extra sql.NullString
792+
793+
if err := rows.Scan(&field, &colType, &null, &key, &defaultVal, &extra); err != nil {
794+
continue
795+
}
796+
797+
col := ColumnSchema{
798+
Name: field,
799+
Type: colType,
800+
Nullable: null == "YES",
801+
Key: key,
802+
Extra: extra.String,
803+
}
804+
if defaultVal.Valid {
805+
col.Default = defaultVal.String
806+
}
807+
schema.Columns = append(schema.Columns, col)
808+
}
809+
810+
indexRows, err := db.Query(fmt.Sprintf("SHOW INDEX FROM `%s`", table))
811+
if err != nil {
812+
return nil
813+
}
814+
defer indexRows.Close()
815+
816+
indexMap := make(map[string]*IndexSchema)
817+
for indexRows.Next() {
818+
var tableName, keyName, columnName string
819+
var nonUnique int
820+
var seqInIndex, cardinality sql.NullInt64
821+
var collation, subPart, packed, null, indexType, comment, indexComment sql.NullString
822+
var visible sql.NullString
823+
824+
cols, _ := indexRows.Columns()
825+
var scanArgs []interface{}
826+
if len(cols) >= 15 {
827+
scanArgs = []interface{}{&tableName, &nonUnique, &keyName, &seqInIndex, &columnName,
828+
&collation, &cardinality, &subPart, &packed, &null, &indexType, &comment, &indexComment, &visible}
829+
if len(cols) > 14 {
830+
var extra sql.NullString
831+
scanArgs = append(scanArgs, &extra)
832+
}
833+
} else {
834+
scanArgs = []interface{}{&tableName, &nonUnique, &keyName, &seqInIndex, &columnName,
835+
&collation, &cardinality, &subPart, &packed, &null, &indexType, &comment, &indexComment}
836+
}
837+
838+
if err := indexRows.Scan(scanArgs[:len(cols)]...); err != nil {
839+
continue
840+
}
841+
842+
if _, exists := indexMap[keyName]; !exists {
843+
indexMap[keyName] = &IndexSchema{
844+
Name: keyName,
845+
Columns: []string{},
846+
Unique: nonUnique == 0,
847+
Primary: keyName == "PRIMARY",
848+
}
849+
}
850+
indexMap[keyName].Columns = append(indexMap[keyName].Columns, columnName)
851+
}
852+
853+
for _, idx := range indexMap {
854+
schema.Indexes = append(schema.Indexes, *idx)
855+
}
856+
857+
return nil
858+
}
859+
860+
func (m *Manager) describePostgresTable(db *sql.DB, table string, schema *TableSchema) error {
861+
query := `
862+
SELECT
863+
c.column_name,
864+
c.data_type || COALESCE('(' || c.character_maximum_length::text || ')', '') as full_type,
865+
c.is_nullable,
866+
c.column_default,
867+
CASE
868+
WHEN pk.column_name IS NOT NULL THEN 'PRI'
869+
WHEN uq.column_name IS NOT NULL THEN 'UNI'
870+
ELSE ''
871+
END as key_type
872+
FROM information_schema.columns c
873+
LEFT JOIN (
874+
SELECT kcu.column_name
875+
FROM information_schema.table_constraints tc
876+
JOIN information_schema.key_column_usage kcu
877+
ON tc.constraint_name = kcu.constraint_name
878+
AND tc.table_schema = kcu.table_schema
879+
WHERE tc.constraint_type = 'PRIMARY KEY'
880+
AND tc.table_name = $1
881+
) pk ON c.column_name = pk.column_name
882+
LEFT JOIN (
883+
SELECT kcu.column_name
884+
FROM information_schema.table_constraints tc
885+
JOIN information_schema.key_column_usage kcu
886+
ON tc.constraint_name = kcu.constraint_name
887+
AND tc.table_schema = kcu.table_schema
888+
WHERE tc.constraint_type = 'UNIQUE'
889+
AND tc.table_name = $1
890+
) uq ON c.column_name = uq.column_name
891+
WHERE c.table_name = $1
892+
ORDER BY c.ordinal_position
893+
`
894+
895+
rows, err := db.Query(query, table)
896+
if err != nil {
897+
return err
898+
}
899+
defer rows.Close()
900+
901+
for rows.Next() {
902+
var name, colType, nullable, key string
903+
var defaultVal sql.NullString
904+
905+
if err := rows.Scan(&name, &colType, &nullable, &defaultVal, &key); err != nil {
906+
continue
907+
}
908+
909+
col := ColumnSchema{
910+
Name: name,
911+
Type: colType,
912+
Nullable: nullable == "YES",
913+
Key: key,
914+
Extra: "",
915+
}
916+
if defaultVal.Valid {
917+
col.Default = defaultVal.String
918+
}
919+
schema.Columns = append(schema.Columns, col)
920+
}
921+
922+
indexQuery := `
923+
SELECT indexname, indexdef
924+
FROM pg_indexes
925+
WHERE tablename = $1
926+
`
927+
indexRows, err := db.Query(indexQuery, table)
928+
if err != nil {
929+
return nil
930+
}
931+
defer indexRows.Close()
932+
933+
for indexRows.Next() {
934+
var indexName, indexDef string
935+
if err := indexRows.Scan(&indexName, &indexDef); err != nil {
936+
continue
937+
}
938+
939+
idx := IndexSchema{
940+
Name: indexName,
941+
Columns: []string{},
942+
Unique: strings.Contains(indexDef, "UNIQUE"),
943+
Primary: strings.HasSuffix(indexName, "_pkey"),
944+
}
945+
946+
start := strings.Index(indexDef, "(")
947+
end := strings.LastIndex(indexDef, ")")
948+
if start != -1 && end != -1 && end > start {
949+
colStr := indexDef[start+1 : end]
950+
cols := strings.Split(colStr, ",")
951+
for _, c := range cols {
952+
idx.Columns = append(idx.Columns, strings.TrimSpace(c))
953+
}
954+
}
955+
956+
schema.Indexes = append(schema.Indexes, idx)
957+
}
958+
959+
return nil
960+
}

0 commit comments

Comments
 (0)