diff --git a/Internal/adapters/repository/dynamicDBRepo.go b/Internal/adapters/repository/dynamicDBRepo.go index d7ae243..36f0081 100644 --- a/Internal/adapters/repository/dynamicDBRepo.go +++ b/Internal/adapters/repository/dynamicDBRepo.go @@ -1,5 +1,10 @@ package repository +import ( + "github.com/Arjuna-Ragil/Localbase/Internal/core/domain" + "gorm.io/gorm" +) + type DynamicDBRepo struct { DB *DBContainer } @@ -8,10 +13,33 @@ func NewDynamicDB(db *DBContainer) *DynamicDBRepo { return &DynamicDBRepo{DB: db} } -func (dr *DynamicDBRepo) CreateDynamicDB(query string) error { - _, err := dr.DB.Sqlx.Exec(query) - if err != nil { +func (dr *DynamicDBRepo) RunDynamicQuery(tx *gorm.DB, query string) error { + if err := tx.Exec(query).Error; err != nil { return err } return nil } + +// Dynamic database note + +func (dr *DynamicDBRepo) CreateDynTable(tx *gorm.DB, dynTable *domain.DynTableDef) (*domain.DynTableDef, error) { + if err := tx.Create(&dynTable).Error; err != nil { + return nil, err + } + return dynTable, nil +} + +func (dr *DynamicDBRepo) CreateDynCol(tx *gorm.DB, dynCol *domain.DynColDef) error { + if err := tx.Create(&dynCol).Error; err != nil { + return err + } + return nil +} + +func (dr *DynamicDBRepo) GetTables(projectID uint) ([]domain.DynTableDef, error) { + var tables []domain.DynTableDef + if err := dr.DB.Gorm.Preload("Columns").Where("project_id = ?", projectID).Find(&tables).Error; err != nil { + return nil, err + } + return tables, nil +} diff --git a/Internal/adapters/repository/postgres.go b/Internal/adapters/repository/postgres.go index a6a2f28..3ba08a8 100644 --- a/Internal/adapters/repository/postgres.go +++ b/Internal/adapters/repository/postgres.go @@ -43,6 +43,8 @@ func (db *DBContainer) Migrate() error { &domain.Invitation{}, &domain.Project{}, &domain.ProjectUser{}, + &domain.DynTableDef{}, + &domain.DynColDef{}, ) if err != nil { log.Fatalf("Failed to migrate users: %v", err) diff --git a/Internal/api/Routes/routev1.go b/Internal/api/Routes/routev1.go index 7db3f07..d1e070f 100644 --- a/Internal/api/Routes/routev1.go +++ b/Internal/api/Routes/routev1.go @@ -53,12 +53,14 @@ func SetupRouterV1(r *gin.Engine, deps Deps) { { project.GET("/projects", deps.Project.GetAllProjectHandler) project.GET("/:projectid", deps.Project.GetProjectHandler) + project.GET("/:projectid/tables", deps.DynamicDB.GetDynTablesHandler) manage := project.Group("/manage") { manage.POST("/invite/:projectid", deps.Project.InviteProjectHandler) manage.DELETE("/remove/:projuserid", deps.Project.RemoveProjectUserHandler) manage.POST("/create/table/:projectid", deps.DynamicDB.CreateDynamicDBHandler) + manage.POST("/create/col/:projectid/:tableid", deps.DynamicDB.AddDynamicColHandler) } } } diff --git a/Internal/api/handlers/dynamicDBHandler.go b/Internal/api/handlers/dynamicDBHandler.go index 42db287..493999a 100644 --- a/Internal/api/handlers/dynamicDBHandler.go +++ b/Internal/api/handlers/dynamicDBHandler.go @@ -25,24 +25,95 @@ func (dh *DynamicDBHandler) CreateDynamicDBHandler(c *gin.Context) { }) return } - var input services.CreateTableReq - if err := c.ShouldBindJSON(&input); err != nil { + var input services.DDLTableReq + if err = c.ShouldBindJSON(&input); err != nil { c.JSON(400, gin.H{ "message": "Invalid json body", "error": err.Error(), }) return } - if err := dh.Serv.CreateDynamicTable(uint(projectID), input); nil != err { + DynTable, err := dh.Serv.CreateDynamicTable(uint(projectID), input) + if nil != err { c.JSON(500, gin.H{ "message": "Failed to create dynamic table", "error": err.Error(), }) return } + if err = dh.Serv.CreateDynamicCols(DynTable.ID, uint(projectID), input); nil != err { + c.JSON(500, gin.H{ + "message": "Failed to create dynamic cols", + "error": err.Error(), + }) + return + } c.JSON(200, gin.H{ "message": "Successfully created dynamic table", "data": nil, }) +} + +func (dh *DynamicDBHandler) AddDynamicColHandler(c *gin.Context) { + projectIDStr := c.Param("projectid") + projectID, err := strconv.Atoi(projectIDStr) + if err != nil { + c.JSON(400, gin.H{ + "message": "Project ID not valid", + "error": err.Error(), + }) + return + } + tableIDStr := c.Param("tableid") + tableID, err := strconv.Atoi(tableIDStr) + if err != nil { + c.JSON(400, gin.H{ + "message": "Table ID not valid", + "error": err.Error(), + }) + return + } + var input services.DDLTableReq + if err = c.ShouldBindJSON(&input); err != nil { + c.JSON(400, gin.H{ + "message": "Invalid json body", + "error": err.Error(), + }) + return + } + if err = dh.Serv.CreateDynamicCols(uint(tableID), uint(projectID), input); nil != err { + c.JSON(500, gin.H{ + "message": "Failed to create dynamic cols", + "error": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "message": "Successfully added dynamic col", + "data": nil, + }) +} +func (dh *DynamicDBHandler) GetDynTablesHandler(c *gin.Context) { + projectIDStr := c.Param("projectid") + projectID, err := strconv.Atoi(projectIDStr) + if err != nil { + c.JSON(400, gin.H{ + "message": "Project ID not valid", + "error": err.Error(), + }) + return + } + tables, err := dh.Serv.GetDynamicTables(uint(projectID)) + if err != nil { + c.JSON(500, gin.H{ + "message": "Failed to get dynamic table", + "error": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "message": "Successfully get dynamic table", + "data": tables, + }) } diff --git a/Internal/core/domain/projectdb.go b/Internal/core/domain/projectdb.go index cd6be9e..081e42d 100644 --- a/Internal/core/domain/projectdb.go +++ b/Internal/core/domain/projectdb.go @@ -21,4 +21,17 @@ type ProjectUser struct { CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` } -//Changes last savepoint +type DynTableDef struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + ProjectID uint `gorm:"not null" json:"project_id"` + Name string `gorm:"size:255;not null" json:"name"` + Alias string `gorm:"size:255;not null" json:"alias"` + Columns []DynColDef `gorm:"foreignKey:DynTableDefID" json:"columns"` +} + +type DynColDef struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + DynTableDefID uint `gorm:"not null" json:"dyn_table_def_id"` + Name string `gorm:"size:255;not null" json:"name"` + DataType string `gorm:"size:255;not null" json:"data_type"` +} diff --git a/Internal/core/services/dynamicDBService.go b/Internal/core/services/dynamicDBService.go index 364301c..f0f8166 100644 --- a/Internal/core/services/dynamicDBService.go +++ b/Internal/core/services/dynamicDBService.go @@ -6,14 +6,16 @@ import ( "strings" "github.com/Arjuna-Ragil/Localbase/Internal/adapters/repository" + "github.com/Arjuna-Ragil/Localbase/Internal/core/domain" ) type DynamicDBService struct { Repo *repository.DynamicDBRepo + DB *repository.DBContainer } -func NewDynamicDBService(repo *repository.DynamicDBRepo) *DynamicDBService { - return &DynamicDBService{Repo: repo} +func NewDynamicDBService(repo *repository.DynamicDBRepo, db *repository.DBContainer) *DynamicDBService { + return &DynamicDBService{Repo: repo, DB: db} } type ColumnReq struct { @@ -21,7 +23,7 @@ type ColumnReq struct { Type string `json:"type"` } -type CreateTableReq struct { +type DDLTableReq struct { TableName string `json:"table_name"` Columns []ColumnReq `json:"columns"` } @@ -34,26 +36,93 @@ func sanitizeInput(input string) string { return result } -func (ds *DynamicDBService) CreateDynamicTable(projectID uint, req CreateTableReq) error { +func (ds *DynamicDBService) CreateDynamicTable(projectID uint, req DDLTableReq) (*domain.DynTableDef, error) { + tx := ds.DB.Gorm.Begin() + + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + safeTableName := fmt.Sprintf("proj_%d_%s", projectID, sanitizeInput(req.TableName)) + tableDef := domain.DynTableDef{ + ProjectID: projectID, + Name: safeTableName, + Alias: req.TableName, + } + + DynTable, err := ds.Repo.CreateDynTable(tx, &tableDef) + if err != nil { + tx.Rollback() + return nil, err + } + var queryBuilder strings.Builder queryBuilder.WriteString(fmt.Sprintf("CREATE TABLE %s (", safeTableName)) queryBuilder.WriteString("id SERIAL PRIMARY KEY, ") queryBuilder.WriteString("created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, ") - for _, col := range req.Columns { - safeColName := sanitizeInput(col.Name) - queryBuilder.WriteString(fmt.Sprintf("%s %s, ", safeColName, col.Type)) - } - query := queryBuilder.String() query = strings.TrimSuffix(query, ", ") query += ");" fmt.Println(query) - if err := ds.Repo.CreateDynamicDB(query); err != nil { + if err = ds.Repo.RunDynamicQuery(tx, query); err != nil { + tx.Rollback() + return nil, err + } + return DynTable, tx.Commit().Error +} + +func (ds *DynamicDBService) CreateDynamicCols(tableID uint, projectID uint, req DDLTableReq) error { + tx := ds.DB.Gorm.Begin() + + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + safeTableName := fmt.Sprintf("proj_%d_%s", projectID, sanitizeInput(req.TableName)) + + if len(req.Columns) == 0 { + return fmt.Errorf("no columns specified") + } + var queryBuilder strings.Builder + queryBuilder.WriteString(fmt.Sprintf("ALTER TABLE %s ", safeTableName)) + + for i, col := range req.Columns { + safeColName := sanitizeInput(col.Name) + coldef := domain.DynColDef{ + DynTableDefID: tableID, + Name: col.Name, + DataType: col.Type, + } + if err := ds.Repo.CreateDynCol(tx, &coldef); err != nil { + tx.Rollback() + return err + } + + queryBuilder.WriteString(fmt.Sprintf("ADD COLUMN %s %s", safeColName, col.Type)) + if i != len(req.Columns)-1 { + queryBuilder.WriteString(", ") + } + } + queryBuilder.WriteString(";") + query := queryBuilder.String() + + if err := ds.Repo.RunDynamicQuery(tx, query); err != nil { return err } - return nil + return tx.Commit().Error +} + +func (ds *DynamicDBService) GetDynamicTables(projectID uint) ([]domain.DynTableDef, error) { + tables, err := ds.Repo.GetTables(projectID) + if err != nil { + return nil, err + } + return tables, nil } diff --git a/Lb-web/src/features/project/components/ProjectSidebar.tsx b/Lb-web/src/features/project/components/ProjectSidebar.tsx index 45916ea..6f804bc 100644 --- a/Lb-web/src/features/project/components/ProjectSidebar.tsx +++ b/Lb-web/src/features/project/components/ProjectSidebar.tsx @@ -12,7 +12,10 @@ interface ProjectSidebarProps { export function ProjectSidebar({ project }: ProjectSidebarProps) { const location = useLocation(); - const isActive = (path: string) => { + const isActive = (path: string, exact = false) => { + if (exact) { + return location.pathname === path; + } return location.pathname === path || location.pathname.startsWith(`${path}/`); }; @@ -38,12 +41,21 @@ export function ProjectSidebar({ project }: ProjectSidebarProps) { + + + + + + + Create New Table + + Define the table name and its initial columns. ID and Created_At are added automatically. + + + +
+
+ + setTableName(e.target.value)} + className="border-sky-200 focus-visible:ring-sky-400" + /> +
+ +
+
+ + +
+ + {newColumns.length === 0 && ( +
+ No additional columns. Table will only have 'id' and 'created_at'. +
+ )} + + {newColumns.map((col, idx) => ( +
+
+ + handleColumnChange(idx, "name", e.target.value)} + placeholder="col_name" + className="h-8 text-sm" + /> +
+
+ + +
+ +
+ ))} +
+ + {createError && ( +
+ {createError} +
+ )} +
+ + + + +
+ + + {/* Add Column Dialog */} + + + + Add Columns to {selectedTable?.alias} + + Add new columns to this table. + + +
+
+
+ + +
+ + {colsToAdd.map((col, idx) => ( +
+
+ + handleColChange_Add(idx, "name", e.target.value)} + placeholder="col_name" + className="h-8 text-sm" + /> +
+
+ + +
+ +
+ ))} +
+
+ + + +
+
+ + + {/* Tables List */} + {loadingTables ? ( +
+ +
+ ) : tables.length > 0 ? ( +
+ {tables.map((t) => ( +
+
toggleTableExpand(t.id)} + > +
+
+ +
+
+

{t.alias}

+

{t.name}

+
+
+
+ + {expandedTables[t.id] ? : } +
+
+ + {/* Columns View */} + {expandedTables[t.id] && ( +
+
+ {t.columns?.map((col) => ( +
+
+ + {col.name} +
+ {col.data_type} +
+ ))} + {(!t.columns || t.columns.length === 0) && ( +
+ No custom columns defined. +
+ )} +
+
+ )} +
+ ))} +
+ ) : ( +
+
+ +
+

No tables found

+

+ Create your first table to start storing data in your project. +

+
+ )} + + {/* Info Note */} +
+ Note: Table management is currently in beta. You can create tables and add columns. +
+ + + + + ); +} diff --git a/Lb-web/src/features/project/services/dynamicDBService.ts b/Lb-web/src/features/project/services/dynamicDBService.ts new file mode 100644 index 0000000..c317213 --- /dev/null +++ b/Lb-web/src/features/project/services/dynamicDBService.ts @@ -0,0 +1,43 @@ +import api from "@/services/api"; + +export interface ColumnReq { + name: string; + type: string; +} + +export interface DDLTableReq { + table_name: string; + columns: ColumnReq[]; +} + +export const dynamicDBService = { + createTable: async (projectId: number, data: DDLTableReq) => { + const response = await api.post(`/protected/project/manage/create/table/${projectId}`, data); + return response.data; + }, + + addColumn: async (projectId: number, tableId: number, data: DDLTableReq) => { + const response = await api.post(`/protected/project/manage/create/col/${projectId}/${tableId}`, data); + return response.data; + }, + + getTables: async (projectId: number): Promise => { + const response = await api.get(`/protected/project/${projectId}/tables`); + return response.data.data; + } +}; + +export interface DynColDef { + id: number; + dyn_table_def_id: number; + name: string; + data_type: string; +} + +export interface DynTableDef { + id: number; + project_id: number; + name: string; + alias: string; + columns: DynColDef[]; +} diff --git a/Lb-web/src/main.tsx b/Lb-web/src/main.tsx index b901da2..fdc13bb 100644 --- a/Lb-web/src/main.tsx +++ b/Lb-web/src/main.tsx @@ -13,6 +13,7 @@ import UserManagement from './features/admin-setting/pages/UserManagement.tsx' import Appearance from './features/admin-setting/pages/Appearance.tsx' import ProjectDashboard from './features/project/pages/ProjectDashboard.tsx' import ProjectUserManagement from './features/project/pages/ProjectUserManagement.tsx' +import ProjectDatabase from './features/project/pages/ProjectDatabase.tsx' const router = createBrowserRouter([ { @@ -73,6 +74,10 @@ const router = createBrowserRouter([ { path: 'settings/members', element: + }, + { + path: 'database', + element: } ] } diff --git a/cmd/server/main.go b/cmd/server/main.go index b6393a4..c363732 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -65,7 +65,7 @@ func SetupApp(db *repository.DBContainer, cfg *config.Config) Routes.Deps { projectHandler := handlers.NewProjectHandler(projectService) dynamicDBRepo := repository.NewDynamicDB(db) - dynamicDBService := services.NewDynamicDBService(dynamicDBRepo) + dynamicDBService := services.NewDynamicDBService(dynamicDBRepo, db) dynamicDBHandler := handlers.NewDynamicDBHandler(dynamicDBService) return Routes.Deps{