├── LICENSE ├── Readme.md ├── VERSION ├── benchmarks └── bench_test.go ├── builderM.go ├── builderS.go ├── builder_adapters.go ├── dash.go ├── dash_auth_views.go ├── dash_middlewares.go ├── dash_models_views.go ├── dash_types.go ├── dash_urls.go ├── dash_views.go ├── docs.png ├── examples └── basic.go ├── go.mod ├── go.sum ├── korm.go ├── korm.png ├── korm_test.go ├── migrator.go ├── migrator_helpers.go ├── nodeManager.go ├── selector.go ├── shell.go ├── sqlhooks.go ├── tracer.go ├── triggers.go ├── types.go └── utils.go /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) korm. 2 | by kamal shkeir All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of korm nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | v1.96.6 -------------------------------------------------------------------------------- /benchmarks/bench_test.go: -------------------------------------------------------------------------------- 1 | package benchmarks 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "testing" 8 | "time" 9 | 10 | "github.com/kamalshkeir/korm" 11 | "github.com/kamalshkeir/lg" 12 | //"gorm.io/driver/sqlite" 13 | //"gorm.io/gorm" 14 | ) 15 | 16 | var DB_BENCH_NAME = "bench" 17 | var NumberOfModel = 300 // min 300 18 | 19 | type TestTable struct { 20 | Id uint `korm:"pk"` 21 | Email string 22 | Content string 23 | Password string 24 | IsAdmin bool 25 | CreatedAt time.Time `korm:"now"` 26 | UpdatedAt time.Time `korm:"update"` 27 | } 28 | 29 | type TestTableGorm struct { 30 | Id uint `gorm:"primarykey"` 31 | Email string 32 | Content string 33 | Password string 34 | IsAdmin bool 35 | CreatedAt time.Time 36 | UpdatedAt time.Time 37 | } 38 | 39 | //var gormDB *gorm.DB 40 | 41 | func TestMain(m *testing.M) { 42 | var err error 43 | // err = korm.New(korm.SQLITE, DB_BENCH_NAME, sqlitedriver.Use()) 44 | // if lg.CheckError(err) { 45 | // return 46 | // } 47 | // gormDB, err = gorm.Open(sqlite.Open("benchgorm.sqlite"), &gorm.Config{}) 48 | // if lg.CheckError(err) { 49 | // return 50 | // } 51 | // migrate table test_table from struct TestTable 52 | err = korm.AutoMigrate[TestTable]("test_table") 53 | if lg.CheckError(err) { 54 | return 55 | } 56 | t, _ := korm.Table("test_table").All() 57 | if len(t) == 0 { 58 | for i := 0; i < NumberOfModel; i++ { 59 | _, err := korm.Model[TestTable]().Insert(&TestTable{ 60 | Email: "test-" + strconv.Itoa(i) + "@example.com", 61 | Content: "Duis tortor odio, sodales quis lacinia quis, tincidunt id dolor. Curabitur tempor nunc at lacinia commodo. Aliquam sapien orci, rhoncus a cursus nec, accumsan ut tortor. Sed sed laoreet ipsum. Ut vulputate porttitor libero, non aliquet est rutrum nec. Nullam vitae viverra tortor.", 62 | Password: "aaffsbfaaaj2sbfsdjqbfsa2bfesfb", 63 | IsAdmin: true, 64 | }) 65 | lg.CheckError(err) 66 | } 67 | } 68 | // gorm 69 | // err = gormDB.AutoMigrate(&TestTableGorm{}) 70 | // if lg.CheckError(err) { 71 | // return 72 | // } 73 | // dest := []TestTableGorm{} 74 | // err = gormDB.Find(&dest, &TestTableGorm{}).Error 75 | // if err != nil || len(dest) == 0 { 76 | // for i := 0; i < NumberOfModel; i++ { 77 | // err := gormDB.Create(&TestTableGorm{ 78 | // Email: "test-" + strconv.Itoa(i) + "@example.com", 79 | // Content: "Duis tortor odio, sodales quis lacinia quis, tincidunt id dolor. Curabitur tempor nunc at lacinia commodo. Aliquam sapien orci, rhoncus a cursus nec, accumsan ut tortor. Sed sed laoreet ipsum. Ut vulputate porttitor libero, non aliquet est rutrum nec. Nullam vitae viverra tortor.", 80 | // Password: "aaffsbfaaaj2sbfsdjqbfsa2bfesfb", 81 | // IsAdmin: true, 82 | // }).Error 83 | // if lg.CheckError(err) { 84 | // return 85 | // } 86 | // } 87 | // } 88 | 89 | //run tests 90 | exitCode := m.Run() 91 | 92 | err = korm.Shutdown(DB_BENCH_NAME) 93 | if lg.CheckError(err) { 94 | return 95 | } 96 | // gormdb, _ := gormDB.DB() 97 | // err = gormdb.Close() 98 | // if lg.CheckError(err) { 99 | // return 100 | // } 101 | // Cleanup for sqlite , remove file db 102 | err = os.Remove(DB_BENCH_NAME + ".sqlite3") 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | // err = os.Remove("benchgorm.sqlite") 107 | // if err != nil { 108 | // log.Fatal(err) 109 | // } 110 | os.Exit(exitCode) 111 | } 112 | 113 | // func BenchmarkGetAllS_GORM(b *testing.B) { 114 | // b.ReportAllocs() 115 | // b.ResetTimer() 116 | // for i := 0; i < b.N; i++ { 117 | // a := []TestTableGorm{} 118 | // err := gormDB.Find(&a).Error 119 | // if err != nil { 120 | // b.Error("error BenchmarkGetAllS_GORM:", err) 121 | // } 122 | // if len(a) != NumberOfModel || a[0].Email != "test-0@example.com" { 123 | // b.Error("Failed:", len(a), a[0].Email) 124 | // } 125 | // } 126 | // } 127 | 128 | func BenchmarkGetAllS(b *testing.B) { 129 | b.ReportAllocs() 130 | b.ResetTimer() 131 | for i := 0; i < b.N; i++ { 132 | a, err := korm.Model[TestTable]().All() 133 | if err != nil { 134 | b.Error("error BenchmarkGetAllS:", err) 135 | } 136 | if len(a) != NumberOfModel || a[0].Email != "test-0@example.com" { 137 | b.Error("Failed:", len(a), a[0].Email) 138 | } 139 | } 140 | } 141 | 142 | func BenchmarkQueryS(b *testing.B) { 143 | b.ReportAllocs() 144 | b.ResetTimer() 145 | for i := 0; i < b.N; i++ { 146 | a, err := korm.Model[TestTable]().QueryS("select * from test_table where is_admin =?", true) 147 | if err != nil { 148 | b.Error("error BenchmarkQueryS:", err) 149 | } 150 | if len(a) != NumberOfModel || a[0].Email != "test-0@example.com" { 151 | b.Error("Failed:", len(a), a[0].Email) 152 | } 153 | } 154 | } 155 | 156 | func BenchmarkTo(b *testing.B) { 157 | b.ReportAllocs() 158 | b.ResetTimer() 159 | for i := 0; i < b.N; i++ { 160 | a := []TestTable{} 161 | err := korm.To(&a).Query("select * from test_table where is_admin = ?", true) 162 | if err != nil { 163 | b.Error("error BenchmarkTo:", err) 164 | } 165 | if len(a) != NumberOfModel || a[0].Email != "test-0@example.com" { 166 | b.Error("Failed:", len(a), a[0].Email) 167 | } 168 | } 169 | } 170 | 171 | func BenchmarkToNamed(b *testing.B) { 172 | b.ReportAllocs() 173 | b.ResetTimer() 174 | for i := 0; i < b.N; i++ { 175 | a := []TestTable{} 176 | err := korm.To(&a).Named("select * from test_table where is_admin = :ad", map[string]any{ 177 | "ad": true, 178 | }) 179 | if err != nil { 180 | b.Error("error BenchmarkToNamed:", err) 181 | } 182 | if len(a) != NumberOfModel || a[0].Email != "test-0@example.com" { 183 | b.Error("Failed:", len(a), a[0].Email) 184 | } 185 | } 186 | } 187 | 188 | func BenchmarkQueryNamedS(b *testing.B) { 189 | b.ReportAllocs() 190 | b.ResetTimer() 191 | for i := 0; i < b.N; i++ { 192 | a, err := korm.Model[TestTable]().QuerySNamed("select * from test_table where is_admin = :ad", map[string]any{ 193 | "ad": true, 194 | }) 195 | if err != nil { 196 | b.Error("error BenchmarkQueryNamedS:", err) 197 | } 198 | if len(a) != NumberOfModel || a[0].Email != "test-0@example.com" { 199 | b.Error("Failed:", len(a), a[0].Email) 200 | } 201 | } 202 | } 203 | 204 | func BenchmarkQueryM(b *testing.B) { 205 | b.ReportAllocs() 206 | b.ResetTimer() 207 | for i := 0; i < b.N; i++ { 208 | a, err := korm.Table("test_table").QueryM("select * from test_table where is_admin =?", true) 209 | if err != nil { 210 | b.Error("error BenchmarkQueryM:", err) 211 | } 212 | if len(a) != NumberOfModel || a[0]["email"] != "test-0@example.com" { 213 | b.Error("Failed:", len(a), a[0]["email"]) 214 | } 215 | } 216 | } 217 | 218 | func BenchmarkQueryNamedM(b *testing.B) { 219 | b.ReportAllocs() 220 | b.ResetTimer() 221 | for i := 0; i < b.N; i++ { 222 | a, err := korm.Table("test_table").QueryMNamed("select * from test_table where is_admin =:ad", map[string]any{ 223 | "ad": true, 224 | }) 225 | if err != nil { 226 | b.Error("error BenchmarkQueryNamedM:", err) 227 | } 228 | if len(a) != NumberOfModel || a[0]["email"] != "test-0@example.com" { 229 | b.Error("Failed:", len(a), a[0]["email"]) 230 | } 231 | } 232 | } 233 | 234 | // func BenchmarkGetAllM_GORM(b *testing.B) { 235 | // b.ReportAllocs() 236 | // b.ResetTimer() 237 | // for i := 0; i < b.N; i++ { 238 | // a := []map[string]any{} 239 | // err := gormDB.Table("test_table_gorms").Find(&a).Error 240 | // if err != nil { 241 | // b.Error("error BenchmarkGetAllM_GORM:", err) 242 | // } 243 | // if len(a) != NumberOfModel || a[0]["email"] != "test-0@example.com" { 244 | // b.Error("Failed:", len(a), a[0]["email"]) 245 | // } 246 | // } 247 | // } 248 | 249 | func BenchmarkGetAllM(b *testing.B) { 250 | b.ReportAllocs() 251 | b.ResetTimer() 252 | for i := 0; i < b.N; i++ { 253 | a, err := korm.Table("test_table").All() 254 | if err != nil { 255 | b.Error("error BenchmarkGetAllM:", err) 256 | } 257 | if len(a) != NumberOfModel || a[0]["email"] != "test-0@example.com" { 258 | b.Error("Failed:", len(a), a[0]["email"]) 259 | } 260 | } 261 | } 262 | 263 | // func BenchmarkGetRowS_GORM(b *testing.B) { 264 | // b.ReportAllocs() 265 | // b.ResetTimer() 266 | // for i := 0; i < b.N; i++ { 267 | // u := TestTableGorm{} 268 | // err := gormDB.Where(&TestTableGorm{ 269 | // Email: "test-10@example.com", 270 | // }).First(&u).Error 271 | // if err != nil { 272 | // b.Error("error BenchmarkGetRowS_GORM:", err) 273 | // } 274 | // if u.Email != "test-10@example.com" { 275 | // b.Error("gorm failed BenchmarkGetRowS_GORM:", u) 276 | // } 277 | // } 278 | // } 279 | 280 | func BenchmarkGetRowS(b *testing.B) { 281 | b.ReportAllocs() 282 | b.ResetTimer() 283 | for i := 0; i < b.N; i++ { 284 | u, err := korm.Model[TestTable]().Where("email = ?", "test-10@example.com").One() 285 | if err != nil { 286 | b.Error("error BenchmarkGetRowS:", err) 287 | } 288 | if u.Email != "test-10@example.com" { 289 | b.Error("gorm failed BenchmarkGetRowS:", u) 290 | } 291 | } 292 | } 293 | 294 | // func BenchmarkGetRowM_GORM(b *testing.B) { 295 | // b.ReportAllocs() 296 | // b.ResetTimer() 297 | // for i := 0; i < b.N; i++ { 298 | // u := map[string]any{} 299 | // err := gormDB.Model(&TestTableGorm{}).Where(&TestTableGorm{ 300 | // Email: "test-10@example.com", 301 | // }).First(&u).Error 302 | // if err != nil { 303 | // b.Error("error BenchmarkGetRowS_GORM:", err) 304 | // } 305 | // if u["email"] != "test-10@example.com" { 306 | // b.Error("gorm failed BenchmarkGetRowM_GORM:", u) 307 | // } 308 | // } 309 | // } 310 | 311 | func BenchmarkGetRowM(b *testing.B) { 312 | b.ReportAllocs() 313 | b.ResetTimer() 314 | for i := 0; i < b.N; i++ { 315 | u, err := korm.Table("test_table").Where("email = ?", "test-10@example.com").One() 316 | if err != nil { 317 | b.Error("error BenchmarkGetRowM:", err) 318 | } 319 | if u["email"] != "test-10@example.com" { 320 | b.Error("gorm failed BenchmarkGetRowM:", u) 321 | } 322 | } 323 | } 324 | 325 | // func BenchmarkPagination10_GORM(b *testing.B) { 326 | // page := 2 327 | // pageSize := 10 328 | // b.ReportAllocs() 329 | // b.ResetTimer() 330 | // for i := 0; i < b.N; i++ { 331 | // u := []TestTableGorm{} 332 | // offset := (page - 1) * pageSize 333 | // err := gormDB.Model(&TestTableGorm{}).Where(&TestTableGorm{ 334 | // IsAdmin: true, 335 | // }).Offset(offset).Limit(pageSize).Find(&u).Error 336 | // if err != nil { 337 | // b.Error("error BenchmarkPagination10_GORM:", err) 338 | // } 339 | // if len(u) != pageSize || u[0].Email == "" { 340 | // b.Error("error len BenchmarkPagination10_GORM:", len(u)) 341 | // } 342 | // } 343 | // } 344 | 345 | func BenchmarkPagination10(b *testing.B) { 346 | page := 2 347 | pageSize := 10 348 | b.ReportAllocs() 349 | b.ResetTimer() 350 | for i := 0; i < b.N; i++ { 351 | u, err := korm.Model[TestTable]().Where("is_admin", true).Page(page).Limit(pageSize).All() 352 | if err != nil { 353 | b.Error("error BenchmarkPagination10:", err) 354 | } 355 | if len(u) != pageSize || u[0].Email == "" { 356 | b.Error("error len BenchmarkPagination10:", len(u)) 357 | } 358 | } 359 | } 360 | 361 | // func BenchmarkPagination100_GORM(b *testing.B) { 362 | // page := 2 363 | // pageSize := 100 364 | // if NumberOfModel <= pageSize { 365 | // return 366 | // } 367 | // b.ReportAllocs() 368 | // b.ResetTimer() 369 | // for i := 0; i < b.N; i++ { 370 | // u := []TestTableGorm{} 371 | // offset := (page - 1) * pageSize 372 | // err := gormDB.Model(&TestTableGorm{}).Where(&TestTableGorm{ 373 | // IsAdmin: true, 374 | // }).Offset(offset).Limit(pageSize).Find(&u).Error 375 | // if err != nil { 376 | // b.Error("error BenchmarkPagination10_GORM:", err) 377 | // } 378 | // if len(u) != pageSize || u[0].Email == "" { 379 | // b.Error("error len BenchmarkPagination10_GORM:", len(u)) 380 | // } 381 | // } 382 | // } 383 | 384 | func BenchmarkPagination100(b *testing.B) { 385 | page := 2 386 | pageSize := 100 387 | if NumberOfModel <= pageSize { 388 | return 389 | } 390 | b.ReportAllocs() 391 | b.ResetTimer() 392 | for i := 0; i < b.N; i++ { 393 | u, err := korm.Model[TestTable]().Where("is_admin", true).Page(page).Limit(pageSize).All() 394 | if err != nil { 395 | b.Error("error BenchmarkPagination10:", err) 396 | } 397 | if len(u) != pageSize || u[0].Email == "" { 398 | b.Error("error len BenchmarkPagination10:", len(u)) 399 | } 400 | } 401 | } 402 | 403 | func BenchmarkGetAllTables(b *testing.B) { 404 | b.ReportAllocs() 405 | b.ResetTimer() 406 | for i := 0; i < b.N; i++ { 407 | t := korm.GetAllTables() 408 | if len(t) == 0 { 409 | b.Error("error BenchmarkGetAllTables: no data") 410 | } 411 | } 412 | } 413 | 414 | func BenchmarkGetAllColumns(b *testing.B) { 415 | b.ReportAllocs() 416 | b.ResetTimer() 417 | for i := 0; i < b.N; i++ { 418 | c, _ := korm.GetAllColumnsTypes("test_table") 419 | if len(c) == 0 { 420 | b.Error("error BenchmarkGetAllColumns: no data") 421 | } 422 | } 423 | } 424 | -------------------------------------------------------------------------------- /builder_adapters.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "time" 8 | 9 | "github.com/kamalshkeir/lg" 10 | ) 11 | 12 | func UnsafeNamedQuery(query string, args map[string]any) (string, error) { 13 | q, _, err := AdaptNamedParams("", query, args, true) 14 | if err != nil { 15 | return "", err 16 | } 17 | return q, nil 18 | } 19 | 20 | func AdaptNamedParams(dialect, statement string, variables map[string]any, unsafe ...bool) (string, []any, error) { 21 | if !strings.Contains(statement, ":") { 22 | return statement, nil, nil 23 | } 24 | var paramCount int 25 | for i := 0; i < len(statement); i++ { 26 | if statement[i] == ':' { 27 | paramCount++ 28 | for i < len(statement) && statement[i] != ' ' && statement[i] != ',' && statement[i] != ')' { 29 | i++ 30 | } 31 | } 32 | } 33 | anys := make([]any, 0, paramCount) 34 | buf := strings.Builder{} 35 | lastIndex := 0 36 | for { 37 | index := strings.Index(statement[lastIndex:], ":") 38 | if index == -1 { 39 | break 40 | } 41 | start := lastIndex + index 42 | end := start 43 | for end < len(statement) && statement[end] != ' ' && statement[end] != ',' && statement[end] != ')' { 44 | end++ 45 | } 46 | key := statement[start+1 : end] 47 | value, ok := variables[key] 48 | if !ok { 49 | return "", nil, fmt.Errorf("missing variable value for '%s'", key) 50 | } 51 | switch vt := value.(type) { 52 | case time.Time: 53 | value = vt.Unix() 54 | case *time.Time: 55 | value = vt.Unix() 56 | } 57 | 58 | // Handle IN clause values 59 | beforeParam := strings.TrimSpace(strings.ToUpper(statement[max(0, start-5):start])) 60 | isInClause := strings.HasSuffix(beforeParam, "IN") || strings.HasSuffix(beforeParam, "IN (") 61 | 62 | buf.WriteString(statement[lastIndex:start]) 63 | if len(unsafe) > 0 && unsafe[0] { 64 | if v, ok := value.(string); ok { 65 | _, err := buf.WriteString(v) 66 | lg.CheckError(err) 67 | } else { 68 | _, err := buf.WriteString(fmt.Sprint(value)) 69 | lg.CheckError(err) 70 | } 71 | } else if isInClause { 72 | // Handle different slice types for IN clause 73 | switch v := value.(type) { 74 | case []int: 75 | buf.WriteString(strings.Repeat("?,", len(v)-1) + "?") 76 | for _, val := range v { 77 | anys = append(anys, val) 78 | } 79 | case []uint: 80 | buf.WriteString(strings.Repeat("?,", len(v)-1) + "?") 81 | for _, val := range v { 82 | anys = append(anys, val) 83 | } 84 | case []string: 85 | buf.WriteString(strings.Repeat("?,", len(v)-1) + "?") 86 | for _, val := range v { 87 | anys = append(anys, val) 88 | } 89 | case []any: 90 | buf.WriteString(strings.Repeat("?,", len(v)-1) + "?") 91 | anys = append(anys, v...) 92 | default: 93 | buf.WriteString("?") 94 | anys = append(anys, value) 95 | } 96 | } else { 97 | buf.WriteString("?") 98 | switch vt := value.(type) { 99 | case time.Time: 100 | value = vt.Unix() 101 | case *time.Time: 102 | value = vt.Unix() 103 | case string: 104 | value = "'" + vt + "'" 105 | } 106 | anys = append(anys, value) 107 | } 108 | lastIndex = end 109 | } 110 | buf.WriteString(statement[lastIndex:]) 111 | res := buf.String() 112 | if len(unsafe) == 0 || !unsafe[0] { 113 | AdaptPlaceholdersToDialect(&res, dialect) 114 | } 115 | return res, anys, nil 116 | } 117 | 118 | // Helper function since Go doesn't have a built-in max for ints 119 | func max(a, b int) int { 120 | if a > b { 121 | return a 122 | } 123 | return b 124 | } 125 | 126 | func AdaptPlaceholdersToDialect(query *string, dialect string) { 127 | if strings.Contains(*query, "?") && (dialect != MYSQL) { 128 | split := strings.Split(*query, "?") 129 | counter := 0 130 | for i := range split { 131 | if i < len(split)-1 { 132 | counter++ 133 | split[i] = split[i] + "$" + strconv.Itoa(counter) 134 | } 135 | } 136 | *query = strings.Join(split, "") 137 | } 138 | } 139 | 140 | func adaptTimeToUnixArgs(args *[]any) { 141 | for i := range *args { 142 | switch v := (*args)[i].(type) { 143 | case time.Time: 144 | (*args)[i] = v.Unix() 145 | case *time.Time: 146 | (*args)[i] = v.Unix() 147 | } 148 | } 149 | } 150 | 151 | func adaptSetQuery(query *string) { 152 | sp := strings.Split(*query, ",") 153 | q := []rune(*query) 154 | hasQuestionMark := false 155 | hasEqual := false 156 | for i := range q { 157 | if q[i] == '?' { 158 | hasQuestionMark = true 159 | } else if q[i] == '=' { 160 | hasEqual = true 161 | } 162 | } 163 | for i := range sp { 164 | if !hasQuestionMark && !hasEqual { 165 | sp[i] = sp[i] + "= ?" 166 | } 167 | } 168 | *query = strings.Join(sp, ",") 169 | } 170 | 171 | func adaptConcatAndLen(str string, dialect Dialect) string { 172 | if strings.Contains(str, "len(") || strings.Contains(str, "concat") { 173 | if dialect == SQLITE { 174 | strt := strings.Replace(str, "len(", "length(", -1) 175 | if str != strt { 176 | str = strt 177 | } else { 178 | str = strings.Replace(str, "len (", "length (", -1) 179 | } 180 | } else { 181 | strt := strings.Replace(str, "len(", "char_length(", -1) 182 | if str != strt { 183 | str = strt 184 | } else { 185 | str = strings.Replace(str, "len (", "char_length (", -1) 186 | } 187 | } 188 | 189 | start := strings.Index(str, "concat") 190 | if start == -1 || (dialect != SQLITE && dialect != "") { 191 | return str 192 | } 193 | // only for sqlite3 194 | parenthesis1 := strings.Index(str[start:], "(") 195 | parenthesis2 := strings.Index(str[start:], ")") 196 | inside := str[start+parenthesis1+1 : start+parenthesis2] 197 | sp := strings.Split(inside, ",") 198 | var result string 199 | for i, val := range sp { 200 | val = strings.TrimSpace(val) 201 | if i == 0 { 202 | result = val 203 | } else { 204 | result += " || " + val 205 | } 206 | } 207 | res := str[:start] + result + str[start+parenthesis2+1:] 208 | return res 209 | } else { 210 | return str 211 | } 212 | 213 | } 214 | 215 | // In expands slice arguments for IN clauses before dialect adaptation 216 | func In(query string, args ...any) (string, []any) { 217 | if len(args) == 1 { 218 | if v, ok := args[0].([]any); ok { 219 | args = v 220 | } 221 | } 222 | if !strings.Contains(query, "?") { 223 | return query, args 224 | } 225 | 226 | var expandedArgs []any 227 | split := strings.Split(query, "?") 228 | var result strings.Builder 229 | argIndex := 0 230 | 231 | for i := range split { 232 | result.WriteString(split[i]) 233 | if i < len(split)-1 && argIndex < len(args) { 234 | // Check if this placeholder is part of an IN clause 235 | beforePlaceholder := strings.TrimSpace(strings.ToUpper(split[i])) 236 | if strings.HasSuffix(beforePlaceholder, "IN") || strings.HasSuffix(beforePlaceholder, "IN (") { 237 | // Handle slice for IN clause 238 | switch v := args[argIndex].(type) { 239 | case []int: 240 | // Convert []int to []any 241 | anySlice := make([]any, len(v)) 242 | for i, val := range v { 243 | anySlice[i] = val 244 | } 245 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 246 | expandedArgs = append(expandedArgs, anySlice...) 247 | case []string: 248 | // Convert []string to []any 249 | anySlice := make([]any, len(v)) 250 | for i, val := range v { 251 | anySlice[i] = val 252 | } 253 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 254 | expandedArgs = append(expandedArgs, anySlice...) 255 | case []int64: 256 | // Convert []int to []any 257 | anySlice := make([]any, len(v)) 258 | for i, val := range v { 259 | anySlice[i] = val 260 | } 261 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 262 | expandedArgs = append(expandedArgs, anySlice...) 263 | case []uint: 264 | // Convert []uint to []any 265 | anySlice := make([]any, len(v)) 266 | for i, val := range v { 267 | anySlice[i] = val 268 | } 269 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 270 | expandedArgs = append(expandedArgs, anySlice...) 271 | case []uint8: 272 | // Convert []uint to []any 273 | anySlice := make([]any, len(v)) 274 | for i, val := range v { 275 | anySlice[i] = val 276 | } 277 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 278 | expandedArgs = append(expandedArgs, anySlice...) 279 | case []int32: 280 | // Convert []int to []any 281 | anySlice := make([]any, len(v)) 282 | for i, val := range v { 283 | anySlice[i] = val 284 | } 285 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 286 | expandedArgs = append(expandedArgs, anySlice...) 287 | case []int16: 288 | // Convert []int to []any 289 | anySlice := make([]any, len(v)) 290 | for i, val := range v { 291 | anySlice[i] = val 292 | } 293 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 294 | expandedArgs = append(expandedArgs, anySlice...) 295 | case []int8: 296 | // Convert []int to []any 297 | anySlice := make([]any, len(v)) 298 | for i, val := range v { 299 | anySlice[i] = val 300 | } 301 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 302 | expandedArgs = append(expandedArgs, anySlice...) 303 | case []uint16: 304 | // Convert []uint to []any 305 | anySlice := make([]any, len(v)) 306 | for i, val := range v { 307 | anySlice[i] = val 308 | } 309 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 310 | expandedArgs = append(expandedArgs, anySlice...) 311 | case []uint32: 312 | // Convert []uint to []any 313 | anySlice := make([]any, len(v)) 314 | for i, val := range v { 315 | anySlice[i] = val 316 | } 317 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 318 | expandedArgs = append(expandedArgs, anySlice...) 319 | case []float32: 320 | // Convert []uint to []any 321 | anySlice := make([]any, len(v)) 322 | for i, val := range v { 323 | anySlice[i] = val 324 | } 325 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 326 | expandedArgs = append(expandedArgs, anySlice...) 327 | case []float64: 328 | // Convert []uint to []any 329 | anySlice := make([]any, len(v)) 330 | for i, val := range v { 331 | anySlice[i] = val 332 | } 333 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 334 | expandedArgs = append(expandedArgs, anySlice...) 335 | case []any: 336 | result.WriteString(strings.Repeat("?,", len(v)-1) + "?") 337 | expandedArgs = append(expandedArgs, v...) 338 | default: 339 | // Not a slice, treat as normal arg 340 | result.WriteString("?") 341 | expandedArgs = append(expandedArgs, args[argIndex]) 342 | } 343 | } else { 344 | // Normal argument 345 | result.WriteString("?") 346 | expandedArgs = append(expandedArgs, args[argIndex]) 347 | } 348 | argIndex++ 349 | } 350 | } 351 | 352 | return result.String(), expandedArgs 353 | } 354 | -------------------------------------------------------------------------------- /dash.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "embed" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/kamalshkeir/lg" 9 | ) 10 | 11 | var ( 12 | staticAndTemplatesFS []embed.FS 13 | statsFuncs []StatsFunc 14 | ) 15 | 16 | type StatsFunc struct { 17 | Name string 18 | Func func() string 19 | } 20 | 21 | func cloneAndMigrateDashboard(migrateUser bool, staticAndTemplatesEmbeded ...embed.FS) { 22 | AddDashStats(StatsFunc{ 23 | Name: "Total records", 24 | Func: statsNbRecords, 25 | }) 26 | AddDashStats(StatsFunc{ 27 | Name: "Database " + defaultDB + " size", 28 | Func: statsDbSize, 29 | }) 30 | if _, err := os.Stat(assetsDir); err != nil && !embededDashboard { 31 | // if not generated 32 | cmd := exec.Command("git", "clone", "https://github.com/"+repoUser+"/"+repoName) 33 | err := cmd.Run() 34 | if lg.CheckError(err) { 35 | return 36 | } 37 | err = os.RemoveAll(repoName + "/.git") 38 | if err != nil { 39 | lg.ErrorC("unable to delete .git", "repo", repoName, "err", err) 40 | } 41 | err = os.Rename(repoName, assetsDir) 42 | if err != nil { 43 | lg.ErrorC("unable to rename", "repo", repoName, "err", err) 44 | } 45 | lg.Printfs("grdashboard assets cloned\n") 46 | } 47 | 48 | if len(staticAndTemplatesEmbeded) > 0 { 49 | staticAndTemplatesFS = staticAndTemplatesEmbeded 50 | lg.CheckError(serverBus.App().EmbededStatics(staticAndTemplatesEmbeded[0], staticDir, staticUrl)) 51 | err := serverBus.App().EmbededTemplates(staticAndTemplatesEmbeded[1], templatesDir) 52 | lg.CheckError(err) 53 | } else { 54 | lg.CheckError(serverBus.App().LocalStatics(staticDir, staticUrl)) 55 | err := serverBus.App().LocalTemplates(templatesDir) 56 | lg.CheckError(err) 57 | } 58 | if migrateUser { 59 | err := AutoMigrate[User]("users") 60 | if lg.CheckError(err) { 61 | return 62 | } 63 | } 64 | } 65 | 66 | func AddDashStats(fn ...StatsFunc) { 67 | statsFuncs = append(statsFuncs, fn...) 68 | } 69 | 70 | func GetStats() []StatsFunc { 71 | return statsFuncs 72 | } 73 | -------------------------------------------------------------------------------- /dash_auth_views.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/kamalshkeir/aes" 7 | "github.com/kamalshkeir/argon" 8 | "github.com/kamalshkeir/ksmux" 9 | "github.com/kamalshkeir/lg" 10 | ) 11 | 12 | var LoginView = func(c *ksmux.Context) { 13 | c.Html("admin/admin_login.html", nil) 14 | } 15 | 16 | var LoginPOSTView = func(c *ksmux.Context) { 17 | requestData := c.BodyJson() 18 | email := requestData["email"] 19 | passRequest := requestData["password"] 20 | 21 | data, err := Table("users").Database(defaultDB).Where("email = ?", email).One() 22 | if err != nil { 23 | c.Status(http.StatusUnauthorized).Json(map[string]any{ 24 | "error": err.Error(), 25 | }) 26 | return 27 | } 28 | if data["email"] == "" || data["email"] == nil { 29 | c.Status(http.StatusNotFound).Json(map[string]any{ 30 | "error": "User doesn not Exist", 31 | }) 32 | return 33 | } 34 | if data["is_admin"] == int64(0) || data["is_admin"] == 0 || data["is_admin"] == false { 35 | c.Status(http.StatusForbidden).Json(map[string]any{ 36 | "error": "Not Allowed to access this page", 37 | }) 38 | return 39 | } 40 | 41 | if passDB, ok := data["password"].(string); ok { 42 | if pp, ok := passRequest.(string); ok { 43 | if !argon.Match(passDB, pp) { 44 | c.Status(http.StatusForbidden).Json(map[string]any{ 45 | "error": "Wrong Password", 46 | }) 47 | return 48 | } else { 49 | if uuid, ok := data["uuid"].(string); ok { 50 | uuid, err = aes.Encrypt(uuid) 51 | lg.CheckError(err) 52 | c.SetCookie("session", uuid) 53 | c.Json(map[string]any{ 54 | "success": "U Are Logged In", 55 | }) 56 | return 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | var LogoutView = func(c *ksmux.Context) { 64 | c.DeleteCookie("session") 65 | c.Status(http.StatusTemporaryRedirect).Redirect("/") 66 | } 67 | -------------------------------------------------------------------------------- /dash_middlewares.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/kamalshkeir/aes" 7 | "github.com/kamalshkeir/ksmux" 8 | ) 9 | 10 | var ( 11 | BASIC_AUTH_USER = "notset" 12 | BASIC_AUTH_PASS = "testnotsetbutwaititshouldbeset" 13 | ) 14 | 15 | var Auth = func(handler ksmux.Handler) ksmux.Handler { 16 | return func(c *ksmux.Context) { 17 | session, err := c.GetCookie("session") 18 | if err != nil || session == "" { 19 | // NOT AUTHENTICATED 20 | c.DeleteCookie("session") 21 | handler(c) 22 | return 23 | } 24 | session, err = aes.Decrypt(session) 25 | if err != nil { 26 | handler(c) 27 | return 28 | } 29 | // Check session 30 | user, err := Model[User]().Where("uuid = ?", session).One() 31 | if err != nil { 32 | // session fail 33 | handler(c) 34 | return 35 | } 36 | 37 | // AUTHENTICATED AND FOUND IN DB 38 | c.SetKey("korm-user", user) 39 | handler(c) 40 | } 41 | } 42 | 43 | var Admin = func(handler ksmux.Handler) ksmux.Handler { 44 | return func(c *ksmux.Context) { 45 | session, err := c.GetCookie("session") 46 | if err != nil || session == "" { 47 | // NOT AUTHENTICATED 48 | c.DeleteCookie("session") 49 | c.Status(http.StatusTemporaryRedirect).Redirect(adminPathNameGroup + "/login") 50 | return 51 | } 52 | session, err = aes.Decrypt(session) 53 | if err != nil { 54 | c.Status(http.StatusTemporaryRedirect).Redirect(adminPathNameGroup + "/login") 55 | return 56 | } 57 | user, err := Model[User]().Where("uuid = ?", session).One() 58 | 59 | if err != nil { 60 | // AUTHENTICATED BUT NOT FOUND IN DB 61 | c.Status(http.StatusTemporaryRedirect).Redirect(adminPathNameGroup + "/login") 62 | return 63 | } 64 | 65 | // Not admin 66 | if !user.IsAdmin { 67 | c.Status(403).Text("Middleware : Not allowed to access this page") 68 | return 69 | } 70 | c.SetKey("korm-user", user) 71 | handler(c) 72 | } 73 | } 74 | 75 | var BasicAuth = func(handler ksmux.Handler) ksmux.Handler { 76 | return ksmux.BasicAuth(handler, BASIC_AUTH_USER, BASIC_AUTH_PASS) 77 | } 78 | -------------------------------------------------------------------------------- /dash_models_views.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "bytes" 5 | "encoding/csv" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "mime/multipart" 10 | "net/http" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/kamalshkeir/argon" 17 | "github.com/kamalshkeir/ksmux" 18 | "github.com/kamalshkeir/lg" 19 | ) 20 | 21 | var TablesView = func(c *ksmux.Context) { 22 | allTables := GetAllTables(defaultDB) 23 | q := []string{} 24 | for _, t := range allTables { 25 | q = append(q, "SELECT '"+t+"' AS table_name,COUNT(*) AS count FROM "+t) 26 | } 27 | query := strings.Join(q, ` UNION ALL `) 28 | 29 | var results []struct { 30 | TableName string `db:"table_name"` 31 | Count int `db:"count"` 32 | } 33 | if err := To(&results).Query(query); lg.CheckError(err) { 34 | c.Error("something wrong happened") 35 | return 36 | } 37 | 38 | c.Html("admin/admin_tables.html", map[string]any{ 39 | "tables": allTables, 40 | "results": results, 41 | }) 42 | } 43 | 44 | var TableGetAll = func(c *ksmux.Context) { 45 | model := c.Param("model") 46 | if model == "" { 47 | c.Json(map[string]any{ 48 | "error": "Error: No model given in params", 49 | }) 50 | return 51 | } 52 | dbMem, _ := GetMemoryDatabase(defaultDB) 53 | if dbMem == nil { 54 | lg.ErrorC("unable to find db in mem", "db", defaultDB) 55 | dbMem = &databases[0] 56 | } 57 | idString := "id" 58 | var t *TableEntity 59 | for i, tt := range dbMem.Tables { 60 | if tt.Name == model { 61 | idString = tt.Pk 62 | t = &dbMem.Tables[i] 63 | } 64 | } 65 | 66 | var body struct { 67 | Page int `json:"page"` 68 | } 69 | if err := c.BodyStruct(&body); lg.CheckError(err) { 70 | c.Error("something wrong happened") 71 | return 72 | } 73 | if body.Page == 0 { 74 | body.Page = 1 75 | } 76 | rows, err := Table(model).Database(defaultDB).OrderBy("-" + idString).Limit(paginationPer).Page(body.Page).All() 77 | if err != nil { 78 | if err != ErrNoData { 79 | c.Status(404).Error("Unable to find this model") 80 | return 81 | } 82 | rows = []map[string]any{} 83 | } 84 | 85 | // Get total count for pagination 86 | var total int64 87 | var totalRows []int64 88 | err = To(&totalRows).Query("SELECT COUNT(*) FROM " + model) 89 | if err == nil { 90 | total = totalRows[0] 91 | } 92 | 93 | dbCols, cols := GetAllColumnsTypes(model) 94 | mmfkeysModels := map[string][]map[string]any{} 95 | mmfkeys := map[string][]any{} 96 | if t != nil { 97 | for _, fkey := range t.Fkeys { 98 | spFrom := strings.Split(fkey.FromTableField, ".") 99 | if len(spFrom) == 2 { 100 | spTo := strings.Split(fkey.ToTableField, ".") 101 | if len(spTo) == 2 { 102 | q := "select * from " + spTo[0] + " order by " + spTo[1] 103 | mm := []map[string]any{} 104 | err := To(&mm).Query(q) 105 | if !lg.CheckError(err) { 106 | ress := []any{} 107 | for _, res := range mm { 108 | ress = append(ress, res[spTo[1]]) 109 | } 110 | if len(ress) > 0 { 111 | mmfkeys[spFrom[1]] = ress 112 | mmfkeysModels[spFrom[1]] = mm 113 | for _, v := range mmfkeysModels[spFrom[1]] { 114 | for i, vv := range v { 115 | if vvStr, ok := vv.(string); ok { 116 | if len(vvStr) > TruncatePer { 117 | v[i] = vvStr[:TruncatePer] + "..." 118 | } 119 | } 120 | } 121 | } 122 | } 123 | } else { 124 | lg.ErrorC("error:", "q", q, "spTo", spTo) 125 | } 126 | } 127 | } 128 | } 129 | } else { 130 | idString = cols[0] 131 | } 132 | 133 | if dbMem != nil { 134 | ccc := cols 135 | if t != nil { 136 | ccc = t.Columns 137 | } 138 | 139 | data := map[string]any{ 140 | "dbType": dbMem.Dialect, 141 | "table": model, 142 | "rows": rows, 143 | "total": total, 144 | "dbcolumns": dbCols, 145 | "pk": idString, 146 | "fkeys": mmfkeys, 147 | "fkeysModels": mmfkeysModels, 148 | "columnsOrdered": ccc, 149 | } 150 | if t != nil { 151 | data["columns"] = t.ModelTypes 152 | } else { 153 | data["columns"] = dbCols 154 | } 155 | c.Json(map[string]any{ 156 | "success": data, 157 | }) 158 | } else { 159 | lg.ErrorC("table not found", "table", model) 160 | c.Status(404).Json(map[string]any{ 161 | "error": "table not found", 162 | }) 163 | } 164 | } 165 | 166 | var AllModelsGet = func(c *ksmux.Context) { 167 | model := c.Param("model") 168 | if model == "" { 169 | c.Json(map[string]any{ 170 | "error": "Error: No model given in params", 171 | }) 172 | return 173 | } 174 | 175 | dbMem, _ := GetMemoryDatabase(defaultDB) 176 | if dbMem == nil { 177 | lg.ErrorC("unable to find db in mem", "db", defaultDB) 178 | dbMem = &databases[0] 179 | } 180 | idString := "id" 181 | var t *TableEntity 182 | for i, tt := range dbMem.Tables { 183 | if tt.Name == model { 184 | idString = tt.Pk 185 | t = &dbMem.Tables[i] 186 | } 187 | } 188 | 189 | rows, err := Table(model).Database(defaultDB).OrderBy("-" + idString).Limit(paginationPer).Page(1).All() 190 | if err != nil { 191 | rows, err = Table(model).Database(defaultDB).All() 192 | if err != nil { 193 | if err != ErrNoData { 194 | c.Status(404).Error("Unable to find this model") 195 | return 196 | } 197 | } 198 | } 199 | dbCols, cols := GetAllColumnsTypes(model) 200 | mmfkeysModels := map[string][]map[string]any{} 201 | mmfkeys := map[string][]any{} 202 | if t != nil { 203 | for _, fkey := range t.Fkeys { 204 | spFrom := strings.Split(fkey.FromTableField, ".") 205 | if len(spFrom) == 2 { 206 | spTo := strings.Split(fkey.ToTableField, ".") 207 | if len(spTo) == 2 { 208 | q := "select * from " + spTo[0] + " order by " + spTo[1] 209 | mm := []map[string]any{} 210 | err := To(&mm).Query(q) 211 | if !lg.CheckError(err) { 212 | ress := []any{} 213 | for _, res := range mm { 214 | ress = append(ress, res[spTo[1]]) 215 | } 216 | if len(ress) > 0 { 217 | mmfkeys[spFrom[1]] = ress 218 | mmfkeysModels[spFrom[1]] = mm 219 | for _, v := range mmfkeysModels[spFrom[1]] { 220 | for i, vv := range v { 221 | if vvStr, ok := vv.(string); ok { 222 | if len(vvStr) > TruncatePer { 223 | v[i] = vvStr[:TruncatePer] + "..." 224 | } 225 | } 226 | } 227 | } 228 | } 229 | } else { 230 | lg.ErrorC("error:", "q", q, "spTo", spTo) 231 | } 232 | } 233 | } 234 | } 235 | } else { 236 | idString = cols[0] 237 | } 238 | 239 | if dbMem != nil { 240 | data := map[string]any{ 241 | "dbType": dbMem.Dialect, 242 | "table": model, 243 | "rows": rows, 244 | "dbcolumns": dbCols, 245 | "pk": idString, 246 | "fkeys": mmfkeys, 247 | "fkeysModels": mmfkeysModels, 248 | "columnsOrdered": cols, 249 | } 250 | if t != nil { 251 | data["columns"] = t.ModelTypes 252 | } else { 253 | data["columns"] = dbCols 254 | } 255 | c.Html("admin/admin_single_table.html", data) 256 | } else { 257 | lg.ErrorC("table not found", "table", model) 258 | c.Status(404).Error("Unable to find this model") 259 | } 260 | } 261 | 262 | var AllModelsSearch = func(c *ksmux.Context) { 263 | model := c.Param("model") 264 | if model == "" { 265 | c.Json(map[string]any{ 266 | "error": "Error: No model given in params", 267 | }) 268 | return 269 | } 270 | 271 | body := c.BodyJson() 272 | 273 | blder := Table(model).Database(defaultDB) 274 | if query, ok := body["query"]; ok { 275 | if v, ok := query.(string); ok { 276 | if v != "" { 277 | blder.Where(v) 278 | } 279 | } else { 280 | c.Json(map[string]any{ 281 | "error": "Error: No query given in body", 282 | }) 283 | return 284 | } 285 | } 286 | 287 | oB := "" 288 | t, err := GetMemoryTable(model, defaultDB) 289 | if lg.CheckError(err) { 290 | c.Json(map[string]any{ 291 | "error": err, 292 | }) 293 | return 294 | } 295 | 296 | mmfkeysModels := map[string][]map[string]any{} 297 | mmfkeys := map[string][]any{} 298 | for _, fkey := range t.Fkeys { 299 | spFrom := strings.Split(fkey.FromTableField, ".") 300 | if len(spFrom) == 2 { 301 | spTo := strings.Split(fkey.ToTableField, ".") 302 | if len(spTo) == 2 { 303 | q := "select * from " + spTo[0] + " order by " + spTo[1] 304 | mm := []map[string]any{} 305 | err := To(&mm).Query(q) 306 | if !lg.CheckError(err) { 307 | ress := []any{} 308 | for _, res := range mm { 309 | ress = append(ress, res[spTo[1]]) 310 | } 311 | if len(ress) > 0 { 312 | mmfkeys[spFrom[1]] = ress 313 | mmfkeysModels[spFrom[1]] = mm 314 | for _, v := range mmfkeysModels[spFrom[1]] { 315 | for i, vv := range v { 316 | if vvStr, ok := vv.(string); ok { 317 | if len(vvStr) > TruncatePer { 318 | v[i] = vvStr[:TruncatePer] + "..." 319 | } 320 | } 321 | } 322 | } 323 | } 324 | } else { 325 | lg.ErrorC("error:", "q", q, "spTo", spTo) 326 | } 327 | } 328 | } 329 | } 330 | 331 | if oB != "" { 332 | blder.OrderBy(oB) 333 | } else { 334 | blder.OrderBy("-" + t.Pk) // Default order by primary key desc 335 | } 336 | 337 | // Get page from request body 338 | pageNum := 1 339 | if v, ok := body["page_num"]; ok { 340 | if pn, ok := v.(string); ok { 341 | if p, err := strconv.Atoi(pn); err == nil { 342 | pageNum = p 343 | } 344 | } 345 | } 346 | blder.Limit(paginationPer).Page(pageNum) 347 | 348 | data, err := blder.All() 349 | if err != nil { 350 | if err != ErrNoData { 351 | c.Status(http.StatusBadRequest).Json(map[string]any{ 352 | "error": err.Error(), 353 | }) 354 | return 355 | } 356 | data = []map[string]any{} 357 | } 358 | 359 | // Get total count for pagination 360 | var total int64 361 | var totalRows []int64 362 | query := "SELECT COUNT(*) FROM " + model 363 | if v, ok := body["query"]; ok { 364 | if vStr, ok := v.(string); ok && vStr != "" { 365 | query += " WHERE " + vStr 366 | } 367 | } 368 | err = To(&totalRows).Query(query) 369 | if err == nil { 370 | total = totalRows[0] 371 | } 372 | 373 | c.Json(map[string]any{ 374 | "table": model, 375 | "rows": data, 376 | "cols": t.Columns, 377 | "types": t.ModelTypes, 378 | "fkeys": mmfkeys, 379 | "fkeysModels": mmfkeysModels, 380 | "total": total, 381 | }) 382 | } 383 | 384 | var BulkDeleteRowPost = func(c *ksmux.Context) { 385 | data := struct { 386 | Ids []uint 387 | Table string 388 | }{} 389 | if lg.CheckError(c.BodyStruct(&data)) { 390 | c.Error("BAD REQUEST") 391 | return 392 | } 393 | idString := "id" 394 | t, err := GetMemoryTable(data.Table, defaultDB) 395 | if err != nil { 396 | c.Status(404).Json(map[string]any{ 397 | "error": "table not found", 398 | }) 399 | return 400 | } 401 | if t.Pk != "" && t.Pk != "id" { 402 | idString = t.Pk 403 | } 404 | _, err = Table(data.Table).Database(defaultDB).Where(idString+" IN (?)", data.Ids).Delete() 405 | if lg.CheckError(err) { 406 | c.Status(http.StatusBadRequest).Json(map[string]any{ 407 | "error": err.Error(), 408 | }) 409 | return 410 | } 411 | flushCache() 412 | c.Json(map[string]any{ 413 | "success": "DELETED WITH SUCCESS", 414 | "ids": data.Ids, 415 | }) 416 | } 417 | 418 | var CreateModelView = func(c *ksmux.Context) { 419 | data, files := c.ParseMultipartForm() 420 | 421 | model := data["table"][0] 422 | m := map[string]any{} 423 | for key, val := range data { 424 | switch key { 425 | case "table": 426 | continue 427 | case "uuid": 428 | if v := m[key]; v == "" { 429 | m[key] = GenerateUUID() 430 | } else { 431 | m[key] = val[0] 432 | } 433 | case "password": 434 | hash, _ := argon.Hash(val[0]) 435 | m[key] = hash 436 | case "email": 437 | if !IsValidEmail(val[0]) { 438 | c.Json(map[string]any{ 439 | "error": "email not valid", 440 | }) 441 | return 442 | } 443 | m[key] = val[0] 444 | case "pk": 445 | continue 446 | default: 447 | if key != "" && val[0] != "" && val[0] != "null" { 448 | m[key] = val[0] 449 | } 450 | } 451 | } 452 | inserted, err := Table(model).Database(defaultDB).InsertR(m) 453 | if err != nil { 454 | lg.ErrorC("CreateModelView error", "err", err) 455 | c.Status(http.StatusBadRequest).Json(map[string]any{ 456 | "error": err.Error(), 457 | }) 458 | return 459 | } 460 | 461 | idString := "id" 462 | t, _ := GetMemoryTable(data["table"][0], defaultDB) 463 | if t.Pk != "" && t.Pk != "id" { 464 | idString = t.Pk 465 | } 466 | pathUploaded, formName, err := handleFilesUpload(files, data["table"][0], fmt.Sprintf("%v", inserted[idString]), c, idString) 467 | if err != nil { 468 | c.Status(http.StatusBadRequest).Json(map[string]any{ 469 | "error": err.Error(), 470 | }) 471 | return 472 | } 473 | if len(pathUploaded) > 0 { 474 | inserted[formName[0]] = pathUploaded[0] 475 | } 476 | 477 | flushCache() 478 | c.Json(map[string]any{ 479 | "success": "Done !", 480 | "inserted": inserted, 481 | }) 482 | } 483 | 484 | var UpdateRowPost = func(c *ksmux.Context) { 485 | // parse the fkorm and get data values + files 486 | data, files := c.ParseMultipartForm() 487 | id := data["row_id"][0] 488 | idString := "id" 489 | db, _ := GetMemoryDatabase(defaultDB) 490 | var t TableEntity 491 | for _, tab := range db.Tables { 492 | if tab.Name == data["table"][0] { 493 | t = tab 494 | } 495 | } 496 | if t.Pk != "" && t.Pk != "id" { 497 | idString = t.Pk 498 | } 499 | _, _, err := handleFilesUpload(files, data["table"][0], id, c, idString) 500 | if err != nil { 501 | c.Status(http.StatusBadRequest).Json(map[string]any{ 502 | "error": err.Error(), 503 | }) 504 | return 505 | } 506 | 507 | modelDB, err := Table(data["table"][0]).Database(defaultDB).Where(idString+" = ?", id).One() 508 | 509 | if err != nil { 510 | c.Status(http.StatusBadRequest).Json(map[string]any{ 511 | "error": err.Error(), 512 | }) 513 | return 514 | } 515 | 516 | ignored := []string{idString, "file", "image", "photo", "img", "fichier", "row_id", "table"} 517 | toUpdate := map[string]any{} 518 | quote := "`" 519 | if db.Dialect == POSTGRES || db.Dialect == COCKROACH { 520 | quote = "\"" 521 | } 522 | for key, val := range data { 523 | if !SliceContains(ignored, key) { 524 | if modelDB[key] == val[0] { 525 | // no changes for bool 526 | continue 527 | } 528 | if key == "password" || key == "pass" { 529 | hash, err := argon.Hash(val[0]) 530 | if err != nil { 531 | c.Error("unable to hash pass") 532 | return 533 | } 534 | toUpdate[quote+key+quote] = hash 535 | } else { 536 | toUpdate[quote+key+quote] = val[0] 537 | } 538 | } 539 | } 540 | 541 | s := "" 542 | values := []any{} 543 | if len(toUpdate) > 0 { 544 | for col, v := range toUpdate { 545 | if s == "" { 546 | s += col + "= ?" 547 | } else { 548 | s += "," + col + "= ?" 549 | } 550 | values = append(values, v) 551 | } 552 | } 553 | if s != "" { 554 | _, err := Table(data["table"][0]).Database(defaultDB).Where(idString+" = ?", id).Set(s, values...) 555 | if err != nil { 556 | c.Status(http.StatusBadRequest).Json(map[string]any{ 557 | "error": err.Error(), 558 | }) 559 | return 560 | } 561 | } 562 | s = "" 563 | if len(files) > 0 { 564 | for f := range files { 565 | if s == "" { 566 | s += f 567 | } else { 568 | s += "," + f 569 | } 570 | } 571 | } 572 | if len(toUpdate) > 0 { 573 | for k := range toUpdate { 574 | if s == "" { 575 | s += k 576 | } else { 577 | s += "," + k 578 | } 579 | } 580 | } 581 | 582 | ret, err := Table(data["table"][0]).Database(defaultDB).Where(idString+" = ?", id).One() 583 | if err != nil { 584 | c.Status(500).Error("something wrong happened") 585 | return 586 | } 587 | 588 | flushCache() 589 | c.Json(map[string]any{ 590 | "success": ret, 591 | }) 592 | } 593 | 594 | func handleFilesUpload(files map[string][]*multipart.FileHeader, model string, id string, c *ksmux.Context, pkKey string) (uploadedPath []string, formName []string, err error) { 595 | if len(files) > 0 { 596 | for key, val := range files { 597 | file, _ := val[0].Open() 598 | defer file.Close() 599 | uploadedImage, err := uploadMultipartFile(file, val[0].Filename, mediaDir+"/uploads/") 600 | if err != nil { 601 | return uploadedPath, formName, err 602 | } 603 | row, err := Table(model).Database(defaultDB).Where(pkKey+" = ?", id).One() 604 | if err != nil { 605 | return uploadedPath, formName, err 606 | } 607 | database_image, okDB := row[key] 608 | uploadedPath = append(uploadedPath, uploadedImage) 609 | formName = append(formName, key) 610 | if database_image == uploadedImage { 611 | return uploadedPath, formName, errors.New("uploadedImage is the same") 612 | } else { 613 | if v, ok := database_image.(string); ok || okDB { 614 | err := c.DeleteFile(v) 615 | if err != nil { 616 | //le fichier n'existe pas 617 | _, err := Table(model).Database(defaultDB).Where(pkKey+" = ?", id).Set(key+" = ?", uploadedImage) 618 | lg.CheckError(err) 619 | continue 620 | } else { 621 | //le fichier existe et donc supprimer 622 | _, err := Table(model).Database(defaultDB).Where(pkKey+" = ?", id).Set(key+" = ?", uploadedImage) 623 | lg.CheckError(err) 624 | continue 625 | } 626 | } 627 | } 628 | } 629 | flushCache() 630 | } 631 | return uploadedPath, formName, nil 632 | } 633 | 634 | var DropTablePost = func(c *ksmux.Context) { 635 | data := c.BodyJson() 636 | if table, ok := data["table"]; ok && table != "" { 637 | if t, ok := data["table"].(string); ok { 638 | _, err := Table(t).Database(defaultDB).Drop() 639 | if lg.CheckError(err) { 640 | c.Status(http.StatusBadRequest).Json(map[string]any{ 641 | "error": err.Error(), 642 | }) 643 | return 644 | } 645 | } else { 646 | c.Status(http.StatusBadRequest).Json(map[string]any{ 647 | "error": "expecting 'table' to be string", 648 | }) 649 | } 650 | } else { 651 | c.Status(http.StatusBadRequest).Json(map[string]any{ 652 | "error": "missing 'table' in body request", 653 | }) 654 | } 655 | flushCache() 656 | c.Json(map[string]any{ 657 | "success": fmt.Sprintf("table %s Deleted !", data["table"]), 658 | }) 659 | } 660 | 661 | var ExportView = func(c *ksmux.Context) { 662 | table := c.Param("table") 663 | if table == "" { 664 | c.Status(http.StatusBadRequest).Json(map[string]any{ 665 | "error": "no param table found", 666 | }) 667 | return 668 | } 669 | data, err := Table(table).Database(defaultDB).All() 670 | lg.CheckError(err) 671 | 672 | data_bytes, err := json.Marshal(data) 673 | lg.CheckError(err) 674 | 675 | c.Download(data_bytes, table+".json") 676 | } 677 | 678 | var ExportCSVView = func(c *ksmux.Context) { 679 | table := c.Param("table") 680 | if table == "" { 681 | c.Status(http.StatusBadRequest).Json(map[string]any{ 682 | "error": "no param table found", 683 | }) 684 | return 685 | } 686 | data, err := Table(table).Database(defaultDB).All() 687 | lg.CheckError(err) 688 | var buff bytes.Buffer 689 | writer := csv.NewWriter(&buff) 690 | 691 | cols := []string{} 692 | tab, _ := GetMemoryTable(table, defaultDB) 693 | if len(tab.Columns) > 0 { 694 | cols = tab.Columns 695 | } else if len(data) > 0 { 696 | d := data[0] 697 | for k := range d { 698 | cols = append(cols, k) 699 | } 700 | } 701 | 702 | err = writer.Write(cols) 703 | lg.CheckError(err) 704 | for _, sd := range data { 705 | values := []string{} 706 | for _, k := range cols { 707 | switch vv := sd[k].(type) { 708 | case string: 709 | values = append(values, vv) 710 | case bool: 711 | if vv { 712 | values = append(values, "true") 713 | } else { 714 | values = append(values, "false") 715 | } 716 | case int: 717 | values = append(values, strconv.Itoa(vv)) 718 | case int64: 719 | values = append(values, strconv.Itoa(int(vv))) 720 | case uint: 721 | values = append(values, strconv.Itoa(int(vv))) 722 | case time.Time: 723 | values = append(values, vv.String()) 724 | default: 725 | values = append(values, fmt.Sprintf("%v", vv)) 726 | } 727 | 728 | } 729 | err = writer.Write(values) 730 | lg.CheckError(err) 731 | } 732 | writer.Flush() 733 | c.Download(buff.Bytes(), table+".csv") 734 | } 735 | 736 | var ImportView = func(c *ksmux.Context) { 737 | // get table name 738 | table := c.Request.FormValue("table") 739 | if table == "" { 740 | c.Status(http.StatusBadRequest).Json(map[string]any{ 741 | "error": "no table !", 742 | }) 743 | return 744 | } 745 | t, err := GetMemoryTable(table, defaultDB) 746 | if lg.CheckError(err) { 747 | c.Status(http.StatusBadRequest).Json(map[string]any{ 748 | "error": err.Error(), 749 | }) 750 | return 751 | } 752 | // upload file and return bytes of file 753 | fname, dataBytes, err := c.UploadFile("thefile", "backup", "json", "csv") 754 | if lg.CheckError(err) { 755 | c.Status(http.StatusBadRequest).Json(map[string]any{ 756 | "error": err.Error(), 757 | }) 758 | return 759 | } 760 | isCsv := strings.HasSuffix(fname, ".csv") 761 | 762 | // get old data and backup 763 | modelsOld, _ := Table(table).Database(defaultDB).All() 764 | if len(modelsOld) > 0 { 765 | modelsOldBytes, err := json.Marshal(modelsOld) 766 | if !lg.CheckError(err) { 767 | _ = os.MkdirAll(mediaDir+"/backup/", 0770) 768 | dst, err := os.Create(mediaDir + "/backup/" + table + "-" + time.Now().Format("2006-01-02") + ".json") 769 | lg.CheckError(err) 770 | defer dst.Close() 771 | _, err = dst.Write(modelsOldBytes) 772 | lg.CheckError(err) 773 | } 774 | } 775 | 776 | // fill list_map 777 | list_map := []map[string]any{} 778 | if isCsv { 779 | reader := csv.NewReader(bytes.NewReader(dataBytes)) 780 | lines, err := reader.ReadAll() 781 | if lg.CheckError(err) { 782 | c.Status(http.StatusBadRequest).Json(map[string]any{ 783 | "error": err.Error(), 784 | }) 785 | return 786 | } 787 | 788 | for _, values := range lines { 789 | m := map[string]any{} 790 | for i := range values { 791 | m[t.Columns[i]] = values[i] 792 | } 793 | list_map = append(list_map, m) 794 | } 795 | } else { 796 | err := json.Unmarshal(dataBytes, &list_map) 797 | if lg.CheckError(err) { 798 | c.Status(http.StatusBadRequest).Json(map[string]any{ 799 | "error": err.Error(), 800 | }) 801 | return 802 | } 803 | } 804 | 805 | // create models in database 806 | var retErr []error 807 | for _, m := range list_map { 808 | _, err = Table(table).Database(defaultDB).Insert(m) 809 | if err != nil { 810 | retErr = append(retErr, err) 811 | } 812 | } 813 | if len(retErr) > 0 { 814 | c.Json(map[string]any{ 815 | "success": "some data could not be added, " + errors.Join(retErr...).Error(), 816 | }) 817 | return 818 | } 819 | flushCache() 820 | 821 | c.Json(map[string]any{ 822 | "success": "Import Done , you can see uploaded backups at ./" + mediaDir + "/backup folder", 823 | }) 824 | } 825 | -------------------------------------------------------------------------------- /dash_types.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "path" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | var ( 10 | paginationPer = 10 11 | embededDashboard = false 12 | mediaDir = "media" 13 | docsUrl = "/docs" 14 | staticUrl = "/static" 15 | assetsDir = "assets" 16 | staticDir = path.Join(assetsDir, "/", "static") 17 | templatesDir = path.Join(assetsDir, "/", "templates") 18 | repoUser = "kamalshkeir" 19 | repoName = "korm-dash" 20 | dahsboardUsed = false 21 | adminPathNameGroup = "/admin" 22 | terminalUIEnabled = false 23 | // Debug when true show extra useful logs for queries executed for migrations and queries statements 24 | Debug = false 25 | // FlushCacheEvery execute korm.FlushCache() every 10 min by default, you should not worry about it, but useful that you can change it 26 | FlushCacheEvery = 10 * time.Minute 27 | // MaxOpenConns set max open connections for db pool 28 | MaxOpenConns = 50 29 | // MaxIdleConns set max idle connections for db pool 30 | MaxIdleConns = 30 31 | // MaxLifetime set max lifetime for a connection in the db pool 32 | MaxLifetime = 30 * time.Minute 33 | // MaxIdleTime set max idletime for a connection in the db pool 34 | MaxIdleTime = 30 * time.Minute 35 | ) 36 | 37 | type User struct { 38 | Id int `json:"id,omitempty" korm:"pk"` 39 | Uuid string `json:"uuid,omitempty" korm:"size:40;iunique"` 40 | Username string `json:"username,omitempty" korm:"size:40;iunique"` 41 | Email string `json:"email,omitempty" korm:"size:50;iunique"` 42 | Password string `json:"password,omitempty" korm:"size:150;default:''"` 43 | IsAdmin bool `json:"is_admin,omitempty" korm:"default:false"` 44 | Image string `json:"image,omitempty" korm:"size:100;default:''"` 45 | CreatedAt time.Time `json:"created_at,omitempty" korm:"now"` 46 | } 47 | 48 | // SetAdminPath set admin path, default '/admin' 49 | func SetAdminPath(path string) { 50 | if !strings.HasPrefix(path, "/") { 51 | adminPathNameGroup = "/" + path 52 | } else { 53 | adminPathNameGroup = path 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /dash_urls.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | "sync/atomic" 7 | 8 | "github.com/kamalshkeir/ksmux" 9 | ) 10 | 11 | func init() { 12 | const kormKeyUser = "korm-user" 13 | ksmux.BeforeRenderHtml("korm-user", func(c *ksmux.Context, data *map[string]any) { 14 | (*data)["admin_path"] = adminPathNameGroup 15 | (*data)["static_url"] = staticUrl 16 | (*data)["trace_enabled"] = defaultTracer.enabled 17 | (*data)["terminal_enabled"] = terminalUIEnabled 18 | (*data)["nodemanager_enabled"] = nodeManager != nil 19 | user, ok := c.GetKey(kormKeyUser) 20 | if ok { 21 | (*data)["IsAuthenticated"] = true 22 | (*data)["User"] = user 23 | } else { 24 | (*data)["IsAuthenticated"] = false 25 | (*data)["User"] = nil 26 | } 27 | }) 28 | 29 | } 30 | 31 | var withRequestCounter = false 32 | 33 | func initAdminUrlPatterns(withReqCounter bool, r *ksmux.Router) { 34 | media_root := http.FileServer(http.Dir("./" + mediaDir)) 35 | r.Get(`/`+mediaDir+`/*path`, func(c *ksmux.Context) { 36 | http.StripPrefix("/"+mediaDir+"/", media_root).ServeHTTP(c.ResponseWriter, c.Request) 37 | }) 38 | 39 | if withReqCounter { 40 | withReqCounter = true 41 | // request counter middleware 42 | r.Use(func(h http.Handler) http.Handler { 43 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | // Skip counting for static files, favicons etc 45 | path := r.URL.Path 46 | if !strings.HasPrefix(path, staticUrl) && 47 | !strings.Contains(path, "/favicon") && 48 | !strings.Contains(path, "/robots.txt") && 49 | !strings.Contains(path, "/manifest.json") && 50 | !strings.Contains(path, "/sw.js") { 51 | atomic.AddUint64(&totalRequests, 1) 52 | } 53 | 54 | h.ServeHTTP(w, r) 55 | }) 56 | }) 57 | } 58 | r.Get("/mon/ping", func(c *ksmux.Context) { c.Status(200).Text("pong") }) 59 | r.Get("/offline", OfflineView) 60 | r.Get("/manifest.webmanifest", ManifestView) 61 | r.Get("/sw.js", ServiceWorkerView) 62 | r.Get("/robots.txt", RobotsTxtView) 63 | adminGroup := r.Group(adminPathNameGroup) 64 | adminGroup.Get("/", Admin(DashView)) 65 | adminGroup.Get("/login", Auth(LoginView)) 66 | adminGroup.Post("/login", Auth(LoginPOSTView)) 67 | adminGroup.Get("/logout", LogoutView) 68 | adminGroup.Get("/tables", Admin(TablesView)) 69 | adminGroup.Post("/tables/all/:model", Admin(TableGetAll)) 70 | adminGroup.Get("/tables/:model", Admin(AllModelsGet)) 71 | adminGroup.Post("/tables/:model/search", Admin(AllModelsSearch)) 72 | adminGroup.Post("/delete/rows", Admin(BulkDeleteRowPost)) 73 | adminGroup.Post("/update/row", Admin(UpdateRowPost)) 74 | adminGroup.Post("/create/row", Admin(CreateModelView)) 75 | adminGroup.Post("/drop/table", Admin(DropTablePost)) 76 | adminGroup.Get("/export/:table", Admin(ExportView)) 77 | adminGroup.Get("/export/:table/csv", Admin(ExportCSVView)) 78 | adminGroup.Get("/logs", Admin(LogsView)) 79 | adminGroup.Get("/logs/get", Admin(GetLogsView)) 80 | adminGroup.Get("/metrics/get", Admin(GetMetricsView)) 81 | adminGroup.Post("/import", Admin(ImportView)) 82 | adminGroup.Get("/restart", Admin(RestartView)) 83 | if defaultTracer.enabled { 84 | adminGroup.Get("/traces", Admin(TracingGetView)) 85 | adminGroup.Get("/traces/get", Admin(GetTraces)) 86 | adminGroup.Post("/traces/clear", Admin(ClearTraces)) 87 | } 88 | if terminalUIEnabled { 89 | adminGroup.Get("/terminal", Admin(TerminalGetView)) 90 | adminGroup.Post("/terminal/execute", Admin(TerminalExecute)) 91 | adminGroup.Get("/terminal/complete", Admin(TerminalComplete)) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /dash_views.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "mime/multipart" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | "runtime" 12 | "sort" 13 | "strconv" 14 | "strings" 15 | "sync/atomic" 16 | 17 | "github.com/kamalshkeir/kmap" 18 | "github.com/kamalshkeir/ksmux" 19 | "github.com/kamalshkeir/lg" 20 | ) 21 | 22 | var TruncatePer = 50 23 | 24 | var termsessions = kmap.New[string, string]() 25 | 26 | var LogsView = func(c *ksmux.Context) { 27 | d := map[string]any{ 28 | "metrics": GetSystemMetrics(), 29 | } 30 | parsed := make([]LogEntry, 0) 31 | if v := lg.GetLogs(); v != nil { 32 | for _, vv := range reverseSlice(v.Slice) { 33 | parsed = append(parsed, parseLogString(vv)) 34 | } 35 | } 36 | d["parsed"] = parsed 37 | c.Html("admin/admin_logs.html", d) 38 | } 39 | 40 | var DashView = func(c *ksmux.Context) { 41 | ddd := map[string]any{ 42 | "withRequestCounter": withRequestCounter, 43 | "stats": GetStats(), 44 | } 45 | if withRequestCounter { 46 | ddd["requests"] = GetTotalRequests() 47 | } 48 | 49 | c.Html("admin/admin_index.html", ddd) 50 | } 51 | 52 | var RestartView = func(c *ksmux.Context) { 53 | if serverBus != nil { 54 | lg.CheckError(serverBus.App().Restart()) 55 | } 56 | } 57 | 58 | var TracingGetView = func(c *ksmux.Context) { 59 | c.Html("admin/admin_tracing.html", nil) 60 | } 61 | 62 | var TerminalGetView = func(c *ksmux.Context) { 63 | c.Html("admin/admin_terminal.html", nil) 64 | } 65 | 66 | // WebSocket endpoint for terminal 67 | var TerminalExecute = func(c *ksmux.Context) { 68 | var req struct { 69 | Command string `json:"command"` 70 | Session string `json:"session"` 71 | } 72 | if err := c.BodyStruct(&req); err != nil { 73 | c.Json(map[string]any{"type": "error", "content": err.Error()}) 74 | return 75 | } 76 | 77 | currentDir, _ := termsessions.Get(req.Session) 78 | if currentDir == "" { 79 | currentDir, _ = os.Getwd() 80 | } 81 | 82 | output, newDir := executeCommand(req.Command, currentDir) 83 | 84 | // Always update the session with the new directory 85 | termsessions.Set(req.Session, newDir) 86 | lg.Debug("Updated session directory:", newDir) // Debug log 87 | 88 | c.Json(map[string]any{ 89 | "type": "output", 90 | "content": output, 91 | "directory": newDir, 92 | }) 93 | } 94 | 95 | var GetTraces = func(c *ksmux.Context) { 96 | dbtraces := GetDBTraces() 97 | if len(dbtraces) > 0 { 98 | for _, t := range dbtraces { 99 | sp, _ := ksmux.StartSpan(context.Background(), t.Query) 100 | sp.SetTag("query", t.Query) 101 | sp.SetTag("args", fmt.Sprint(t.Args)) 102 | if t.Database != "" { 103 | sp.SetTag("database", t.Database) 104 | } 105 | sp.SetTag("duration", t.Duration.String()) 106 | sp.SetDuration(t.Duration) 107 | sp.SetError(t.Error) 108 | sp.End() 109 | } 110 | ClearDBTraces() 111 | } 112 | 113 | traces := ksmux.GetTraces() 114 | traceList := make([]map[string]interface{}, 0) 115 | for traceID, spans := range traces { 116 | spanList := make([]map[string]interface{}, 0) 117 | for _, span := range spans { 118 | errorMsg := "" 119 | if span.Error() != nil { 120 | errorMsg = span.Error().Error() 121 | } 122 | spanList = append(spanList, map[string]interface{}{ 123 | "id": span.SpanID(), 124 | "parentID": span.ParentID(), 125 | "name": span.Name(), 126 | "startTime": span.StartTime(), 127 | "endTime": span.EndTime(), 128 | "duration": span.Duration().String(), 129 | "tags": span.Tags(), 130 | "statusCode": span.StatusCode(), 131 | "error": errorMsg, 132 | }) 133 | } 134 | traceList = append(traceList, map[string]interface{}{ 135 | "traceID": traceID, 136 | "spans": spanList, 137 | }) 138 | } 139 | c.Json(traceList) 140 | } 141 | 142 | var ClearTraces = func(c *ksmux.Context) { 143 | ksmux.ClearTraces() 144 | c.Success("traces cleared") 145 | } 146 | 147 | var GetMetricsView = func(c *ksmux.Context) { 148 | metrics := GetSystemMetrics() 149 | c.Json(metrics) 150 | } 151 | 152 | var GetLogsView = func(c *ksmux.Context) { 153 | parsed := make([]LogEntry, 0) 154 | if v := lg.GetLogs(); v != nil { 155 | for _, vv := range reverseSlice(v.Slice) { 156 | parsed = append(parsed, parseLogString(vv)) 157 | } 158 | } 159 | c.Json(parsed) 160 | } 161 | 162 | var ManifestView = func(c *ksmux.Context) { 163 | if embededDashboard { 164 | f, err := staticAndTemplatesFS[0].ReadFile(staticDir + "/manifest.json") 165 | if err != nil { 166 | lg.ErrorC("cannot embed manifest.json", "err", err) 167 | return 168 | } 169 | c.ServeEmbededFile("application/json; charset=utf-8", f) 170 | } else { 171 | c.ServeFile("application/json; charset=utf-8", staticDir+"/manifest.json") 172 | } 173 | } 174 | 175 | var ServiceWorkerView = func(c *ksmux.Context) { 176 | if embededDashboard { 177 | f, err := staticAndTemplatesFS[0].ReadFile(staticDir + "/sw.js") 178 | if err != nil { 179 | lg.ErrorC("cannot embed sw.js", "err", err) 180 | return 181 | } 182 | c.ServeEmbededFile("application/javascript; charset=utf-8", f) 183 | } else { 184 | c.ServeFile("application/javascript; charset=utf-8", staticDir+"/sw.js") 185 | } 186 | } 187 | 188 | var RobotsTxtView = func(c *ksmux.Context) { 189 | c.ServeFile("text/plain; charset=utf-8", "."+staticUrl+"/robots.txt") 190 | } 191 | 192 | var OfflineView = func(c *ksmux.Context) { 193 | c.Text("

YOUR ARE OFFLINE, check connection

") 194 | } 195 | 196 | func statsNbRecords() string { 197 | allTables := GetAllTables(defaultDB) 198 | q := []string{} 199 | for _, t := range allTables { 200 | q = append(q, "SELECT '"+t+"' AS table_name,COUNT(*) AS count FROM "+t) 201 | } 202 | query := strings.Join(q, ` UNION ALL `) 203 | 204 | var results []struct { 205 | TableName string `db:"table_name"` 206 | Count int `db:"count"` 207 | } 208 | if err := To(&results).Query(query); lg.CheckError(err) { 209 | return "0" 210 | } 211 | count := 0 212 | for _, r := range results { 213 | count += r.Count 214 | } 215 | return strconv.Itoa(count) 216 | } 217 | 218 | func statsDbSize() string { 219 | size, err := GetDatabaseSize(defaultDB) 220 | if err != nil { 221 | lg.Error(err) 222 | size = "0 MB" 223 | } 224 | return size 225 | } 226 | 227 | type LogEntry struct { 228 | Type string 229 | At string 230 | Extra string 231 | } 232 | 233 | // Global atomic counter for requests 234 | var totalRequests uint64 235 | 236 | // GetTotalRequests returns the current total requests count 237 | func GetTotalRequests() uint64 { 238 | return atomic.LoadUint64(&totalRequests) 239 | } 240 | 241 | func parseLogString(logStr string) LogEntry { 242 | // Handle empty string case 243 | if logStr == "" { 244 | return LogEntry{} 245 | } 246 | 247 | // Split the time from the end 248 | parts := strings.Split(logStr, "time=") 249 | timeStr := "" 250 | mainPart := logStr 251 | 252 | if len(parts) > 1 { 253 | timeStr = strings.TrimSpace(parts[1]) 254 | mainPart = strings.TrimSpace(parts[0]) 255 | } 256 | 257 | // Get the log type (ERRO, INFO, etc) 258 | logType := "" 259 | if len(mainPart) >= 4 { 260 | logType = strings.TrimSpace(mainPart[:4]) 261 | mainPart = mainPart[4:] 262 | } 263 | 264 | // Clean up the type 265 | switch logType { 266 | case "ERRO": 267 | logType = "ERROR" 268 | case "INFO": 269 | logType = "INFO" 270 | case "WARN": 271 | logType = "WARNING" 272 | case "DEBU": 273 | logType = "DEBUG" 274 | case "FATA": 275 | logType = "FATAL" 276 | default: 277 | logType = "N/A" 278 | } 279 | 280 | return LogEntry{ 281 | Type: logType, 282 | At: timeStr, 283 | Extra: strings.TrimSpace(mainPart), 284 | } 285 | } 286 | 287 | func reverseSlice[T any](slice []T) []T { 288 | new := make([]T, 0, len(slice)) 289 | for i := len(slice) - 1; i >= 0; i-- { 290 | new = append(new, slice[i]) 291 | } 292 | return new 293 | } 294 | 295 | // GetDatabaseSize returns the size of the database in GB or MB 296 | func GetDatabaseSize(dbName string) (string, error) { 297 | db := databases[0] // default db 298 | for _, d := range databases { 299 | if d.Name == dbName { 300 | db = d 301 | break 302 | } 303 | } 304 | 305 | var size float64 306 | var err error 307 | 308 | switch db.Dialect { 309 | case "sqlite", "sqlite3": 310 | // For SQLite, get the file size 311 | info, err := os.Stat(dbName + ".sqlite3") 312 | if err != nil { 313 | return "0 MB", fmt.Errorf("error getting sqlite db size: %v", err) 314 | } 315 | size = float64(info.Size()) 316 | 317 | case "postgres", "postgresql": 318 | // For PostgreSQL, query the pg_database_size function 319 | var sizeBytes int64 320 | query := `SELECT pg_database_size($1)` 321 | 322 | err = GetConnection().QueryRow(query, db.Name).Scan(&sizeBytes) 323 | if err != nil { 324 | return "0 MB", fmt.Errorf("error getting postgres db size: %v", err) 325 | } 326 | size = float64(sizeBytes) 327 | 328 | case "mysql", "mariadb": 329 | // For MySQL/MariaDB, query information_schema 330 | var sizeBytes int64 331 | query := ` 332 | SELECT SUM(data_length + index_length) 333 | FROM information_schema.TABLES 334 | WHERE table_schema = ?` 335 | err = GetConnection().QueryRow(query, db.Name).Scan(&sizeBytes) 336 | if err != nil { 337 | return "0 MB", fmt.Errorf("error getting mysql db size: %v", err) 338 | } 339 | size = float64(sizeBytes) 340 | 341 | default: 342 | return "0 MB", fmt.Errorf("unsupported database dialect: %s", db.Dialect) 343 | } 344 | 345 | // Convert bytes to GB (1 GB = 1024^3 bytes) 346 | sizeGB := size / (1024 * 1024 * 1024) 347 | 348 | // If size is less than 1 GB, convert to MB 349 | if sizeGB < 1 { 350 | sizeMB := size / (1024 * 1024) 351 | return fmt.Sprintf("%.2f MB", sizeMB), nil 352 | } 353 | 354 | return fmt.Sprintf("%.2f GB", sizeGB), nil 355 | } 356 | 357 | func uploadMultipartFile(file multipart.File, filename string, outPath string, acceptedFormats ...string) (string, error) { 358 | //create destination file making sure the path is writeable. 359 | if outPath == "" { 360 | outPath = mediaDir + "/uploads/" 361 | } else { 362 | if !strings.HasSuffix(outPath, "/") { 363 | outPath += "/" 364 | } 365 | } 366 | err := os.MkdirAll(outPath, 0770) 367 | if err != nil { 368 | return "", err 369 | } 370 | 371 | l := []string{"jpg", "jpeg", "png", "json"} 372 | if len(acceptedFormats) > 0 { 373 | l = acceptedFormats 374 | } 375 | 376 | if strings.ContainsAny(filename, strings.Join(l, "")) { 377 | dst, err := os.Create(outPath + filename) 378 | if err != nil { 379 | return "", err 380 | } 381 | defer dst.Close() 382 | 383 | //copy the uploaded file to the destination file 384 | if _, err := io.Copy(dst, file); err != nil { 385 | return "", err 386 | } else { 387 | url := "/" + outPath + filename 388 | return url, nil 389 | } 390 | } else { 391 | return "", fmt.Errorf("not in allowed extensions 'jpg','jpeg','png','json' : %v", l) 392 | } 393 | } 394 | 395 | // TERMINAL 396 | 397 | func executeCommand(command, currentDir string) (output, newDir string) { 398 | parts := strings.Fields(command) 399 | if len(parts) == 0 { 400 | return "", currentDir 401 | } 402 | 403 | // Check if command is allowed 404 | if !terminalAllowedCommands[parts[0]] { 405 | return fmt.Sprintf("Command '%s' not allowed. Use only: %v\n", 406 | parts[0], getAllowedCommands()), currentDir 407 | } 408 | 409 | // Handle built-in cd command since it affects the terminal's working directory 410 | switch parts[0] { 411 | case "touch": 412 | if len(parts) < 2 { 413 | return "Error: missing file name\n", currentDir 414 | } 415 | fileName := parts[1] 416 | filePath := filepath.Join(currentDir, fileName) 417 | // check if file exists 418 | if _, err := os.Stat(filePath); err == nil { 419 | return fmt.Sprintf("File '%s' already exists\n", fileName), currentDir 420 | } 421 | _, err := os.Create(filePath) 422 | if err != nil { 423 | return fmt.Sprintf("Error creating file: %s\n", err), currentDir 424 | } 425 | return fmt.Sprintf("File '%s' created at '%s'\n", fileName, filePath), currentDir 426 | case "ls", "dir": 427 | // Default to current directory if no argument provided 428 | targetDir := currentDir 429 | 430 | // If argument provided, resolve the path 431 | if len(parts) > 1 { 432 | // Handle relative or absolute path 433 | if filepath.IsAbs(parts[1]) { 434 | targetDir = parts[1] 435 | } else { 436 | targetDir = filepath.Join(currentDir, parts[1]) 437 | } 438 | } 439 | 440 | // Clean up path and check if directory exists 441 | targetDir = filepath.Clean(targetDir) 442 | if fi, err := os.Stat(targetDir); err != nil || !fi.IsDir() { 443 | return fmt.Sprintf("Error: cannot access '%s': No such directory\n", parts[1]), currentDir 444 | } 445 | 446 | files, err := os.ReadDir(targetDir) 447 | if err != nil { 448 | return fmt.Sprintf("Error reading directory: %s\n", err), currentDir 449 | } 450 | 451 | var output strings.Builder 452 | for _, file := range files { 453 | info, err := file.Info() 454 | if err != nil { 455 | continue 456 | } 457 | prefix := "F" 458 | if file.IsDir() { 459 | prefix = "D" 460 | } 461 | size := fmt.Sprintf("%8d", info.Size()) 462 | name := file.Name() 463 | output.WriteString(fmt.Sprintf("[%s] %s %s\n", prefix, size, name)) 464 | } 465 | return output.String(), currentDir 466 | case "cd": 467 | if len(parts) < 2 { 468 | // cd without args goes to home directory 469 | home, err := os.UserHomeDir() 470 | if err != nil { 471 | return "Error getting home directory: " + err.Error() + "\n", currentDir 472 | } 473 | return home, home 474 | } 475 | newDir := parts[1] 476 | if !filepath.IsAbs(newDir) { 477 | newDir = filepath.Join(currentDir, newDir) 478 | } 479 | if fi, err := os.Stat(newDir); err == nil && fi.IsDir() { 480 | return newDir, newDir 481 | } 482 | return "Error: Not a directory\n", currentDir 483 | case "clear", "cls": 484 | return "CLEAR", currentDir 485 | case "pwd": 486 | return currentDir + "\n", currentDir 487 | case "vim", "vi", "nano", "nvim": 488 | return fmt.Sprintf("Interactive editors like %s are not supported in web terminal\n", parts[0]), currentDir 489 | case "tail": 490 | if len(parts) < 2 { 491 | return "Error: missing file name\n", currentDir 492 | } 493 | fileName := parts[1] 494 | filePath := filepath.Join(currentDir, fileName) 495 | 496 | // Default settings 497 | numLines := 10 498 | follow := false 499 | 500 | // Parse flags 501 | for i := 2; i < len(parts); i++ { 502 | switch parts[i] { 503 | case "-n": 504 | if i+1 < len(parts) { 505 | if n, err := strconv.Atoi(parts[i+1]); err == nil { 506 | numLines = n 507 | i++ 508 | } 509 | } 510 | case "-f": 511 | follow = true 512 | } 513 | } 514 | 515 | if follow { 516 | return "tail -f not supported in web terminal (requires WebSocket)\n", currentDir 517 | } 518 | 519 | // Check file exists 520 | if _, err := os.Stat(filePath); err != nil { 521 | return fmt.Sprintf("Error: file '%s' does not exist\n", fileName), currentDir 522 | } 523 | 524 | // Read file 525 | content, err := os.ReadFile(filePath) 526 | if err != nil { 527 | return fmt.Sprintf("Error reading file: %s\n", err), currentDir 528 | } 529 | 530 | // Split into lines and get last N lines 531 | lines := strings.Split(string(content), "\n") 532 | start := len(lines) - numLines 533 | if start < 0 { 534 | start = 0 535 | } 536 | 537 | return strings.Join(lines[start:], "\n"), currentDir 538 | case "rmdir": 539 | if len(parts) < 2 { 540 | return "Error: missing directory name\n", currentDir 541 | } 542 | dirName := parts[1] 543 | dirPath := filepath.Join(currentDir, dirName) 544 | if _, err := os.Stat(dirPath); err != nil { 545 | return fmt.Sprintf("Error: directory '%s' does not exist\n", dirName), currentDir 546 | } 547 | if err := os.RemoveAll(dirPath); err != nil { 548 | return fmt.Sprintf("Error removing directory: %s\n", err), currentDir 549 | } 550 | return fmt.Sprintf("Directory '%s' removed\n", dirName), currentDir 551 | case "rm": 552 | if len(parts) < 2 { 553 | return "Error: missing file name\n", currentDir 554 | } 555 | fileName := parts[1] 556 | filePath := filepath.Join(currentDir, fileName) 557 | if _, err := os.Stat(filePath); err != nil { 558 | return fmt.Sprintf("Error: file '%s' does not exist\n", fileName), currentDir 559 | } 560 | if err := os.Remove(filePath); err != nil { 561 | return fmt.Sprintf("Error removing file: %s\n", err), currentDir 562 | } 563 | return fmt.Sprintf("File '%s' removed\n", fileName), currentDir 564 | case "cp": 565 | if len(parts) < 3 { 566 | return "Error: missing source and destination file names\n", currentDir 567 | } 568 | sourceFileName := parts[1] 569 | destinationFileName := parts[2] 570 | sourceFilePath := filepath.Join(currentDir, sourceFileName) 571 | destinationFilePath := filepath.Join(currentDir, destinationFileName) 572 | if stat, err := os.Stat(sourceFilePath); err != nil { 573 | return fmt.Sprintf("Error: file '%s' does not exist\n", sourceFileName), currentDir 574 | } else { 575 | if stat.IsDir() { 576 | if err := copyDir(sourceFilePath, destinationFilePath); err != nil { 577 | return fmt.Sprintf("Error copying directory: %s\n", err), currentDir 578 | } 579 | return fmt.Sprintf("Directory '%s' copied to '%s'\n", sourceFileName, destinationFileName), currentDir 580 | } 581 | } 582 | if err := copyFile(sourceFilePath, destinationFilePath); err != nil { 583 | return fmt.Sprintf("Error copying file: %s\n", err), currentDir 584 | } 585 | return fmt.Sprintf("File '%s' copied to '%s'\n", sourceFileName, destinationFileName), currentDir 586 | case "mv": 587 | if len(parts) < 3 { 588 | return "Error: missing source and destination file names\n", currentDir 589 | } 590 | sourceFileName := parts[1] 591 | destinationFileName := parts[2] 592 | sourceFilePath := filepath.Join(currentDir, sourceFileName) 593 | destinationFilePath := filepath.Join(currentDir, destinationFileName) 594 | if _, err := os.Stat(sourceFilePath); err != nil { 595 | return fmt.Sprintf("Error: file '%s' does not exist\n", sourceFileName), currentDir 596 | } 597 | if err := os.Rename(sourceFilePath, destinationFilePath); err != nil { 598 | return fmt.Sprintf("Error renaming file: %s\n", err), currentDir 599 | } 600 | return fmt.Sprintf("File '%s' renamed to '%s'\n", sourceFileName, destinationFileName), currentDir 601 | case "cat": 602 | if len(parts) < 2 { 603 | return "Error: missing file name\n", currentDir 604 | } 605 | fileName := parts[1] 606 | filePath := filepath.Join(currentDir, fileName) 607 | if _, err := os.Stat(filePath); err != nil { 608 | return fmt.Sprintf("Error: file '%s' does not exist\n", fileName), currentDir 609 | } 610 | content, err := os.ReadFile(filePath) 611 | if err != nil { 612 | return fmt.Sprintf("Error reading file: %s\n", err), currentDir 613 | } 614 | return string(content), currentDir 615 | case "echo": 616 | if len(parts) < 2 { 617 | return "Error: missing text to echo\n", currentDir 618 | } 619 | text := strings.Join(parts[1:], " ") 620 | return text + "\n", currentDir 621 | case "exit": 622 | return "EXIT", currentDir 623 | } 624 | 625 | // Rest of shell commands 626 | var cmd *exec.Cmd 627 | if runtime.GOOS == "windows" { 628 | cmd = exec.Command("cmd", "/c", command) 629 | } else { 630 | cmd = exec.Command("/bin/sh", "-c", command) 631 | } 632 | 633 | cmd.Dir = currentDir 634 | out, err := cmd.CombinedOutput() 635 | if err != nil { 636 | return fmt.Sprintf("Error: %s\n%s", err, string(out)), currentDir 637 | } 638 | return string(out), currentDir 639 | } 640 | 641 | func getAllowedCommands() []string { 642 | cmds := make([]string, 0, len(terminalAllowedCommands)) 643 | for cmd := range terminalAllowedCommands { 644 | cmds = append(cmds, cmd) 645 | } 646 | sort.Strings(cmds) 647 | return cmds 648 | } 649 | 650 | var TerminalComplete = func(c *ksmux.Context) { 651 | input := c.Request.URL.Query().Get("input") 652 | session := c.Request.URL.Query().Get("session") 653 | 654 | currentDir, _ := termsessions.Get(session) 655 | if currentDir == "" { 656 | currentDir, _ = os.Getwd() 657 | } 658 | 659 | // Get the last word of the input (after any spaces) 660 | parts := strings.Fields(input) 661 | if len(parts) == 0 { 662 | c.Json(map[string]any{"suggestions": []string{}}) 663 | return 664 | } 665 | 666 | lastWord := parts[len(parts)-1] 667 | targetDir := currentDir 668 | 669 | // Handle path completion 670 | if strings.Contains(lastWord, "/") { 671 | // Split the path into parts 672 | pathParts := strings.Split(lastWord, "/") 673 | // The last part is what we're trying to complete 674 | searchPattern := pathParts[len(pathParts)-1] 675 | // Everything before the last part is the directory to search in 676 | searchDir := strings.Join(pathParts[:len(pathParts)-1], "/") 677 | 678 | // Resolve the full path of the directory to search 679 | if filepath.IsAbs(searchDir) { 680 | targetDir = searchDir 681 | } else { 682 | targetDir = filepath.Join(currentDir, searchDir) 683 | } 684 | 685 | // Get all files in the target directory 686 | files, err := os.ReadDir(targetDir) 687 | if err != nil { 688 | lg.Error("Error reading directory:", err) 689 | c.Json(map[string]any{"suggestions": []string{}}) 690 | return 691 | } 692 | 693 | // Find matches and build full paths 694 | suggestions := []string{} 695 | for _, file := range files { 696 | name := file.Name() 697 | if strings.HasPrefix(strings.ToLower(name), strings.ToLower(searchPattern)) { 698 | if file.IsDir() { 699 | name += "/" 700 | } 701 | // Reconstruct the full path suggestion 702 | suggestion := strings.Join([]string{searchDir, name}, "/") 703 | suggestions = append(suggestions, suggestion) 704 | } 705 | } 706 | 707 | c.Json(map[string]any{"suggestions": suggestions}) 708 | return 709 | } 710 | 711 | // Handle non-path completion (first level) 712 | files, err := os.ReadDir(targetDir) 713 | if err != nil { 714 | lg.Error("Error reading directory:", err) 715 | c.Json(map[string]any{"suggestions": []string{}}) 716 | return 717 | } 718 | 719 | suggestions := []string{} 720 | for _, file := range files { 721 | name := file.Name() 722 | if strings.HasPrefix(strings.ToLower(name), strings.ToLower(lastWord)) { 723 | if file.IsDir() { 724 | name += "/" 725 | } 726 | suggestions = append(suggestions, name) 727 | } 728 | } 729 | 730 | c.Json(map[string]any{"suggestions": suggestions}) 731 | } 732 | 733 | func copyFile(src, dst string) error { 734 | sourceFile, err := os.Open(src) 735 | if err != nil { 736 | return err 737 | } 738 | defer sourceFile.Close() 739 | 740 | destFile, err := os.Create(dst) 741 | if err != nil { 742 | return err 743 | } 744 | defer destFile.Close() 745 | 746 | _, err = io.Copy(destFile, sourceFile) 747 | return err 748 | } 749 | 750 | func copyDir(src, dst string) error { 751 | err := os.MkdirAll(dst, os.ModePerm) 752 | if err != nil { 753 | return err 754 | } 755 | 756 | entries, err := os.ReadDir(src) 757 | if err != nil { 758 | return err 759 | } 760 | 761 | for _, entry := range entries { 762 | srcPath := filepath.Join(src, entry.Name()) 763 | dstPath := filepath.Join(dst, entry.Name()) 764 | 765 | if entry.IsDir() { 766 | if err = copyDir(srcPath, dstPath); err != nil { 767 | return err 768 | } 769 | } else { 770 | if err = copyFile(srcPath, dstPath); err != nil { 771 | return err 772 | } 773 | } 774 | } 775 | return nil 776 | } 777 | -------------------------------------------------------------------------------- /docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kamalshkeir/korm/7953aea3598d000badc4e08fbb4035b20e8c4800/docs.png -------------------------------------------------------------------------------- /examples/basic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type TestTable struct { 8 | Id int `korm:"pk"` 9 | Name string 10 | } 11 | 12 | type Another struct { 13 | Id int `korm:"pk"` 14 | Name string 15 | UserID uint `korm:"fk:users.id:cascade:cascade"` 16 | Content string `korm:"text"` 17 | IsAdmin bool 18 | CreatedAt time.Time `korm:"now"` 19 | UpdatedAt time.Time `korm:"update"` 20 | } 21 | 22 | // func main() { 23 | // aes.SetSecret("blablabla") 24 | // if err := korm.New(korm.SQLITE, "db", sqlitedriver.Use()); lg.CheckError(err) { 25 | // return 26 | // } 27 | // defer korm.Shutdown() 28 | 29 | // err := korm.AutoMigrate[TestTable]("test_table") 30 | // lg.CheckError(err) 31 | // err = korm.AutoMigrate[Another]("another") 32 | // lg.CheckError(err) 33 | // srv := korm.WithDashboard(":9313", korm.DashOpts{ 34 | // WithTracing: true, 35 | // }) 36 | // korm.WithShell() 37 | 38 | // srv.App.Get("/", func(c *ksmux.Context) { 39 | // c.Json(map[string]any{ 40 | // "users": "ok", 41 | // }) 42 | // }) 43 | // srv.App.Get("/test", func(c *ksmux.Context) { 44 | // sp, ctx := ksmux.StartSpan(c.Request.Context(), "Test Handler") 45 | // defer sp.End() 46 | // sp.SetTag("bla", "value1") 47 | // users, err := korm.Model[korm.User]().Trace().Where("id > ?", 0).All() 48 | // if lg.CheckError(err) { 49 | // c.SetStatus(500) 50 | // return 51 | // } 52 | // doWork(ctx, 1) 53 | // doWork(ctx, 2) 54 | // c.Json(map[string]any{ 55 | // "users": users, 56 | // }) 57 | // }) 58 | 59 | // srv.Run() 60 | // } 61 | 62 | // func doWork(ctx context.Context, i int) { 63 | // sp, spCtx := ksmux.StartSpan(ctx, "doWork-"+strconv.Itoa(i)) 64 | // defer sp.End() 65 | // sp.SetTag("bla", "value1") 66 | // if i == 1 { 67 | // doSubWork(spCtx, i) 68 | // } 69 | // time.Sleep(time.Duration(rand.IntN(2)) * time.Second) 70 | // } 71 | 72 | // func doSubWork(ctx context.Context, i int) { 73 | // sp, _ := ksmux.StartSpan(ctx, "doSubWork-"+strconv.Itoa(i)) 74 | // defer sp.End() 75 | // sp.SetTag("bla", "value1") 76 | // time.Sleep(time.Duration(rand.IntN(2)) * time.Second) 77 | // } 78 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kamalshkeir/korm 2 | 3 | go 1.24.3 4 | 5 | require ( 6 | github.com/kamalshkeir/aes v1.1.2 7 | github.com/kamalshkeir/argon v1.0.1 8 | github.com/kamalshkeir/kactor v0.1.0 9 | github.com/kamalshkeir/kinput v0.1.0 10 | github.com/kamalshkeir/kmap v1.1.8 11 | github.com/kamalshkeir/ksmux v0.7.4 12 | github.com/kamalshkeir/kstrct v1.9.22 13 | github.com/kamalshkeir/lg v0.1.4 14 | ) 15 | 16 | require ( 17 | golang.org/x/crypto v0.38.0 // indirect 18 | golang.org/x/net v0.40.0 // indirect 19 | golang.org/x/sys v0.33.0 // indirect 20 | golang.org/x/term v0.32.0 // indirect 21 | golang.org/x/text v0.25.0 // indirect 22 | golang.org/x/time v0.11.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/kamalshkeir/aes v1.1.2 h1:abtIh8VI4776N2ZW1Zni3Ax5qIg4Xn+VjYUIAmm9dlo= 2 | github.com/kamalshkeir/aes v1.1.2/go.mod h1:EgK5oLi56UJEDirxDS+wJD+u9Wk7cqTUZ94e2jTzP1U= 3 | github.com/kamalshkeir/argon v1.0.1 h1:8KET6+qoytHVSIg47N8Wefy0PWdUszyIxe9S748zsIU= 4 | github.com/kamalshkeir/argon v1.0.1/go.mod h1:1yzi4VtpOY6S10rfO5gZ19iachH9CSO2THcu4HIsJHQ= 5 | github.com/kamalshkeir/kactor v0.1.0 h1:Gmo22+T0/yysFY6V7AUxrq4LkxqmljcEieDoZtHKpI4= 6 | github.com/kamalshkeir/kactor v0.1.0/go.mod h1:buR4dE9UkqwLdmkou4QZVEQEvmnsz99eKFLJ+We7Rxg= 7 | github.com/kamalshkeir/kinput v0.1.0 h1:mSGQoEE3lpxRN2azpXR2PulWHMNEvJeQVAXbqDfL0uw= 8 | github.com/kamalshkeir/kinput v0.1.0/go.mod h1:8eh2u/btMpxfx8w+7XoG/FIrtNr0jG0I0SL/ENTh6uQ= 9 | github.com/kamalshkeir/kmap v1.1.8 h1:lZqyL9f717ZzBkkI74bNuCjfnWdCLbFZmakzLBQG3A4= 10 | github.com/kamalshkeir/kmap v1.1.8/go.mod h1:SLSllMqrhSTJtgYd14nXeFZ/shp9AY4t20YGCtlcziI= 11 | github.com/kamalshkeir/ksmux v0.7.4 h1:34FhdDtitd94mUQzhr+/lQlDj65dD51Z/e3H/6A7g1M= 12 | github.com/kamalshkeir/ksmux v0.7.4/go.mod h1:lGkcauU3ct/Q1Ox4MmfW0psSX4yAlIZS0stCp6IJ3jc= 13 | github.com/kamalshkeir/kstrct v1.9.22 h1:WaUdUW9XzZ2MKwZAxRite4bwvfhLLp7h8L9RABfNHCY= 14 | github.com/kamalshkeir/kstrct v1.9.22/go.mod h1:QdCq0t1MZsJb75R1cSxKqyVa6LCGCiQpS+kG8S0pPC8= 15 | github.com/kamalshkeir/lg v0.1.4 h1:HIL4ry0EMEYTyzX7lPPm2FZcEBBIf7Y2q6IiAL4T7qA= 16 | github.com/kamalshkeir/lg v0.1.4/go.mod h1:Ub/kxOdgleTDhDBXtFXXxO/XOHR/zt+6pvTIJNtuhew= 17 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 18 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 19 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 20 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 21 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 22 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 23 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 24 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 25 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 26 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 27 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 28 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 29 | -------------------------------------------------------------------------------- /korm.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "database/sql/driver" 7 | "embed" 8 | "errors" 9 | "fmt" 10 | "io/fs" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "strings" 15 | "sync" 16 | 17 | "github.com/kamalshkeir/kactor" 18 | "github.com/kamalshkeir/kmap" 19 | "github.com/kamalshkeir/ksmux" 20 | "github.com/kamalshkeir/kstrct" 21 | "github.com/kamalshkeir/lg" 22 | ) 23 | 24 | var ( 25 | // defaultDB keep tracking of the first database connected 26 | defaultDB = "" 27 | useCache = true 28 | cacheMaxMemoryMb = 100 29 | databases = []DatabaseEntity{} 30 | mutexModelTablename sync.RWMutex 31 | mModelTablename = map[string]any{} 32 | cacheAllTables = kmap.New[string, []string]() 33 | cacheAllCols = kmap.New[string, map[string]string]() 34 | cacheAllColsOrdered = kmap.New[string, []string]() 35 | relationsMap = kmap.New[string, struct{}]() 36 | serverBus *kactor.BusServer 37 | cacheQ = kmap.New[string, any](cacheMaxMemoryMb) 38 | ErrTableNotFound = errors.New("unable to find tableName") 39 | ErrBigData = kmap.ErrLargeData 40 | logQueries = false 41 | terminalAllowedCommands = map[string]bool{ 42 | "ls": true, 43 | "pwd": true, 44 | "cd": true, 45 | "tail": true, 46 | "cat": true, 47 | "echo": true, 48 | "exit": true, 49 | "clear": true, 50 | "cls": true, 51 | } 52 | ) 53 | 54 | // New the generic way to connect to all handled databases 55 | // 56 | // Example: 57 | // korm.New(korm.SQLITE, "db", sqlitedriver.Use()) 58 | // korm.New(korm.MYSQL,"dbName", mysqldriver.Use(), "user:password@localhost:3333") 59 | // korm.New(korm.POSTGRES,"dbName", pgdriver.Use(), "user:password@localhost:5432") 60 | func New(dbType Dialect, dbName string, dbDriver driver.Driver, dbDSN ...string) error { 61 | var dsn string 62 | if dbDriver == nil { 63 | err := fmt.Errorf("New expect a dbDriver, you can use sqlitedriver.Use that return a driver.Driver") 64 | lg.ErrorC(err.Error()) 65 | return err 66 | } 67 | if defaultDB == "" { 68 | defaultDB = dbName 69 | } 70 | options := "" 71 | if len(dbDSN) > 0 { 72 | if strings.Contains(dbDSN[0], "?") { 73 | sp := strings.Split(dbDSN[0], "?") 74 | dbDSN[0] = sp[0] 75 | options = sp[1] 76 | } 77 | } 78 | switch dbType { 79 | case POSTGRES, COCKROACH: 80 | dbType = POSTGRES 81 | if len(dbDSN) == 0 { 82 | return errors.New("dbDSN for mysql cannot be empty") 83 | } 84 | dsn = "postgres://" + dbDSN[0] + "/" + dbName 85 | if options != "" { 86 | dsn += "?" + options 87 | } else { 88 | dsn += "?sslmode=disable" 89 | } 90 | case MYSQL, MARIA: 91 | dbType = MYSQL 92 | if len(dbDSN) == 0 { 93 | return errors.New("dbDSN for mysql cannot be empty") 94 | } 95 | if strings.Contains(dbDSN[0], "tcp(") { 96 | dsn = dbDSN[0] + "/" + dbName 97 | } else { 98 | split := strings.Split(dbDSN[0], "@") 99 | if len(split) > 2 { 100 | return errors.New("there is 2 or more @ symbol in dsn") 101 | } 102 | dsn = split[0] + "@" + "tcp(" + split[1] + ")/" + dbName 103 | } 104 | if options != "" { 105 | dsn += "?" + options 106 | } 107 | case SQLITE: 108 | if dsn == "" { 109 | dsn = "db.sqlite3" 110 | } 111 | if !strings.Contains(dbName, SQLITE) { 112 | dsn = dbName + ".sqlite3" 113 | } else { 114 | dsn = dbName 115 | } 116 | if options != "" { 117 | dsn += "?" + options 118 | } 119 | default: 120 | dbType = "sqlite3" 121 | lg.ErrorC("not handled, choices are: postgres,mysql,sqlite3,maria,cockroach", "dbType", dbType) 122 | dsn = dbName + ".sqlite3" 123 | if dsn == "" { 124 | dsn = "db.sqlite3" 125 | } 126 | if options != "" { 127 | dsn += "?" + options 128 | } 129 | } 130 | 131 | cstm := GenerateUUID() 132 | if useCache { 133 | sql.Register(cstm, Wrap(dbDriver, &logAndCacheHook{})) 134 | } else { 135 | sql.Register(cstm, dbDriver) 136 | } 137 | 138 | conn, err := sql.Open(cstm, dsn) 139 | if lg.CheckError(err) { 140 | return err 141 | } 142 | err = conn.Ping() 143 | if lg.CheckError(err) { 144 | lg.ErrorC("make sure env is loaded", "dsn", dsn) 145 | return err 146 | } 147 | if dbType == SQLITE { 148 | // add foreign key support 149 | query := `PRAGMA foreign_keys = ON;` 150 | _, err := conn.Exec(query) 151 | if err != nil { 152 | lg.ErrorC("failed to enable foreign keys", "err", err) 153 | } 154 | } 155 | if dbType == SQLITE { 156 | conn.SetMaxOpenConns(1) 157 | } else { 158 | conn.SetMaxOpenConns(MaxOpenConns) 159 | } 160 | dbFound := false 161 | for _, dbb := range databases { 162 | if dbb.Name == dbName { 163 | dbFound = true 164 | } 165 | } 166 | 167 | conn.SetMaxIdleConns(MaxIdleConns) 168 | conn.SetConnMaxLifetime(MaxLifetime) 169 | conn.SetConnMaxIdleTime(MaxIdleTime) 170 | 171 | if !dbFound { 172 | databases = append(databases, DatabaseEntity{ 173 | Name: dbName, 174 | Conn: conn, 175 | Dialect: dbType, 176 | Tables: []TableEntity{}, 177 | }) 178 | } 179 | err = AutoMigrate[TablesInfos]("_tables_infos") 180 | lg.CheckError(err) 181 | err = AutoMigrate[TriggersQueue]("_triggers_queue") 182 | lg.CheckError(err) 183 | 184 | initCacheHooks() 185 | return nil 186 | } 187 | 188 | // WithShell enable shell, go run main.go shell 189 | func WithShell() { 190 | runned := InitShell() 191 | if runned { 192 | os.Exit(0) 193 | } 194 | } 195 | 196 | // ManyToMany create m2m_table1_table2 many 2 many table 197 | func ManyToMany(table1, table2 string, dbName ...string) error { 198 | var err error 199 | mdbName := databases[0].Name 200 | if len(dbName) > 0 { 201 | mdbName = dbName[0] 202 | } 203 | dben, err := GetMemoryDatabase(mdbName) 204 | if err != nil { 205 | return fmt.Errorf("database not found:%v", err) 206 | } 207 | 208 | fkeys := []string{} 209 | autoinc := "" 210 | 211 | defer func() { 212 | relationsMap.Set("m2m_"+table1+"-"+mdbName+"-"+table2, struct{}{}) 213 | }() 214 | 215 | if _, ok := relationsMap.Get("m2m_" + table1 + "-" + mdbName + "-" + table2); ok { 216 | return nil 217 | } 218 | 219 | tables := GetAllTables(mdbName) 220 | if len(tables) == 0 { 221 | return fmt.Errorf("databse is empty: %v", tables) 222 | } 223 | for _, t := range tables { 224 | if t == table1+"_"+table2 || t == table2+"_"+table1 { 225 | return nil 226 | } 227 | } 228 | switch dben.Dialect { 229 | case SQLITE, "": 230 | autoinc = "INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT" 231 | case POSTGRES, COCKROACH: 232 | autoinc = "SERIAL NOT NULL PRIMARY KEY" 233 | case MYSQL, MARIA: 234 | autoinc = "INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT" 235 | default: 236 | lg.ErrorC("allowed dialects: dialect can be sqlite3, postgres, cockroach or mysql,maria only") 237 | } 238 | 239 | fkeys = append(fkeys, foreignkeyStat(table1+"_id", table1, "cascade", "cascade")) 240 | fkeys = append(fkeys, foreignkeyStat(table2+"_id", table2, "cascade", "cascade")) 241 | st := prepareCreateStatement( 242 | "m2m_"+table1+"_"+table2, 243 | map[string]string{ 244 | "id": autoinc, 245 | table1 + "_id": "INTEGER", 246 | table2 + "_id": "INTEGER", 247 | }, 248 | fkeys, 249 | []string{"id", table1 + "_id", table2 + "_id"}, 250 | dben.Dialect, 251 | ) 252 | if Debug { 253 | lg.Printfs("yl%s\n", st) 254 | } 255 | err = Exec(dben.Name, st) 256 | if err != nil { 257 | return err 258 | } 259 | dben.Tables = append(dben.Tables, TableEntity{ 260 | Types: map[string]string{ 261 | "id": "uint", 262 | table1 + "_id": "uint", 263 | table2 + "_id": "uint", 264 | }, 265 | Columns: []string{"id", table1 + "_id", table2 + "_id"}, 266 | Name: "m2m_" + table1 + "_" + table2, 267 | Pk: "id", 268 | }) 269 | return nil 270 | } 271 | 272 | // WithBus return ksbus.NewServer() that can be Run, RunTLS, RunAutoTLS 273 | func WithBus(config ...ksmux.Config) *kactor.BusServer { 274 | if serverBus == nil { 275 | serverBus = kactor.NewBusServer(config...) 276 | if strings.HasPrefix(serverBus.App().Address(), ":") { 277 | serverBus.App().Config.Address = "localhost" + serverBus.App().Address() 278 | } 279 | } else { 280 | lg.DebugC("another bus already registered, returning it") 281 | } 282 | return serverBus 283 | } 284 | 285 | type DashOpts struct { 286 | ServerOpts *ksmux.Config 287 | EmbededStatic embed.FS 288 | EmbededTemplates embed.FS 289 | PaginatePer int // default 10 290 | DocsUrl string // default docs 291 | MediaDir string // default media 292 | BaseDir string // default assets 293 | StaticDir string // default BaseDir/static 294 | TemplatesDir string // default BaseDir/templates 295 | Path string // default /admin 296 | RepoUser string // default kamalshkeir 297 | RepoName string // default korm-dash 298 | WithTracing bool // add tracing handling page in dash and enable tracing 299 | WithTerminal bool // add terminal session handling page in dash 300 | WithNodeManager bool // add node manager handling page in dash 301 | WithRequestCounter bool // add request counter dashboard,default false 302 | } 303 | 304 | // WithDashboard enable admin dashboard 305 | func WithDashboard(addr string, options ...DashOpts) *kactor.BusServer { 306 | dahsboardUsed = true 307 | var opts *DashOpts 308 | staticAndTemplatesEmbeded := []embed.FS{} 309 | if len(options) > 0 { 310 | opts = &options[0] 311 | embededDashboard = opts.EmbededStatic != embed.FS{} || opts.EmbededTemplates != embed.FS{} 312 | if embededDashboard { 313 | staticAndTemplatesEmbeded = append(staticAndTemplatesEmbeded, opts.EmbededStatic, opts.EmbededTemplates) 314 | } 315 | if opts.PaginatePer > 0 { 316 | paginationPer = opts.PaginatePer 317 | } 318 | if opts.DocsUrl != "" { 319 | docsUrl = opts.DocsUrl 320 | } 321 | if opts.MediaDir != "" { 322 | mediaDir = opts.MediaDir 323 | } 324 | if opts.BaseDir != "" { 325 | assetsDir = opts.BaseDir 326 | } 327 | if opts.StaticDir != "" { 328 | staticDir = opts.StaticDir 329 | } 330 | if opts.TemplatesDir != "" { 331 | templatesDir = opts.TemplatesDir 332 | } 333 | if opts.RepoName != "" { 334 | repoName = opts.RepoName 335 | } 336 | if opts.RepoUser != "" { 337 | repoUser = opts.RepoUser 338 | } 339 | if opts.Path != "" { 340 | if !strings.HasPrefix(opts.Path, "/") { 341 | opts.Path = "/" + opts.Path 342 | } 343 | adminPathNameGroup = opts.Path 344 | } 345 | if serverBus == nil { 346 | if opts.ServerOpts != nil { 347 | if addr != "" && opts.ServerOpts.Address != addr { 348 | if strings.HasPrefix(addr, ":") { 349 | addr = "localhost" + addr 350 | } 351 | opts.ServerOpts.Address = addr 352 | } else if addr == "" && opts.ServerOpts.Address == "" { 353 | lg.InfoC("no address specified, using :9313") 354 | } 355 | } else { 356 | if strings.HasPrefix(addr, ":") { 357 | addr = "localhost" + addr 358 | } 359 | if addr != "" { 360 | opts.ServerOpts = &ksmux.Config{ 361 | Address: addr, 362 | } 363 | } else { 364 | lg.InfoC("no address specified, using :9313") 365 | } 366 | } 367 | } 368 | } else if addr != "" { 369 | opts = &DashOpts{ 370 | ServerOpts: &ksmux.Config{ 371 | Address: addr, 372 | }, 373 | } 374 | } 375 | 376 | if serverBus == nil { 377 | if opts != nil { 378 | serverBus = WithBus(*opts.ServerOpts) 379 | } else { 380 | serverBus = WithBus() 381 | } 382 | } 383 | cloneAndMigrateDashboard(true, staticAndTemplatesEmbeded...) 384 | 385 | reqqCounter := false 386 | if opts != nil && opts.WithRequestCounter { 387 | reqqCounter = opts.WithRequestCounter 388 | } 389 | if opts != nil && opts.WithTracing { 390 | // Enable db tracing 391 | WithTracing() 392 | } 393 | if opts != nil && opts.WithTerminal { 394 | terminalUIEnabled = true 395 | } 396 | if opts != nil && opts.WithNodeManager && nodeManager == nil { 397 | WithNodeManager() 398 | } 399 | initAdminUrlPatterns(reqqCounter, serverBus.App()) 400 | if len(os.Args) == 1 { 401 | const razor = ` 402 | __ 403 | .'| .'| .'|=|'. .'|=| | .'|\/|'. 404 | .' | .' .' .' | | '. .' | | | .' | | '. 405 | | |=|.: | | | | | |=|.' | | | | 406 | | | |'. '. | | .' | | |'. | | | | 407 | |___| |_| '.|=|.' |___| |_| |___| |___| 408 | ` 409 | lg.Printfs("yl%s\n", razor) 410 | } 411 | return serverBus 412 | } 413 | 414 | // WithDocs enable swagger docs at DocsUrl default to '/docs/' 415 | func WithDocs(generateJsonDocs bool, outJsonDocs string, handlerMiddlewares ...func(handler ksmux.Handler) ksmux.Handler) *kactor.BusServer { 416 | if serverBus == nil { 417 | lg.DebugC("using default bus :9313") 418 | serverBus = WithBus() 419 | } 420 | 421 | if outJsonDocs != "" { 422 | ksmux.DocsOutJson = outJsonDocs 423 | } else { 424 | ksmux.DocsOutJson = staticDir + "/docs" 425 | } 426 | 427 | // check swag install and init docs.Routes slice 428 | serverBus.App().WithDocs(generateJsonDocs) 429 | webPath := docsUrl 430 | if webPath[0] != '/' { 431 | webPath = "/" + webPath 432 | } 433 | webPath = strings.TrimSuffix(webPath, "/") 434 | handler := func(c *ksmux.Context) { 435 | http.StripPrefix(webPath, http.FileServer(http.Dir(ksmux.DocsOutJson))).ServeHTTP(c.ResponseWriter, c.Request) 436 | } 437 | if len(handlerMiddlewares) > 0 { 438 | for _, mid := range handlerMiddlewares { 439 | handler = mid(handler) 440 | } 441 | } 442 | serverBus.App().Get(webPath+"/*path", handler) 443 | return serverBus 444 | } 445 | 446 | // WithEmbededDocs same as WithDocs but embeded, enable swagger docs at DocsUrl default to '/docs/' 447 | func WithEmbededDocs(embeded embed.FS, embededDirPath string, handlerMiddlewares ...func(handler ksmux.Handler) ksmux.Handler) *kactor.BusServer { 448 | if serverBus == nil { 449 | lg.DebugC("using default bus :9313") 450 | serverBus = WithBus() 451 | } 452 | if embededDirPath != "" { 453 | ksmux.DocsOutJson = embededDirPath 454 | } else { 455 | ksmux.DocsOutJson = staticDir + "/docs" 456 | } 457 | webPath := docsUrl 458 | 459 | ksmux.DocsOutJson = filepath.ToSlash(ksmux.DocsOutJson) 460 | if webPath[0] != '/' { 461 | webPath = "/" + webPath 462 | } 463 | webPath = strings.TrimSuffix(webPath, "/") 464 | toembed_dir, err := fs.Sub(embeded, ksmux.DocsOutJson) 465 | if err != nil { 466 | lg.ErrorC("rdServeEmbededDir error", "err", err) 467 | return serverBus 468 | } 469 | toembed_root := http.FileServer(http.FS(toembed_dir)) 470 | handler := func(c *ksmux.Context) { 471 | http.StripPrefix(webPath, toembed_root).ServeHTTP(c.ResponseWriter, c.Request) 472 | } 473 | if len(handlerMiddlewares) > 0 { 474 | for _, mid := range handlerMiddlewares { 475 | handler = mid(handler) 476 | } 477 | } 478 | serverBus.App().Get(webPath+"/*path", handler) 479 | return serverBus 480 | } 481 | 482 | // WithMetrics enable path /metrics (default), it take http.Handler like promhttp.Handler() 483 | func WithMetrics(httpHandler http.Handler) *kactor.BusServer { 484 | if serverBus == nil { 485 | lg.DebugC("using default bus :9313") 486 | serverBus = WithBus() 487 | serverBus.WithMetrics(httpHandler) 488 | return serverBus 489 | } 490 | serverBus.WithMetrics(httpHandler) 491 | return serverBus 492 | } 493 | 494 | // WithPprof enable std library pprof at /debug/pprof, prefix default to 'debug' 495 | func WithPprof(path ...string) *kactor.BusServer { 496 | if serverBus == nil { 497 | lg.DebugC("using default bus :9313") 498 | serverBus = WithBus() 499 | serverBus.WithPprof(path...) 500 | return serverBus 501 | } 502 | serverBus.WithPprof(path...) 503 | return serverBus 504 | } 505 | 506 | // Transaction create new database/sql transaction and return it, it can be rollback ... 507 | func Transaction(dbName ...string) (*sql.Tx, error) { 508 | return GetConnection(dbName...).Begin() 509 | } 510 | 511 | // FlushCache send msg to the cache system to Flush all the cache, safe to use in concurrent mode, and safe to use in general, flushed every (korm.FlushCacheEvery) 512 | func FlushCache(tables ...string) { 513 | flushCache() 514 | } 515 | 516 | // DisableCache disable the cache system, if and only if you are having problem with it, also you can korm.FlushCache on command too 517 | func DisableCache() { 518 | useCache = false 519 | } 520 | 521 | // GetConnection get connection of dbName, if not specified , it return default, first database connected 522 | func GetConnection(dbName ...string) *sql.DB { 523 | var name string 524 | var db *DatabaseEntity 525 | if len(dbName) > 0 { 526 | var err error 527 | db, err = GetMemoryDatabase(dbName[0]) 528 | if lg.CheckError(err) { 529 | return nil 530 | } 531 | } else { 532 | name = databases[0].Name 533 | db = &databases[0] 534 | } 535 | 536 | if db.Conn == nil { 537 | lg.ErrorC("memory db have no connection", "name", name) 538 | } 539 | return db.Conn 540 | } 541 | 542 | // GetAllTables get all tables for the optional dbName given, otherwise, if not args, it will return tables of the first connected database 543 | func GetAllTables(dbName ...string) []string { 544 | var name string 545 | if len(dbName) == 0 { 546 | name = databases[0].Name 547 | } else { 548 | name = dbName[0] 549 | } 550 | 551 | db, err := GetMemoryDatabase(name) 552 | if err != nil { 553 | return nil 554 | } 555 | if useCache { 556 | if v, ok := cacheAllTables.Get(name); ok { 557 | if len(v) == len(db.Tables) { 558 | return v 559 | } 560 | } 561 | } 562 | 563 | tables := []string{} 564 | 565 | switch db.Dialect { 566 | case POSTGRES: 567 | rows, err := db.Conn.Query(`select tablename FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema','crdb_internal','pg_extension') AND tableowner != 'node'`) 568 | if lg.CheckError(err) { 569 | return nil 570 | } 571 | defer rows.Close() 572 | for rows.Next() { 573 | var table string 574 | err := rows.Scan(&table) 575 | if lg.CheckError(err) { 576 | return nil 577 | } 578 | tables = append(tables, table) 579 | } 580 | case MYSQL, MARIA: 581 | rows, err := db.Conn.Query("SELECT table_name,table_schema FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND table_schema ='" + name + "'") 582 | if lg.CheckError(err) { 583 | return nil 584 | } 585 | defer rows.Close() 586 | for rows.Next() { 587 | var table string 588 | var table_schema string 589 | err := rows.Scan(&table, &table_schema) 590 | if lg.CheckError(err) { 591 | return nil 592 | } 593 | tables = append(tables, table) 594 | } 595 | case SQLITE, "": 596 | rows, err := db.Conn.Query(`select name FROM sqlite_master WHERE type ='table' AND name NOT LIKE 'sqlite_%';`) 597 | if lg.CheckError(err) { 598 | return nil 599 | } 600 | defer rows.Close() 601 | 602 | for rows.Next() { 603 | var table string 604 | err := rows.Scan(&table) 605 | if lg.CheckError(err) { 606 | return nil 607 | } 608 | tables = append(tables, table) 609 | } 610 | default: 611 | lg.ErrorC("database type not supported, should be sqlite3, postgres, cockroach, maria or mysql") 612 | return nil 613 | } 614 | 615 | for i := len(tables) - 1; i >= 0; i-- { 616 | t := tables[i] 617 | if strings.HasPrefix(t, "_") { 618 | tables = append(tables[:i], tables[i+1:]...) 619 | } 620 | } 621 | if useCache && len(tables) > 0 { 622 | cacheAllTables.Set(name, tables) 623 | } 624 | return tables 625 | } 626 | 627 | // GetAllColumnsTypes get columns and types from the database 628 | func GetAllColumnsTypes(table string, dbName ...string) (map[string]string, []string) { 629 | dName := databases[0].Name 630 | if len(dbName) > 0 { 631 | dName = dbName[0] 632 | } 633 | if useCache { 634 | if v, ok := cacheAllCols.Get(dName + table); ok { 635 | if vv, ok := cacheAllColsOrdered.Get(dName + table); ok { 636 | return v, vv 637 | } 638 | } 639 | } 640 | 641 | db, err := GetMemoryDatabase(dName) 642 | if err != nil { 643 | return nil, nil 644 | } 645 | 646 | var statement string 647 | colsSlice := []string{} 648 | columns := map[string]string{} 649 | switch db.Dialect { 650 | case POSTGRES: 651 | statement = "select column_name,data_type FROM information_schema.columns WHERE table_name = '" + table + "'" 652 | case MYSQL, MARIA: 653 | statement = "select column_name,data_type FROM information_schema.columns WHERE table_name = '" + table + "' AND TABLE_SCHEMA = '" + db.Name + "'" 654 | default: 655 | statement = "pragma table_info(" + table + ");" 656 | row, err := db.Conn.Query(statement) 657 | if lg.CheckError(err) { 658 | return nil, nil 659 | } 660 | defer row.Close() 661 | var num int 662 | var singleColName string 663 | var singleColType string 664 | var fake1 int 665 | var fake2 any 666 | var fake3 int 667 | for row.Next() { 668 | err := row.Scan(&num, &singleColName, &singleColType, &fake1, &fake2, &fake3) 669 | if lg.CheckError(err) { 670 | return nil, nil 671 | } 672 | columns[singleColName] = singleColType 673 | colsSlice = append(colsSlice, singleColName) 674 | } 675 | if useCache { 676 | cacheAllCols.Set(dName+table, columns) 677 | } 678 | return columns, colsSlice 679 | } 680 | 681 | row, err := db.Conn.Query(statement) 682 | 683 | if lg.CheckError(err) { 684 | return nil, nil 685 | } 686 | defer row.Close() 687 | var singleColName string 688 | var singleColType string 689 | for row.Next() { 690 | err := row.Scan(&singleColName, &singleColType) 691 | if lg.CheckError(err) { 692 | return nil, nil 693 | } 694 | columns[singleColName] = singleColType 695 | colsSlice = append(colsSlice, singleColName) 696 | } 697 | if useCache { 698 | cacheAllCols.Set(dName+table, columns) 699 | cacheAllColsOrdered.Set(dName+table, colsSlice) 700 | } 701 | return columns, colsSlice 702 | } 703 | 704 | // Shutdown shutdown many database 705 | func Shutdown(dbNames ...string) error { 706 | if len(dbNames) > 0 { 707 | for _, db := range databases { 708 | if SliceContains(dbNames, db.Name) { 709 | if err := db.Conn.Close(); err != nil { 710 | return err 711 | } 712 | } 713 | } 714 | return nil 715 | } else { 716 | for i := range databases { 717 | if err := databases[i].Conn.Close(); err != nil { 718 | return err 719 | } 720 | } 721 | return nil 722 | } 723 | } 724 | 725 | // Exec exec sql and return error if any 726 | func Exec(dbName, query string, args ...any) error { 727 | conn := GetConnection(dbName) 728 | if conn == nil { 729 | return errors.New("no connection found") 730 | } 731 | adaptTimeToUnixArgs(&args) 732 | _, err := conn.Exec(query, args...) 733 | if err != nil { 734 | return err 735 | } 736 | return nil 737 | } 738 | 739 | // ExecContext exec sql and return error if any 740 | func ExecContext(ctx context.Context, dbName, query string, args ...any) error { 741 | conn := GetConnection(dbName) 742 | if conn == nil { 743 | return errors.New("no connection found") 744 | } 745 | adaptTimeToUnixArgs(&args) 746 | _, err := conn.ExecContext(ctx, query, args...) 747 | if err != nil { 748 | return err 749 | } 750 | return nil 751 | } 752 | 753 | // ExecNamed exec named sql and return error if any 754 | func ExecNamed(query string, args map[string]any, dbName ...string) error { 755 | db := databases[0] 756 | if len(dbName) > 0 && dbName[0] != "" { 757 | dbb, err := GetMemoryDatabase(dbName[0]) 758 | if err != nil { 759 | return errors.New("no connection found") 760 | } 761 | db = *dbb 762 | } 763 | q, newargs, err := AdaptNamedParams(db.Dialect, query, args) 764 | if err != nil { 765 | return err 766 | } 767 | _, err = db.Conn.Exec(q, newargs...) 768 | if err != nil { 769 | return err 770 | } 771 | return nil 772 | } 773 | 774 | // ExecContextNamed exec named sql and return error if any 775 | func ExecContextNamed(ctx context.Context, query string, args map[string]any, dbName ...string) error { 776 | db := databases[0] 777 | if len(dbName) > 0 && dbName[0] != "" { 778 | dbb, err := GetMemoryDatabase(dbName[0]) 779 | if err != nil { 780 | return errors.New("no connection found") 781 | } 782 | db = *dbb 783 | } 784 | q, newargs, err := AdaptNamedParams(db.Dialect, query, args) 785 | if err != nil { 786 | return err 787 | } 788 | _, err = db.Conn.ExecContext(ctx, q, newargs...) 789 | if err != nil { 790 | return err 791 | } 792 | return nil 793 | } 794 | 795 | type KV kstrct.KV 796 | 797 | func getTableName[T any]() string { 798 | mutexModelTablename.RLock() 799 | defer mutexModelTablename.RUnlock() 800 | for k, v := range mModelTablename { 801 | if _, ok := v.(T); ok { 802 | return k 803 | } else if _, ok := v.(*T); ok { 804 | return k 805 | } 806 | } 807 | return "" 808 | } 809 | 810 | // LogQueries enable logging sql statements with time tooked 811 | func LogQueries() { 812 | logQueries = true 813 | } 814 | 815 | // AllowTerminalCommands adds commands to the allowed list for admin terminal 816 | func AllowTerminalCommands(commands ...string) { 817 | for _, cmd := range commands { 818 | terminalAllowedCommands[cmd] = true 819 | } 820 | } 821 | 822 | // DisallowTerminalCommands removes commands from the allowed list 823 | func DisallowTerminalCommands(commands ...string) { 824 | for _, cmd := range commands { 825 | delete(terminalAllowedCommands, cmd) 826 | } 827 | } 828 | -------------------------------------------------------------------------------- /korm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kamalshkeir/korm/7953aea3598d000badc4e08fbb4035b20e8c4800/korm.png -------------------------------------------------------------------------------- /korm_test.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | var DB_TEST_NAME = "test" 12 | 13 | func TestMain(m *testing.M) { 14 | // err := New(SQLITE, DB_TEST_NAME, sqlitedriver.Use()) 15 | // if err != nil { 16 | // log.Fatal(err) 17 | // } 18 | // // run tests 19 | // exitCode := m.Run() 20 | // // Cleanup for sqlite , remove file db 21 | // err = os.Remove(DB_TEST_NAME + ".sqlite3") 22 | // if err != nil { 23 | // log.Fatal(err) 24 | // } 25 | // os.Exit(exitCode) 26 | } 27 | 28 | type TestUser struct { 29 | Id *uint `korm:"pk"` 30 | Uuid string `korm:"size:40;iunique"` 31 | Email *string `korm:"size:100;iunique"` 32 | Gen string `korm:"size:250;generated: concat(uuid,'working',len(password))"` 33 | Password string 34 | Gen2 int `korm:"generated:len(password)*2"` 35 | IsAdmin *bool 36 | CreatedAt time.Time `korm:"now"` 37 | UpdatedAt time.Time `korm:"update"` 38 | } 39 | 40 | type Group struct { 41 | Id uint `korm:"pk"` 42 | Name string 43 | } 44 | 45 | type UserNotMigrated struct { 46 | Id uint `korm:"pk"` 47 | Uuid string `korm:"size:40;iunique"` 48 | Email string `korm:"size:100;iunique"` 49 | Password string 50 | IsAdmin bool 51 | CreatedAt time.Time `korm:"now"` 52 | UpdatedAt time.Time `korm:"update"` 53 | } 54 | 55 | func TestMigrate(t *testing.T) { 56 | err := AutoMigrate[TestUser]("users") 57 | if err != nil { 58 | t.Error(err) 59 | } 60 | err = AutoMigrate[Group]("groups") 61 | if err != nil { 62 | t.Error(err) 63 | } 64 | } 65 | 66 | func TestInsertNonMigrated(t *testing.T) { 67 | _, err := Model[UserNotMigrated]().Insert(&UserNotMigrated{ 68 | Uuid: GenerateUUID(), 69 | Email: "user-will-not-work@example.com", 70 | Password: "dqdqd", 71 | IsAdmin: true, 72 | }) 73 | if err == nil { 74 | t.Error("TestInsertNonMigrated did not error for not migrated model") 75 | } 76 | } 77 | 78 | func TestGetAllTables(t *testing.T) { 79 | tables := GetAllTables(DB_TEST_NAME) 80 | if len(tables) != 2 { 81 | t.Error("GetAllTables not working", tables) 82 | } 83 | } 84 | 85 | func TestManyToMany(t *testing.T) { 86 | err := ManyToMany("users", "groups") 87 | if err != nil { 88 | t.Error(err) 89 | } 90 | found := false 91 | for _, t := range GetAllTables() { 92 | if t == "m2m_users_groups" { 93 | found = true 94 | } 95 | } 96 | if !found { 97 | t.Error("m2m_users_groups has not been created:", GetAllTables()) 98 | } 99 | } 100 | 101 | func TestInsertUsersAndGroups(t *testing.T) { 102 | for i := 0; i < 10; i++ { 103 | iString := strconv.Itoa(i) 104 | email := "user-" + iString + "@example.com" 105 | admin := true 106 | _, err := Model[TestUser]().Insert(&TestUser{ 107 | Uuid: GenerateUUID(), 108 | Email: &email, 109 | Password: "dqdqd", 110 | IsAdmin: &admin, 111 | CreatedAt: time.Now(), 112 | }) 113 | if err != nil { 114 | t.Error(err) 115 | } 116 | } 117 | _, err := Model[Group]().BulkInsert(&Group{ 118 | Name: "admin", 119 | }, &Group{Name: "normal"}) 120 | if err != nil { 121 | t.Error(err) 122 | } 123 | _, err = Table("groups").BulkInsert(map[string]any{ 124 | "name": "another", 125 | }, map[string]any{ 126 | "name": "last", 127 | }) 128 | if err != nil { 129 | t.Error(err) 130 | } 131 | } 132 | 133 | func TestAddRelatedS(t *testing.T) { 134 | _, err := Model[Group]().Where("name = ?", "admin").AddRelated("users", "id = ?", 1) 135 | if err != nil { 136 | t.Error(err) 137 | } 138 | _, err = Model[Group]().Where("name = ?", "admin").AddRelated("users", "id = ?", 2) 139 | if err != nil { 140 | t.Error(err) 141 | } 142 | } 143 | 144 | func TestAddRelatedM(t *testing.T) { 145 | _, err := Table("users").Where("id = ?", 3).AddRelated("groups", "name = ?", "admin") 146 | if err != nil { 147 | t.Error(err) 148 | } 149 | _, err = Table("users").Where("id = ?", 4).AddRelated("groups", "name = ?", "admin") 150 | if err != nil { 151 | t.Error(err) 152 | } 153 | } 154 | 155 | func TestDeleteRelatedS(t *testing.T) { 156 | _, err := Model[Group]().Where("name = ?", "admin").DeleteRelated("users", "id = ?", 1) 157 | if err != nil { 158 | t.Error(err) 159 | } 160 | } 161 | 162 | func TestDeleteRelatedM(t *testing.T) { 163 | _, err := Table("groups").Where("name = ?", "admin").DeleteRelated("users", "id = ?", 2) 164 | if err != nil { 165 | t.Error(err) 166 | } 167 | } 168 | 169 | func TestGetRelatedM(t *testing.T) { 170 | users := []map[string]any{} 171 | err := Table("groups").Where("name", "admin").GetRelated("users", &users) 172 | if err != nil { 173 | t.Error(err) 174 | } 175 | if len(users) != 2 { 176 | t.Error("len(users) != 2 , got: ", users) 177 | } 178 | } 179 | 180 | func TestGetRelatedS(t *testing.T) { 181 | users := []TestUser{} 182 | err := Model[Group]().Where("name = ?", "admin").GetRelated("users", &users) 183 | if err != nil { 184 | t.Error(err) 185 | } 186 | if len(users) != 2 { 187 | t.Error("len(users) != 2 , got: ", users) 188 | } 189 | } 190 | 191 | func TestConcatANDLen(t *testing.T) { 192 | groupes, err := Model[Group]().Where("name = concat(?,'min') AND len(name) = ?", "ad", 5).All() 193 | // translated to select * from groups WHERE name = 'ad' || 'min' AND length(name) = 5 (sqlite) 194 | // translated to select * from groups WHERE name = concat('ad','min') AND char_length(name) = 5 (postgres, mysql) 195 | if err != nil { 196 | t.Error(err) 197 | } 198 | if len(groupes) != 1 || groupes[0].Name != "admin" { 199 | t.Error("len(groupes) != 1 , got: ", groupes) 200 | } 201 | } 202 | 203 | func TestGetRelatedSWithLen(t *testing.T) { 204 | users := []TestUser{} 205 | err := Model[Group]().Where("name = ? AND len(name) = ?", "admin", 5).GetRelated("users", &users) 206 | if err != nil { 207 | t.Error(err) 208 | } 209 | if len(users) != 2 { 210 | t.Error("len(users) != 2 , got: ", users) 211 | } 212 | } 213 | 214 | func TestGetRelatedSWithConcatANDLen(t *testing.T) { 215 | users := []TestUser{} 216 | err := Model[Group]().Where("name = concat(?,'min') AND len(name) = ?", "ad", 5).GetRelated("users", &users) 217 | if err != nil { 218 | t.Error(err) 219 | } 220 | if len(users) != 2 { 221 | t.Error("len(users) != 2 , got: ", users) 222 | } 223 | } 224 | 225 | func TestGeneratedAs(t *testing.T) { 226 | u, err := Model[TestUser]().Limit(3).All() 227 | if err != nil { 228 | t.Error(err) 229 | } 230 | if len(u) != 3 { 231 | t.Error("len not 20") 232 | } 233 | if (u[0].Gen != u[0].Uuid+"working"+fmt.Sprintf("%d", len(u[0].Password))) || u[0].Gen2 != len(u[0].Password)*2 { 234 | t.Error("generated not working:", u[0].Gen) 235 | } 236 | } 237 | 238 | func TestJoinRelatedM(t *testing.T) { 239 | users := []map[string]any{} 240 | err := Table("groups").Where("name = ?", "admin").JoinRelated("users", &users) 241 | if err != nil { 242 | t.Error(err) 243 | } 244 | if len(users) != 2 { 245 | t.Error("len(users) != 2 , got: ", users) 246 | } 247 | } 248 | 249 | func TestInsertForeignKeyShouldError(t *testing.T) { 250 | for i := 0; i < 10; i++ { 251 | email := "user-0@example.com" 252 | admin := true 253 | _, err := Model[TestUser]().Insert(&TestUser{ 254 | Uuid: GenerateUUID(), 255 | Email: &email, 256 | Password: "dqdqd", 257 | IsAdmin: &admin, 258 | CreatedAt: time.Now(), 259 | }) 260 | if err == nil { 261 | t.Error("should error,did not") 262 | } 263 | } 264 | } 265 | 266 | func TestInsertM(t *testing.T) { 267 | for i := 10; i < 20; i++ { 268 | iString := strconv.Itoa(i) 269 | _, err := Table("users").Insert(map[string]any{ 270 | "uuid": GenerateUUID(), 271 | "email": "user-" + iString + "@example.com", 272 | "password": "dqdqd", 273 | "is_admin": true, 274 | "created_at": time.Now(), 275 | }) 276 | if err != nil { 277 | t.Error(err) 278 | } 279 | } 280 | } 281 | 282 | func TestGetAll(t *testing.T) { 283 | u, err := Model[TestUser]().All() 284 | if err != nil { 285 | t.Error(err) 286 | } 287 | if len(u) != 20 { 288 | t.Error("len not 20") 289 | } 290 | } 291 | 292 | func TestGetAllM(t *testing.T) { 293 | u, err := Table("users").All() 294 | if err != nil { 295 | t.Error(err) 296 | } 297 | if len(u) != 20 { 298 | t.Error("len not 20") 299 | } 300 | } 301 | 302 | func TestQuery(t *testing.T) { 303 | u, err := Table("users").QueryM("select * from users") 304 | if err != nil { 305 | t.Error(err) 306 | } 307 | if len(u) != 20 { 308 | t.Error("len not 20") 309 | } 310 | } 311 | 312 | func TestMemoryDatabases(t *testing.T) { 313 | dbs := GetMemoryDatabases() 314 | if len(dbs) != 1 { 315 | t.Error("len(dbs) != 1") 316 | } 317 | if dbs[0].Name != DB_TEST_NAME { 318 | t.Error("dbs[0].Name != DB_TEST_NAME:", dbs[0].Name) 319 | } 320 | } 321 | 322 | func TestMemoryDatabase(t *testing.T) { 323 | db, err := GetMemoryDatabase(DB_TEST_NAME) 324 | if err != nil { 325 | t.Error(err) 326 | } 327 | if db.Name != DB_TEST_NAME { 328 | t.Error("db.Name != DB_TEST_NAME:", db.Name) 329 | } 330 | } 331 | 332 | func TestGetOne(t *testing.T) { 333 | u, err := Model[TestUser]().Where("id = ?", 1).One() 334 | if err != nil { 335 | t.Error(err) 336 | } 337 | if !*u.IsAdmin || *u.Email == "" || u.CreatedAt.IsZero() || u.Uuid == "" { 338 | t.Error("wrong data:", u) 339 | } 340 | } 341 | 342 | func TestGetOneM(t *testing.T) { 343 | u, err := Table("users").Where("id = ?", 1).One() 344 | if err != nil { 345 | t.Error(err) 346 | } 347 | if u["is_admin"] != int64(1) || u["email"] == "" || u["uuid"] == "" { 348 | t.Error("wrong data:", u["is_admin"] != int64(1), u["email"] == "", u["uuid"] == "") 349 | } 350 | } 351 | 352 | func TestGetOneWithDebug(t *testing.T) { 353 | u, err := Model[TestUser]().Debug().Where("id = ?", 1).One() 354 | if err != nil { 355 | t.Error(err) 356 | } 357 | if !*u.IsAdmin || *u.Email == "" || u.CreatedAt.IsZero() || u.Uuid == "" { 358 | t.Error("wrong data:", u) 359 | } 360 | } 361 | 362 | func TestGetOneWithDebugM(t *testing.T) { 363 | u, err := Table("users").Debug().Where("id = ?", 1).One() 364 | if err != nil { 365 | t.Error(err) 366 | } 367 | if u["is_admin"] != int64(1) || u["email"] == "" || u["uuid"] == "" { 368 | t.Error("wrong data:", u["is_admin"] != int64(1), u["email"] == "", u["uuid"] == "") 369 | } 370 | } 371 | 372 | func TestOrderBy(t *testing.T) { 373 | u, err := Model[TestUser]().Where("is_admin = ?", true).OrderBy("-id").All() 374 | if err != nil { 375 | t.Error(err) 376 | } 377 | if (len(u) > 1 && *(u[0]).Id < *(u[1]).Id) || !*(u[0]).IsAdmin || *(u[0]).Email == "" || u[0].CreatedAt.IsZero() || u[0].Uuid == "" { 378 | t.Error("wrong data:", u[0], u[0].CreatedAt.IsZero()) 379 | } 380 | } 381 | 382 | func TestOrderByM(t *testing.T) { 383 | u, err := Table("users").Where("is_admin = ?", true).OrderBy("-id").All() 384 | if err != nil { 385 | t.Error(err) 386 | } 387 | if (len(u) > 1 && u[0]["id"].(int64) < u[1]["id"].(int64)) || u[0]["is_admin"] != int64(1) || u[0]["email"] == "" || u[0]["uuid"] == "" { 388 | t.Error("wrong data:", u[0]) 389 | } 390 | } 391 | 392 | func TestPagination(t *testing.T) { 393 | u, err := Model[TestUser]().Where("is_admin = ?", true).Limit(5).Page(2).All() 394 | if err != nil { 395 | t.Error(err) 396 | } 397 | if len(u) != 5 || *(u[0]).Id != 6 { 398 | t.Error("wrong data:", u[0]) 399 | } 400 | } 401 | 402 | func TestPaginationM(t *testing.T) { 403 | u, err := Table("users").Where("is_admin = ?", true).Limit(5).Page(2).All() 404 | if err != nil { 405 | t.Error(err) 406 | } 407 | if len(u) != 5 || u[0]["id"] != int64(6) { 408 | t.Error("wrong data:", u[0]) 409 | } 410 | } 411 | 412 | func TestWithCtx(t *testing.T) { 413 | u, err := Model[TestUser]().Where("is_admin = ?", true).Context(context.Background()).All() 414 | if err != nil { 415 | t.Error(err) 416 | } 417 | if len(u) != 20 { 418 | t.Error("missing data") 419 | } 420 | } 421 | 422 | func TestWithCtxM(t *testing.T) { 423 | u, err := Table("users").Where("is_admin = ?", true).Context(context.Background()).All() 424 | if err != nil { 425 | t.Error(err) 426 | } 427 | if len(u) != 20 { 428 | t.Error("missing data") 429 | } 430 | } 431 | 432 | func TestQueryS(t *testing.T) { 433 | u, err := Model[TestUser]().QueryS("select * from users") 434 | if err != nil { 435 | t.Error(err) 436 | } 437 | if len(u) != 20 { 438 | t.Error("missing data") 439 | } 440 | } 441 | 442 | func TestQueryM(t *testing.T) { 443 | u, err := Table("users").QueryM("select * from users") 444 | if err != nil { 445 | t.Error(err) 446 | } 447 | if len(u) != 20 { 448 | t.Error("missing data") 449 | } 450 | } 451 | 452 | func TestSelect(t *testing.T) { 453 | u, err := Model[TestUser]().Select("email").All() 454 | if err != nil { 455 | t.Error(err) 456 | } 457 | if len(u) != 20 || !u[0].CreatedAt.IsZero() || *u[0].Email == "" || u[0].Password != "" { 458 | t.Error("wrong data:", u[0]) 459 | } 460 | } 461 | 462 | func TestSelectM(t *testing.T) { 463 | u, err := Table("users").Select("email").All() 464 | if err != nil { 465 | t.Error(err) 466 | } 467 | if len(u) != 20 || len(u[0]) != 1 { 468 | t.Error("wrong data:", u[0]) 469 | } 470 | } 471 | 472 | func TestDatabase(t *testing.T) { 473 | u, err := Model[TestUser]().Database(DB_TEST_NAME).All() 474 | if err != nil { 475 | t.Error(err) 476 | } 477 | if len(u) != 20 { 478 | t.Error("wrong data len:", len(u)) 479 | } 480 | } 481 | 482 | func TestDatabaseM(t *testing.T) { 483 | u, err := Table("users").Database(DB_TEST_NAME).All() 484 | if err != nil { 485 | t.Error(err) 486 | } 487 | if len(u) != 20 { 488 | t.Error("wrong data len:", len(u)) 489 | } 490 | } 491 | 492 | func TestUpdateSet(t *testing.T) { 493 | updatedEmail := "updated@example.com" 494 | is_admin := true 495 | n, err := Model[TestUser]().Where("id = ?", 3).Set("email,is_admin", updatedEmail, &is_admin) 496 | if err != nil { 497 | t.Error(err) 498 | } 499 | if n <= 0 { 500 | t.Error("nothing updated, it should") 501 | } 502 | u, err := Model[TestUser]().Where("id = ?", 3).One() 503 | if err != nil { 504 | t.Error(err) 505 | } 506 | if *u.Email != updatedEmail || !*u.IsAdmin { 507 | t.Errorf("expect %s got %v, bool is %v", updatedEmail, *u.Email, *u.IsAdmin) 508 | } 509 | } 510 | 511 | func TestUpdateSetM(t *testing.T) { 512 | updatedEmail := "updated2@example.com" 513 | n, err := Table("users").Where("id = ?", 7).Set("email = ?", updatedEmail) 514 | if err != nil { 515 | t.Error(err) 516 | } 517 | if n <= 0 { 518 | t.Error("nothing updated, it should") 519 | } 520 | u, err := Model[TestUser]().Where("id = ?", 7).One() 521 | if err != nil { 522 | t.Error(err) 523 | } 524 | if *u.Email != updatedEmail { 525 | t.Errorf("expect %s got %v", updatedEmail, u.Email) 526 | } 527 | } 528 | 529 | func TestDelete(t *testing.T) { 530 | n, err := Model[TestUser]().Where("id = ?", 12).Delete() 531 | if err != nil { 532 | t.Error(err) 533 | } 534 | if n < 0 { 535 | t.Error("nothing deleted, it should", n) 536 | } 537 | u, err := Model[TestUser]().Where("id = ?", 12).One() 538 | if err == nil { 539 | t.Error("not errored, it should : ", err, u) 540 | } 541 | } 542 | 543 | func TestDeleteM(t *testing.T) { 544 | n, err := Table("users").Where("id = ?", 13).Delete() 545 | if err != nil { 546 | t.Error(err) 547 | } 548 | if n < 0 { 549 | t.Error("nothing deleted, it should") 550 | } 551 | _, err = Table("users").Where("id = ?", 12).One() 552 | if err == nil { 553 | t.Error("not errored, it should") 554 | } 555 | } 556 | 557 | func TestDropM(t *testing.T) { 558 | _, err := Table("m2m_users_groups").Drop() 559 | if err != nil { 560 | t.Error(err) 561 | } 562 | for _, table := range GetAllTables() { 563 | if table == "m2m_users_groups groups" { 564 | t.Error("m2m_users_groups groups table not dropped", GetAllTables()) 565 | } 566 | } 567 | } 568 | 569 | func TestDropS(t *testing.T) { 570 | _, err := Model[TestUser]().Drop() 571 | if err != nil { 572 | t.Error(err) 573 | } 574 | for _, table := range GetAllTables() { 575 | if table == "users" { 576 | t.Error("users table not dropped", GetAllTables()) 577 | } 578 | } 579 | } 580 | 581 | func TestShutdown(t *testing.T) { 582 | err := Shutdown(DB_TEST_NAME) 583 | if err != nil { 584 | t.Error(err) 585 | } 586 | } 587 | -------------------------------------------------------------------------------- /migrator_helpers.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | "unicode" 8 | 9 | "github.com/kamalshkeir/kinput" 10 | "github.com/kamalshkeir/kstrct" 11 | "github.com/kamalshkeir/lg" 12 | ) 13 | 14 | var checkEnabled = false 15 | 16 | // WithSchemaCheck enable struct changes check 17 | func WithSchemaCheck() { 18 | checkEnabled = true 19 | } 20 | 21 | type kormFkey struct { 22 | FromTableField string 23 | ToTableField string 24 | Unique bool 25 | } 26 | 27 | // LinkModel link a struct model to a db_table_name 28 | func LinkModel[T any](to_table_name string, dbName ...string) { 29 | var db *DatabaseEntity 30 | if len(dbName) == 0 && len(databases) > 0 { 31 | db = &databases[0] 32 | } else { 33 | dbb, err := GetMemoryDatabase(dbName[0]) 34 | if lg.CheckError(err) { 35 | return 36 | } 37 | db = dbb 38 | } 39 | 40 | tFound := false 41 | for _, t := range db.Tables { 42 | if t.Name == to_table_name { 43 | tFound = true 44 | } 45 | } 46 | var kfkeys = []kormFkey{} 47 | // get columns from db 48 | colsNameType, _ := GetAllColumnsTypes(to_table_name, db.Name) 49 | fields, _, ftypes, ftags := getStructInfos(new(T)) 50 | pk := "" 51 | if !tFound { 52 | tagsLoop: 53 | for col, tags := range ftags { 54 | for i := range tags { 55 | tags[i] = strings.TrimSpace(tags[i]) 56 | if tags[i] == "pk" || tags[i] == "autoinc" { 57 | pk = col 58 | break tagsLoop 59 | } 60 | } 61 | } 62 | loop: 63 | for k, v := range ftypes { 64 | if v, ok := ftags[k]; ok { 65 | for _, t := range v { 66 | if t == "-" || t == "skip" { 67 | for i := range fields { 68 | if fields[i] == k { 69 | fields = append(fields[:i], fields[i+1:]...) 70 | } 71 | } 72 | delete(ftags, k) 73 | delete(ftypes, k) 74 | delete(colsNameType, k) 75 | continue loop 76 | } else if strings.HasPrefix(t, "fk:") { 77 | st := strings.Split(t, ":")[1] 78 | fkey := kormFkey{ 79 | FromTableField: to_table_name + "." + kstrct.ToSnakeCase(k), 80 | ToTableField: st, 81 | } 82 | if strings.Contains(strings.Join(ftags[k], ";"), "unique") { 83 | fkey.Unique = true 84 | } 85 | kfkeys = append(kfkeys, fkey) 86 | } 87 | } 88 | } 89 | 90 | if v == "" { 91 | for i := len(fields) - 1; i >= 0; i-- { 92 | if fields[i] == k { 93 | fields = append(fields[:i], fields[i+1:]...) 94 | } 95 | } 96 | delete(ftags, k) 97 | delete(ftypes, k) 98 | delete(colsNameType, k) 99 | } else if (unicode.IsUpper(rune(v[0])) || strings.Contains(v, ".")) && !strings.HasSuffix(v, "Time") && !(strings.HasPrefix(v, "map") || strings.HasPrefix(v, "*map")) { 100 | // if struct 101 | for i := len(fields) - 1; i >= 0; i-- { 102 | if fields[i] == k { 103 | fields = append(fields[:i], fields[i+1:]...) 104 | } 105 | } 106 | delete(ftags, k) 107 | delete(ftypes, k) 108 | delete(colsNameType, k) 109 | } 110 | } 111 | if pk == "" { 112 | if strings.HasSuffix(fields[0], "id") { 113 | pk = fields[0] 114 | } else { 115 | pk = "id" 116 | } 117 | } 118 | te := TableEntity{ 119 | Fkeys: kfkeys, 120 | Name: to_table_name, 121 | Columns: fields, 122 | ModelTypes: ftypes, 123 | Types: colsNameType, 124 | Tags: ftags, 125 | Pk: pk, 126 | } 127 | db.Tables = append(db.Tables, te) 128 | 129 | if to_table_name != "_tables_infos" { 130 | // insert tables infos into db 131 | mTablesInfos, err := Model[TablesInfos]().Where("name = ?", to_table_name).One() 132 | if err != nil { 133 | // adapt table_infos and insert 134 | fktbinfos := []string{} 135 | for _, fk := range te.Fkeys { 136 | un := "false" 137 | if fk.Unique { 138 | un = "true" 139 | } 140 | st := fk.FromTableField + ";;" + fk.ToTableField + ";;" + un 141 | fktbinfos = append(fktbinfos, st) 142 | } 143 | types := make([]string, 0, len(te.Types)) 144 | for k, v := range te.Types { 145 | types = append(types, k+":"+v) 146 | } 147 | model_types_in := make([]string, 0, len(te.ModelTypes)) 148 | for k, v := range te.ModelTypes { 149 | model_types_in = append(model_types_in, k+":"+v) 150 | } 151 | tags_in := make([]string, 0, len(te.Tags)) 152 | for k, v := range te.Tags { 153 | tags_in = append(tags_in, k+":"+strings.Join(v, ",")) 154 | } 155 | _, err = Table("_tables_infos").Insert(map[string]any{ 156 | "pk": pk, 157 | "name": to_table_name, 158 | "columns": strings.Join(fields, ","), 159 | "fkeys": strings.Join(fktbinfos, ","), 160 | "types": strings.Join(types, ";;"), 161 | "model_types": strings.Join(model_types_in, ";;"), 162 | "tags": strings.Join(tags_in, ";;"), 163 | }) 164 | lg.CheckError(err) 165 | } else { 166 | fkk := []kormFkey{} 167 | for _, fk := range mTablesInfos.Fkeys { 168 | sp := strings.Split(fk, ";;") 169 | if len(sp) == 3 { 170 | k := kormFkey{} 171 | for i, spp := range sp { 172 | spp = strings.TrimSpace(spp) 173 | switch i { 174 | case 0: 175 | k.FromTableField = spp 176 | case 1: 177 | k.ToTableField = spp 178 | case 2: 179 | if spp == "true" { 180 | k.Unique = true 181 | } 182 | } 183 | } 184 | fkk = append(fkk, k) 185 | } 186 | } 187 | tee := TableEntity{ 188 | Types: mTablesInfos.Types, 189 | ModelTypes: mTablesInfos.ModelTypes, 190 | Tags: mTablesInfos.Tags, 191 | Columns: mTablesInfos.Columns, 192 | Pk: mTablesInfos.Pk, 193 | Name: mTablesInfos.Name, 194 | Fkeys: fkk, 195 | } 196 | if !kstrct.CompareStructs(te, tee) { 197 | // update 198 | // adapt table_infos and insert 199 | fktbinfos := []string{} 200 | for _, fk := range te.Fkeys { 201 | un := "false" 202 | if fk.Unique { 203 | un = "true" 204 | } 205 | st := fk.FromTableField + ";;" + fk.ToTableField + ";;" + un 206 | fktbinfos = append(fktbinfos, st) 207 | } 208 | types := make([]string, 0, len(te.Types)) 209 | for k, v := range te.Types { 210 | types = append(types, k+":"+v) 211 | } 212 | model_types_in := make([]string, 0, len(te.ModelTypes)) 213 | for k, v := range te.ModelTypes { 214 | model_types_in = append(model_types_in, k+":"+v) 215 | } 216 | tags_in := make([]string, 0, len(te.Tags)) 217 | for k, v := range te.Tags { 218 | tags_in = append(tags_in, k+":"+strings.Join(v, ",")) 219 | } 220 | _, err = Table("_tables_infos").Where("name = ?", to_table_name).SetM(map[string]any{ 221 | "pk": pk, 222 | "name": to_table_name, 223 | "columns": strings.Join(fields, ","), 224 | "fkeys": strings.Join(fktbinfos, ","), 225 | "types": strings.Join(types, ";;"), 226 | "model_types": strings.Join(model_types_in, ";;"), 227 | "tags": strings.Join(tags_in, ";;"), 228 | }) 229 | lg.CheckError(err) 230 | } 231 | } 232 | } 233 | } 234 | // sync models struct types with db tables 235 | if checkEnabled { 236 | if len(colsNameType) > len(fields) { 237 | removedCols := []string{} 238 | colss := []string{} 239 | for dbcol := range colsNameType { 240 | found := false 241 | for _, fname := range fields { 242 | if fname == dbcol { 243 | found = true 244 | } 245 | } 246 | if !found { 247 | // remove dbcol from db 248 | lg.Printfs("rd⚠️ field '%s' has been removed from '%T'\n", dbcol, *new(T)) 249 | removedCols = append(removedCols, dbcol) 250 | } else { 251 | colss = append(colss, dbcol) 252 | } 253 | } 254 | if len(removedCols) > 0 { 255 | choice, err := kinput.String(kinput.Yellow, "> do we remove extra columns ? (Y/n): ") 256 | lg.CheckError(err) 257 | switch choice { 258 | case "y", "Y": 259 | temp := to_table_name + "_temp" 260 | tempQuery, err := autoMigrate(new(T), db, temp, true) 261 | if lg.CheckError(err) { 262 | return 263 | } 264 | if Debug { 265 | fmt.Println("DEBUG:SYNC:", tempQuery) 266 | } 267 | cls := strings.Join(colss, ",") 268 | _, err = db.Conn.Exec("INSERT INTO " + temp + " (" + cls + ") SELECT " + cls + " FROM " + to_table_name) 269 | if lg.CheckError(err) { 270 | return 271 | } 272 | _, err = Table(to_table_name + "_old").Database(db.Name).Drop() 273 | if lg.CheckError(err) { 274 | return 275 | } 276 | _, err = db.Conn.Exec("ALTER TABLE " + to_table_name + " RENAME TO " + to_table_name + "_old") 277 | if lg.CheckError(err) { 278 | return 279 | } 280 | _, err = db.Conn.Exec("ALTER TABLE " + temp + " RENAME TO " + to_table_name) 281 | if lg.CheckError(err) { 282 | return 283 | } 284 | lg.Printfs("grDone, you can still find your old table with the same data %s\n", to_table_name+"_old") 285 | os.Exit(0) 286 | default: 287 | return 288 | } 289 | } 290 | } else if len(colsNameType) < len(fields) { 291 | addedFields := []string{} 292 | for _, fname := range fields { 293 | if _, ok := colsNameType[fname]; !ok { 294 | lg.Printfs("rd⚠️ column '%s' is missing from table '%s'\n", fname, to_table_name) 295 | addedFields = append(addedFields, fname) 296 | } 297 | } 298 | if len(addedFields) > 0 { 299 | choice, err := kinput.String(kinput.Yellow, "> do we add missing columns ? (Y/n): ") 300 | lg.CheckError(err) 301 | switch choice { 302 | case "y", "Y": 303 | temp := to_table_name + "_temp" 304 | tempQuery, err := autoMigrate(new(T), db, temp, true) 305 | if lg.CheckError(err) { 306 | return 307 | } 308 | if Debug { 309 | fmt.Println("DEBUG:SYNC:", tempQuery) 310 | } 311 | var colss []string 312 | 313 | for k := range colsNameType { 314 | colss = append(colss, k) 315 | } 316 | cls := strings.Join(colss, ",") 317 | _, err = db.Conn.Exec("INSERT INTO " + temp + " (" + cls + ") SELECT " + cls + " FROM " + to_table_name) 318 | if lg.CheckError(err) { 319 | lg.Printfs("query: %s\n", "INSERT INTO "+temp+" ("+cls+") SELECT "+cls+" FROM "+to_table_name) 320 | return 321 | } 322 | _, err = Table(to_table_name + "_old").Database(db.Name).Drop() 323 | if lg.CheckError(err) { 324 | return 325 | } 326 | _, err = db.Conn.Exec("ALTER TABLE " + to_table_name + " RENAME TO " + to_table_name + "_old") 327 | if lg.CheckError(err) { 328 | return 329 | } 330 | _, err = db.Conn.Exec("ALTER TABLE " + temp + " RENAME TO " + to_table_name) 331 | if lg.CheckError(err) { 332 | return 333 | } 334 | lg.Printfs("grDone, you can still find your old table with the same data %s\n", to_table_name+"_old") 335 | os.Exit(0) 336 | default: 337 | return 338 | } 339 | } 340 | } 341 | } 342 | } 343 | 344 | func flushCache() { 345 | caches.Flush() 346 | cacheQ.Flush() 347 | cacheAllTables.Flush() 348 | cacheAllCols.Flush() 349 | } 350 | -------------------------------------------------------------------------------- /shell.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/kamalshkeir/argon" 12 | "github.com/kamalshkeir/kinput" 13 | "github.com/kamalshkeir/ksmux" 14 | "github.com/kamalshkeir/lg" 15 | ) 16 | 17 | const ( 18 | red = "\033[1;31m%v\033[0m\n" 19 | green = "\033[1;32m%v\033[0m\n" 20 | yellow = "\033[1;33m%v\033[0m\n" 21 | blue = "\033[1;34m%v\033[0m\n" 22 | magenta = "\033[5;35m%v\033[0m\n" 23 | ) 24 | 25 | var usedDB DatabaseEntity 26 | 27 | const helpS string = ` 28 | [ 29 | databases, use, tables, columns, migrate, createsuperuser, createuser 30 | query, getall, get, drop, delete, clear/cls, q/quit/exit, help/commands 31 | ] 32 | 33 | 34 | 'databases': 35 | list all connected databases 36 | 37 | 'use': 38 | use a specific database 39 | 40 | 'tables': 41 | list all tables in database 42 | 43 | 'columns': 44 | list all columns of a table 45 | 46 | 'migrate': 47 | migrate or execute sql file 48 | 49 | 'createsuperuser': (only with dashboard) 50 | create a admin user 51 | 52 | 'createuser': (only with dashboard) 53 | create a regular user 54 | 55 | 'query': 56 | query data from database 57 | (accept but not required extra param like : 'query' or 'query select * from users where ...') 58 | 59 | 'getall': 60 | get all rows given a table name 61 | (accept but not required extra param like : 'getall' or 'getall users') 62 | 63 | 'get': 64 | get single row 65 | (accept but not required extra param like : 'get' or 'get users email like "%anything%"') 66 | 67 | 'delete': 68 | delete rows where field equal_to 69 | (accept but not required extra param like : 'delete' or 'delete users email="email@example.com"') 70 | 71 | 'drop': 72 | drop a table given table name 73 | (accept but not required extra param like : 'drop' or 'drop users') 74 | 75 | 'clear / cls': 76 | clear shell console 77 | 78 | 'q / quit / exit / q!': 79 | exit shell 80 | 81 | 'help': 82 | show this help message 83 | ` 84 | 85 | const commandsS string = "Commands : [databases, use, tables, columns, migrate, query, getall, get, drop, delete, createsuperuser, createuser, clear/cls, q/q!/quit/exit, help/commands]" 86 | 87 | // InitShell init the shell and return true if used to stop main 88 | func InitShell() bool { 89 | args := os.Args 90 | if len(args) < 2 { 91 | return false 92 | } 93 | args = args[1:] 94 | switch args[0] { 95 | case "commands": 96 | fmt.Printf(yellow, "Usage: go run main.go shell") 97 | fmt.Printf(yellow, commandsS) 98 | return true 99 | case "help": 100 | fmt.Printf(yellow, "Usage: go run main.go shell") 101 | fmt.Printf(yellow, helpS) 102 | return true 103 | case "shell": 104 | if len(args) > 1 { 105 | handleCommand(args[1:]) 106 | return true 107 | } 108 | databases := GetMemoryDatabases() 109 | usedDB = databases[0] 110 | defer usedDB.Conn.Close() 111 | fmt.Printf(yellow, commandsS) 112 | for { 113 | command, err := kinput.String(kinput.Blue, "> ") 114 | if err != nil { 115 | if errors.Is(err, io.EOF) { 116 | fmt.Printf(blue, "shell shutting down") 117 | } 118 | return true 119 | } 120 | spCommand := strings.Split(command, " ") 121 | if handleCommand(spCommand) { 122 | return true 123 | } 124 | } 125 | case "gendocs", "gendoc": 126 | if len(args) > 1 && args[1] != "" { 127 | ksmux.DocsOutJson = args[1] 128 | _ = ksmux.CheckAndInstallSwagger() 129 | ksmux.GenerateGoDocsComments() 130 | ksmux.GenerateJsonDocs() 131 | } else { 132 | ksmux.DocsOutJson = "./" + staticDir + "/docs" 133 | _ = ksmux.CheckAndInstallSwagger() 134 | ksmux.GenerateGoDocsComments() 135 | ksmux.GenerateJsonDocs() 136 | } 137 | return true 138 | default: 139 | return false 140 | } 141 | } 142 | 143 | func handleCommand(commands []string) bool { 144 | command := "" 145 | if len(commands) > 0 { 146 | command = commands[0] 147 | } 148 | switch command { 149 | case "quit", "exit", "q", "q!": 150 | return true 151 | case "clear", "cls": 152 | kinput.Clear() 153 | fmt.Printf(yellow, commandsS) 154 | case "help": 155 | fmt.Printf(yellow, helpS) 156 | case "commands": 157 | fmt.Printf(yellow, commandsS) 158 | case "migrate": 159 | var path string 160 | if len(commands) > 1 { 161 | path = commands[1] 162 | } else { 163 | path = kinput.Input(kinput.Blue, "path to sql file: ") 164 | } 165 | err := migratefromfile(path) 166 | if !lg.CheckError(err) { 167 | fmt.Printf(green, "migrated successfully") 168 | } 169 | case "databases": 170 | fmt.Printf(green, GetMemoryDatabases()) 171 | case "use": 172 | var dbName string 173 | if len(commands) > 1 { 174 | dbName = commands[1] 175 | } else { 176 | dbName = kinput.Input(kinput.Blue, "database name: ") 177 | } 178 | db, err := GetMemoryDatabase(dbName) 179 | if err != nil { 180 | lg.Printfs("rd%v\n", err) 181 | } 182 | usedDB = *db 183 | fmt.Printf(green, "you are using database "+usedDB.Name) 184 | case "tables": 185 | fmt.Printf(green, GetAllTables(usedDB.Name)) 186 | case "columns": 187 | var tb string 188 | if len(commands) > 1 { 189 | tb = commands[1] 190 | } else { 191 | tb = kinput.Input(kinput.Blue, "Table name: ") 192 | } 193 | if tb == "" { 194 | fmt.Printf(red, "you should specify a table that exist !") 195 | } 196 | mcols, _ := GetAllColumnsTypes(tb, usedDB.Name) 197 | cols := []string{} 198 | for k := range mcols { 199 | cols = append(cols, k) 200 | } 201 | fmt.Printf(green, cols) 202 | case "getall": 203 | if len(commands) > 1 { 204 | getAll(commands[1]) 205 | } else { 206 | getAll("") 207 | } 208 | case "get": 209 | if len(commands) > 2 { 210 | getRow(commands[1], strings.Join(commands[2:], " ")) 211 | } else { 212 | getRow("", "") 213 | } 214 | case "query": 215 | if len(commands) > 1 { 216 | query(strings.Join(commands[1:], " ")) 217 | } else { 218 | query("") 219 | } 220 | case "drop": 221 | if len(commands) > 1 { 222 | dropTable(commands[1]) 223 | } else { 224 | dropTable("") 225 | } 226 | case "delete": 227 | if len(commands) > 2 { 228 | deleteRow(commands[1], strings.Join(commands[2:], " ")) 229 | } else { 230 | deleteRow("", "") 231 | } 232 | case "createuser": 233 | createuser() 234 | case "createsuperuser": 235 | createsuperuser() 236 | default: 237 | fmt.Printf(red, "command not handled, use 'help' or 'commands' to list available commands"+command) 238 | } 239 | return false 240 | } 241 | 242 | func createuser() { 243 | username := kinput.Input(kinput.Blue, "Username : ") 244 | email := kinput.Input(kinput.Blue, "Email : ") 245 | password := kinput.Hidden(kinput.Blue, "Password : ") 246 | if email != "" && password != "" { 247 | err := newuser(username, email, password, false) 248 | if err == nil { 249 | fmt.Printf(green, "User "+email+" created successfully") 250 | } else { 251 | fmt.Printf(red, "unable to create user:"+err.Error()) 252 | } 253 | } else { 254 | fmt.Printf(red, "email or password invalid") 255 | } 256 | } 257 | 258 | func createsuperuser() { 259 | username := kinput.Input(kinput.Blue, "Username: ") 260 | email := kinput.Input(kinput.Blue, "Email: ") 261 | password := kinput.Hidden(kinput.Blue, "Password: ") 262 | err := newuser(username, email, password, true) 263 | if err == nil { 264 | fmt.Printf(green, "Admin "+email+" created successfully") 265 | } else { 266 | fmt.Printf(red, "error creating user :"+err.Error()) 267 | } 268 | } 269 | 270 | func getAll(tbName string) { 271 | if tbName == "" { 272 | tbName = kinput.Input(kinput.Blue, "Enter a table name: ") 273 | } 274 | data, err := Table(tbName).Database(usedDB.Name).All() 275 | if err == nil { 276 | d, _ := json.MarshalIndent(data, "", " ") 277 | fmt.Printf(green, string(d)) 278 | } else { 279 | fmt.Printf(red, err.Error()) 280 | } 281 | } 282 | 283 | func query(queryStatement string) { 284 | if queryStatement == "" { 285 | queryStatement = kinput.Input(kinput.Blue, "Query: ") 286 | } 287 | data, err := BuilderMap().QueryM(queryStatement) 288 | if err == nil { 289 | d, _ := json.MarshalIndent(data, "", " ") 290 | fmt.Printf(green, string(d)) 291 | } else { 292 | fmt.Printf(red, err.Error()) 293 | } 294 | } 295 | 296 | func newuser(username, email, password string, admin bool) error { 297 | if email == "" || password == "" { 298 | return fmt.Errorf("email or password empty") 299 | } 300 | if !IsValidEmail(email) { 301 | return fmt.Errorf("email not valid") 302 | } 303 | 304 | hash, err := argon.Hash(password) 305 | if err != nil { 306 | return err 307 | } 308 | _, err = Table("users").Insert(map[string]any{ 309 | "uuid": GenerateUUID(), 310 | "email": email, 311 | "password": hash, 312 | "username": username, 313 | "is_admin": admin, 314 | "image": "", 315 | }) 316 | if err != nil { 317 | return err 318 | } 319 | return nil 320 | } 321 | 322 | func getRow(tbName, where string) { 323 | if tbName == "" || where == "" { 324 | tbName = kinput.Input(kinput.Blue, "Table Name: ") 325 | where = kinput.Input(kinput.Blue, "Where Query: ") 326 | } 327 | 328 | if tbName != "" && where != "" { 329 | var data map[string]any 330 | var err error 331 | data, err = Table(tbName).Database(usedDB.Name).Where(where).One() 332 | if err == nil { 333 | d, _ := json.MarshalIndent(data, "", " ") 334 | fmt.Printf(green, string(d)) 335 | } else { 336 | fmt.Printf(red, "error: "+err.Error()) 337 | } 338 | } else { 339 | fmt.Printf(red, "One or more field are empty") 340 | } 341 | } 342 | 343 | func migratefromfile(path string) error { 344 | if !SliceContains([]string{POSTGRES, SQLITE, MYSQL, MARIA}, databases[0].Dialect) { 345 | fmt.Printf(red, "database is neither postgres, sqlite3 or mysql ") 346 | return errors.New("database is neither postgres, sqlite3 or mysql ") 347 | } 348 | if path == "" { 349 | fmt.Printf(red, "path cannot be empty ") 350 | return errors.New("path cannot be empty ") 351 | } 352 | statements := []string{} 353 | b, err := os.ReadFile(path) 354 | if err != nil { 355 | return errors.New("error reading from " + path + " " + err.Error()) 356 | } 357 | splited := strings.Split(string(b), ";") 358 | statements = append(statements, splited...) 359 | 360 | //exec migrations 361 | for i := range statements { 362 | conn := usedDB.Conn 363 | _, err := conn.Exec(statements[i]) 364 | if err != nil { 365 | return errors.New("error migrating from " + path + " " + err.Error()) 366 | } 367 | } 368 | return nil 369 | } 370 | 371 | func dropTable(tbName string) { 372 | if tbName == "" { 373 | tbName = kinput.Input(kinput.Blue, "Table to drop : ") 374 | } 375 | 376 | _, err := Table(tbName).Database(usedDB.Name).Drop() 377 | if err != nil { 378 | fmt.Printf(red, "error dropping table :"+err.Error()) 379 | } else { 380 | fmt.Printf(green, tbName+" dropped") 381 | } 382 | } 383 | 384 | func deleteRow(tbName, where string) { 385 | if tbName == "" || where == "" { 386 | tbName = kinput.Input(kinput.Blue, "Table Name: ") 387 | where = kinput.Input(kinput.Blue, "Where Query: ") 388 | } 389 | if tbName != "" && where != "" { 390 | _, err := Table(tbName).Database(usedDB.Name).Where(where).Delete() 391 | if err == nil { 392 | fmt.Printf(green, tbName+" deleted.") 393 | } else { 394 | fmt.Printf(red, "error deleting row: "+err.Error()) 395 | } 396 | } else { 397 | fmt.Printf(red, "some of args are empty") 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /sqlhooks.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "context" 5 | "database/sql/driver" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/kamalshkeir/ksmux" 12 | "github.com/kamalshkeir/lg" 13 | ) 14 | 15 | // Hooks instances may be passed to Wrap() to define an instrumented driver 16 | type Hooks interface { 17 | Before(ctx context.Context, query string, args ...interface{}) (context.Context, error) 18 | After(ctx context.Context, query string, args ...interface{}) (context.Context, error) 19 | } 20 | 21 | type logAndCacheHook struct{} 22 | 23 | func (h *logAndCacheHook) Before(ctx context.Context, query string, args ...any) (context.Context, error) { 24 | if ctx.Value(traceEnabledKey) != nil { 25 | return context.WithValue(ctx, ksmux.ContextKey("trace_start"), time.Now()), nil 26 | } 27 | if logQueries { 28 | lg.Printfs("yl> %s %v", query, args) 29 | return context.WithValue(ctx, ksmux.ContextKey("begin"), time.Now()), nil 30 | } 31 | return ctx, nil 32 | } 33 | 34 | func (h *logAndCacheHook) After(ctx context.Context, query string, args ...any) (context.Context, error) { 35 | if !logQueries && ctx.Value(traceEnabledKey) == nil { 36 | return ctx, nil 37 | } 38 | 39 | if strings.Contains(strings.ToUpper(query), "DROP") { 40 | flushCache() 41 | if v, ok := hooks.Get("drop"); ok { 42 | for _, vv := range v { 43 | vv(HookData{ 44 | Operation: "drop", 45 | Data: map[string]any{ 46 | "query": query, 47 | "args": args, 48 | }, 49 | }) 50 | } 51 | } 52 | } 53 | 54 | startTime, _ := ctx.Value(ksmux.ContextKey("trace_start")).(time.Time) 55 | duration := time.Since(startTime) 56 | 57 | if ctx.Value(traceEnabledKey) != nil && defaultTracer.enabled { 58 | trace := TraceData{ 59 | Database: defaultDB, 60 | Query: query, 61 | Args: args, 62 | StartTime: startTime, 63 | Duration: duration, 64 | } 65 | defaultTracer.addTrace(trace) 66 | } 67 | 68 | if logQueries { 69 | lg.InfoC("Query executed", 70 | "query", query, 71 | "args", fmt.Sprint(args...), 72 | "duration", duration) 73 | } 74 | 75 | return ctx, nil 76 | } 77 | 78 | // OnErrorer instances will be called if any error happens 79 | type OnErrorer interface { 80 | OnError(ctx context.Context, err error, query string, args ...interface{}) error 81 | } 82 | 83 | func handlerErr(ctx context.Context, hooks Hooks, err error, query string, args ...interface{}) error { 84 | h, ok := hooks.(OnErrorer) 85 | if !ok { 86 | return err 87 | } 88 | 89 | if err := h.OnError(ctx, err, query, args...); err != nil { 90 | return err 91 | } 92 | 93 | return err 94 | } 95 | 96 | // Driver implements a database/sql/driver.Driver 97 | type Driver struct { 98 | driver.Driver 99 | hooks Hooks 100 | } 101 | 102 | // Open opens a connection 103 | func (drv *Driver) Open(name string) (driver.Conn, error) { 104 | conn, err := drv.Driver.Open(name) 105 | if err != nil { 106 | return conn, err 107 | } 108 | 109 | // Drivers that don't implement driver.ConnBeginTx are not supported. 110 | if _, ok := conn.(driver.ConnBeginTx); !ok { 111 | return nil, errors.New("driver must implement driver.ConnBeginTx") 112 | } 113 | 114 | wrapped := &driverConn{conn, drv.hooks} 115 | if isExecer(conn) && isQueryer(conn) && isSessionResetter(conn) { 116 | return &ExecerQueryerContextWithSessionResetter{wrapped, 117 | &execerContext{wrapped}, &queryerContext{wrapped}, 118 | &SessionResetter{wrapped}}, nil 119 | } else if isExecer(conn) && isQueryer(conn) { 120 | return &ExecerQueryerContext{wrapped, &execerContext{wrapped}, 121 | &queryerContext{wrapped}}, nil 122 | } else if isExecer(conn) { 123 | // If conn implements an Execer interface, return a driver.Conn which 124 | // also implements Execer 125 | return &execerContext{wrapped}, nil 126 | } else if isQueryer(conn) { 127 | // If conn implements an Queryer interface, return a driver.Conn which 128 | // also implements Queryer 129 | return &queryerContext{wrapped}, nil 130 | } 131 | return wrapped, nil 132 | } 133 | 134 | // driverConn implements a database/sql.driver.driverConn 135 | type driverConn struct { 136 | Conn driver.Conn 137 | hooks Hooks 138 | } 139 | 140 | func isSessionResetter(conn driver.Conn) bool { 141 | _, ok := conn.(driver.SessionResetter) 142 | return ok 143 | } 144 | 145 | func (conn *driverConn) PrepareContext(ctx context.Context, query string) (driver.Stmt, error) { 146 | var ( 147 | stmt driver.Stmt 148 | err error 149 | ) 150 | 151 | if c, ok := conn.Conn.(driver.ConnPrepareContext); ok { 152 | stmt, err = c.PrepareContext(ctx, query) 153 | } else { 154 | stmt, err = conn.Prepare(query) 155 | } 156 | 157 | if err != nil { 158 | return stmt, err 159 | } 160 | 161 | return &Stmt{stmt, conn.hooks, query}, nil 162 | } 163 | 164 | func (conn *driverConn) Prepare(query string) (driver.Stmt, error) { 165 | return conn.Conn.Prepare(query) 166 | } 167 | func (conn *driverConn) Close() error { return conn.Conn.Close() } 168 | func (conn *driverConn) Begin() (driver.Tx, error) { 169 | return conn.BeginTx(context.Background(), driver.TxOptions{}) 170 | } 171 | func (conn *driverConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) { 172 | return conn.Conn.(driver.ConnBeginTx).BeginTx(ctx, opts) 173 | } 174 | 175 | // execerContext implements a database/sql.driver.execerContext 176 | type execerContext struct { 177 | *driverConn 178 | } 179 | 180 | func isExecer(conn driver.Conn) bool { 181 | switch conn.(type) { 182 | case driver.ExecerContext: 183 | return true 184 | default: 185 | return false 186 | } 187 | } 188 | 189 | func (conn *execerContext) execContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { 190 | switch c := conn.Conn.(type) { 191 | case driver.ExecerContext: 192 | return c.ExecContext(ctx, query, args) 193 | default: 194 | // This should not happen 195 | return nil, errors.New("ExecerContext created for a non Execer driver.Conn") 196 | } 197 | } 198 | 199 | func (conn *execerContext) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) { 200 | var err error 201 | 202 | list := namedValueToAny(args) 203 | 204 | // Exec `Before` Hooks 205 | if ctx, err = conn.hooks.Before(ctx, query, list...); err != nil { 206 | return nil, err 207 | } 208 | 209 | results, err := conn.execContext(ctx, query, args) 210 | if err != nil { 211 | return results, handlerErr(ctx, conn.hooks, err, query, list...) 212 | } 213 | 214 | if _, err := conn.hooks.After(ctx, query, list...); err != nil { 215 | return nil, err 216 | } 217 | 218 | return results, err 219 | } 220 | 221 | func (conn *execerContext) Exec(query string, args []driver.Value) (driver.Result, error) { 222 | // We have to implement Exec since it is required in the current version of 223 | // Go for it to run ExecContext. From Go 10 it will be optional. However, 224 | // this code should never run since database/sql always prefers to run 225 | // ExecContext. 226 | return nil, errors.New("Exec was called when ExecContext was implemented") 227 | } 228 | 229 | // queryerContext implements a database/sql.driver.queryerContext 230 | type queryerContext struct { 231 | *driverConn 232 | } 233 | 234 | func isQueryer(conn driver.Conn) bool { 235 | switch conn.(type) { 236 | case driver.QueryerContext: 237 | return true 238 | default: 239 | return false 240 | } 241 | } 242 | 243 | func (conn *queryerContext) queryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { 244 | switch c := conn.Conn.(type) { 245 | case driver.QueryerContext: 246 | return c.QueryContext(ctx, query, args) 247 | default: 248 | // This should not happen 249 | return nil, errors.New("QueryerContext created for a non Queryer driver.Conn") 250 | } 251 | } 252 | 253 | func (conn *queryerContext) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) { 254 | var err error 255 | 256 | list := namedValueToAny(args) 257 | 258 | // Query `Before` Hooks 259 | if ctx, err = conn.hooks.Before(ctx, query, list...); err != nil { 260 | return nil, err 261 | } 262 | 263 | results, err := conn.queryContext(ctx, query, args) 264 | if err != nil { 265 | return results, handlerErr(ctx, conn.hooks, err, query, list...) 266 | } 267 | 268 | if _, err := conn.hooks.After(ctx, query, list...); err != nil { 269 | return nil, err 270 | } 271 | 272 | return results, err 273 | } 274 | 275 | // ExecerQueryerContext implements database/sql.driver.ExecerContext and 276 | // database/sql.driver.QueryerContext 277 | type ExecerQueryerContext struct { 278 | *driverConn 279 | *execerContext 280 | *queryerContext 281 | } 282 | 283 | // ExecerQueryerContext implements database/sql.driver.ExecerContext and 284 | // database/sql.driver.QueryerContext 285 | type ExecerQueryerContextWithSessionResetter struct { 286 | *driverConn 287 | *execerContext 288 | *queryerContext 289 | *SessionResetter 290 | } 291 | 292 | type SessionResetter struct { 293 | *driverConn 294 | } 295 | 296 | // Stmt implements a database/sql/driver.Stmt 297 | type Stmt struct { 298 | Stmt driver.Stmt 299 | hooks Hooks 300 | query string 301 | } 302 | 303 | func (stmt *Stmt) execContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) { 304 | if s, ok := stmt.Stmt.(driver.StmtExecContext); ok { 305 | return s.ExecContext(ctx, args) 306 | } 307 | 308 | values := make([]driver.Value, len(args)) 309 | for _, arg := range args { 310 | values[arg.Ordinal-1] = arg.Value 311 | } 312 | 313 | return stmt.Exec(values) 314 | } 315 | 316 | func (stmt *Stmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) { 317 | var err error 318 | 319 | list := namedValueToAny(args) 320 | 321 | // Exec `Before` Hooks 322 | if ctx, err = stmt.hooks.Before(ctx, stmt.query, list...); err != nil { 323 | return nil, err 324 | } 325 | 326 | results, err := stmt.execContext(ctx, args) 327 | if err != nil { 328 | return results, handlerErr(ctx, stmt.hooks, err, stmt.query, list...) 329 | } 330 | 331 | if _, err := stmt.hooks.After(ctx, stmt.query, list...); err != nil { 332 | return nil, err 333 | } 334 | 335 | return results, err 336 | } 337 | 338 | func (stmt *Stmt) queryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) { 339 | if s, ok := stmt.Stmt.(driver.StmtQueryContext); ok { 340 | return s.QueryContext(ctx, args) 341 | } 342 | 343 | values := make([]driver.Value, len(args)) 344 | for _, arg := range args { 345 | values[arg.Ordinal-1] = arg.Value 346 | } 347 | return stmt.Query(values) 348 | } 349 | 350 | func (stmt *Stmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) { 351 | var err error 352 | 353 | list := namedValueToAny(args) 354 | 355 | // Exec Before Hooks 356 | if ctx, err = stmt.hooks.Before(ctx, stmt.query, list...); err != nil { 357 | return nil, err 358 | } 359 | 360 | rows, err := stmt.queryContext(ctx, args) 361 | if err != nil { 362 | return rows, handlerErr(ctx, stmt.hooks, err, stmt.query, list...) 363 | } 364 | 365 | if _, err := stmt.hooks.After(ctx, stmt.query, list...); err != nil { 366 | return nil, err 367 | } 368 | 369 | return rows, err 370 | } 371 | 372 | func (stmt *Stmt) Close() error { return stmt.Stmt.Close() } 373 | func (stmt *Stmt) NumInput() int { return stmt.Stmt.NumInput() } 374 | func (stmt *Stmt) Exec(args []driver.Value) (driver.Result, error) { 375 | named := make([]driver.NamedValue, 0, len(args)) 376 | for i, a := range args { 377 | v := driver.NamedValue{ 378 | Ordinal: i + 1, 379 | Name: "", 380 | Value: a, 381 | } 382 | named = append(named, v) 383 | } 384 | return stmt.ExecContext(context.Background(), named) 385 | } 386 | func (stmt *Stmt) Query(args []driver.Value) (driver.Rows, error) { 387 | named := make([]driver.NamedValue, 0, len(args)) 388 | for i, a := range args { 389 | v := driver.NamedValue{ 390 | Ordinal: i + 1, 391 | Name: "", 392 | Value: a, 393 | } 394 | named = append(named, v) 395 | } 396 | return stmt.QueryContext(context.Background(), named) 397 | } 398 | 399 | func Wrap(driver driver.Driver, hooks Hooks) driver.Driver { 400 | return &Driver{driver, hooks} 401 | } 402 | 403 | func WrapConn(conn driver.Conn, hooks Hooks) driver.Conn { 404 | return &driverConn{conn, hooks} 405 | } 406 | 407 | func namedValueToAny(args []driver.NamedValue) []interface{} { 408 | list := make([]interface{}, len(args)) 409 | for i, a := range args { 410 | list[i] = a.Value 411 | } 412 | return list 413 | } 414 | -------------------------------------------------------------------------------- /tracer.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/kamalshkeir/ksmux" 10 | "github.com/kamalshkeir/lg" 11 | ) 12 | 13 | // Add at the top with other types 14 | type traceContextKey string 15 | 16 | const ( 17 | traceEnabledKey traceContextKey = "trace_enabled" 18 | ) 19 | 20 | // TraceData represents a single trace entry 21 | type TraceData struct { 22 | Query string // The SQL query 23 | Args []any // Query arguments 24 | Database string // Database name 25 | StartTime time.Time // When the query started 26 | Duration time.Duration // How long it took 27 | Error error // Any error that occurred 28 | } 29 | 30 | // Tracer handles query tracing functionality 31 | type Tracer struct { 32 | enabled bool 33 | traces []TraceData 34 | mu sync.RWMutex 35 | maxSize int // Maximum number of traces to keep 36 | } 37 | 38 | var ( 39 | defaultTracer = &Tracer{ 40 | enabled: false, 41 | traces: make([]TraceData, 0), 42 | maxSize: 500, // Default to keeping last 1000 traces 43 | } 44 | ) 45 | 46 | // WithTracing turns on tracing db + api 47 | func WithTracing() { 48 | SetMaxDBTraces(MaxDbTraces) 49 | defaultTracer.enabled = true 50 | // enable ksmux tracing 51 | ksmux.EnableTracing(nil) 52 | } 53 | 54 | // DisableTracing turns off query tracing 55 | func DisableTracing() { 56 | defaultTracer.enabled = false 57 | ksmux.DisableTracing() 58 | } 59 | 60 | // SetMaxDBTraces sets the maximum number of traces to keep 61 | func SetMaxDBTraces(max int) { 62 | defaultTracer.maxSize = max 63 | ksmux.SetMaxTraces(max) 64 | } 65 | 66 | // ClearDBTraces removes all stored traces 67 | func ClearDBTraces() { 68 | defaultTracer.mu.Lock() 69 | defaultTracer.traces = make([]TraceData, 0) 70 | defaultTracer.mu.Unlock() 71 | } 72 | 73 | // GetDBTraces returns all stored traces 74 | func GetDBTraces() []TraceData { 75 | defaultTracer.mu.RLock() 76 | defer defaultTracer.mu.RUnlock() 77 | return defaultTracer.traces 78 | } 79 | 80 | // addTrace adds a new trace entry 81 | func (t *Tracer) addTrace(trace TraceData) { 82 | if !t.enabled || trace.Query == "" { 83 | return 84 | } 85 | 86 | t.mu.Lock() 87 | defer t.mu.Unlock() 88 | 89 | // Add new trace 90 | t.traces = append(t.traces, trace) 91 | 92 | // Remove oldest traces if we exceed maxSize 93 | if len(t.traces) > t.maxSize { 94 | t.traces = t.traces[len(t.traces)-t.maxSize:] 95 | } 96 | } 97 | 98 | // TraceQuery wraps a query execution with tracing 99 | func TraceQuery(ctx context.Context, db *DatabaseEntity, query string, args ...any) (TraceData, error) { 100 | trace := TraceData{ 101 | Query: query, 102 | Args: args, 103 | Database: db.Name, 104 | StartTime: time.Now(), 105 | } 106 | 107 | // Execute the query 108 | var err error 109 | if ctx != nil { 110 | _, err = db.Conn.ExecContext(ctx, query, args...) 111 | } else { 112 | _, err = db.Conn.Exec(query, args...) 113 | } 114 | 115 | trace.Duration = time.Since(trace.StartTime) 116 | trace.Error = err 117 | 118 | // Add trace to storage 119 | defaultTracer.addTrace(trace) 120 | 121 | if err != nil { 122 | lg.ErrorC("Query failed", 123 | "query", query, 124 | "args", fmt.Sprint(args...), 125 | "duration", trace.Duration, 126 | "error", err) 127 | } else if logQueries { 128 | lg.InfoC("Query executed", 129 | "query", query, 130 | "args", fmt.Sprint(args...), 131 | "duration", trace.Duration) 132 | } 133 | 134 | return trace, err 135 | } 136 | -------------------------------------------------------------------------------- /triggers.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | "time" 7 | 8 | "github.com/kamalshkeir/kmap" 9 | "github.com/kamalshkeir/lg" 10 | ) 11 | 12 | func init() { 13 | if useCache { 14 | go RunEvery(FlushCacheEvery, func(cancelChan chan struct{}) { 15 | if !useCache { 16 | cancelChan <- struct{}{} 17 | } 18 | flushCache() 19 | }) 20 | } 21 | } 22 | 23 | var ( 24 | hooks = kmap.New[string, []HookFunc]() 25 | ) 26 | 27 | type sizeDb struct { 28 | Size float64 29 | } 30 | type TriggersQueue struct { 31 | Id uint `korm:"pk"` 32 | Data string `korm:"text"` 33 | } 34 | 35 | type HookFunc func(HookData) 36 | 37 | type HookData struct { 38 | Pk string `json:"pk"` 39 | Table string `json:"table"` 40 | Operation string `json:"operation"` 41 | Data map[string]any `json:"data"` 42 | Old map[string]any `json:"old"` 43 | New map[string]any `json:"new"` 44 | } 45 | 46 | func OnInsert(fn HookFunc) { 47 | if v, ok := hooks.Get("insert"); ok { 48 | v = append(v, fn) 49 | go hooks.Set("insert", v) 50 | } else { 51 | hooks.Set("insert", []HookFunc{fn}) 52 | } 53 | } 54 | 55 | func OnSet(fn HookFunc) { 56 | if v, ok := hooks.Get("update"); ok { 57 | v = append(v, fn) 58 | go hooks.Set("update", v) 59 | } else { 60 | hooks.Set("update", []HookFunc{fn}) 61 | } 62 | } 63 | 64 | func OnDelete(fn HookFunc) { 65 | if v, ok := hooks.Get("delete"); ok { 66 | v = append(v, fn) 67 | go hooks.Set("delete", v) 68 | } else { 69 | hooks.Set("delete", []HookFunc{fn}) 70 | } 71 | } 72 | 73 | func OnDrop(fn HookFunc) { 74 | if v, ok := hooks.Get("drop"); ok { 75 | v = append(v, fn) 76 | go hooks.Set("drop", v) 77 | } else { 78 | hooks.Set("drop", []HookFunc{fn}) 79 | } 80 | } 81 | 82 | func initCacheHooks() { 83 | // Add hook for data changes 84 | OnInsert(func(hd HookData) { 85 | flushCache() 86 | }) 87 | 88 | // Add hook for updates 89 | OnSet(func(hd HookData) { 90 | flushCache() 91 | }) 92 | 93 | // Add hook for deletes 94 | OnDelete(func(hd HookData) { 95 | flushCache() 96 | }) 97 | 98 | // Add hook for drops 99 | OnDrop(func(hd HookData) { 100 | flushCache() 101 | }) 102 | } 103 | 104 | // AddTrigger add trigger tablename_trig if col empty and tablename_trig_col if not 105 | func AddTrigger(onTable, col, bf_af_UpdateInsertDelete string, stmt string, dbName ...string) { 106 | stat := []string{} 107 | if len(dbName) == 0 { 108 | dbName = append(dbName, databases[0].Name) 109 | } 110 | var dialect = "" 111 | db, err := GetMemoryDatabase(dbName[0]) 112 | if !lg.CheckError(err) { 113 | dialect = db.Dialect 114 | } 115 | switch dialect { 116 | case "sqlite", "sqlite3", "": 117 | // Drop existing trigger first 118 | dropSt := "DROP TRIGGER IF EXISTS " + onTable + "_trig" 119 | if strings.Contains(bf_af_UpdateInsertDelete, "INSERT") { 120 | dropSt += "_insert" 121 | } else if strings.Contains(bf_af_UpdateInsertDelete, "UPDATE") { 122 | dropSt += "_update" 123 | } else if strings.Contains(bf_af_UpdateInsertDelete, "DELETE") { 124 | dropSt += "_delete" 125 | } 126 | if col != "" { 127 | dropSt += "_" + col 128 | } 129 | stat = append(stat, dropSt) 130 | 131 | // Create new trigger with unique name 132 | st := "CREATE TRIGGER IF NOT EXISTS " + onTable + "_trig" 133 | if strings.Contains(bf_af_UpdateInsertDelete, "INSERT") { 134 | st += "_insert" 135 | } else if strings.Contains(bf_af_UpdateInsertDelete, "UPDATE") { 136 | st += "_update" 137 | } else if strings.Contains(bf_af_UpdateInsertDelete, "DELETE") { 138 | st += "_delete" 139 | } 140 | if col != "" { 141 | st += "_" + col 142 | } 143 | st += " " + bf_af_UpdateInsertDelete 144 | if col != "" { 145 | st += " OF " + col 146 | } 147 | st += " ON " + onTable + " FOR EACH ROW" 148 | st += " BEGIN " + stmt + "; END;" 149 | stat = append(stat, st) 150 | case POSTGRES, "cockroach", "pg", "cockroachdb": 151 | // Drop existing trigger first 152 | if col != "" { 153 | stat = append(stat, `DROP TRIGGER IF EXISTS "`+onTable+`_trig_`+col+`" ON "`+onTable+`";`) 154 | stat = append(stat, `DROP FUNCTION IF EXISTS "`+onTable+`_trig_`+col+`_func"();`) 155 | } 156 | stat = append(stat, `DROP TRIGGER IF EXISTS "`+onTable+`_trig_`+strings.ToLower(strings.Split(bf_af_UpdateInsertDelete, " ")[1])+`" ON "`+onTable+`";`) 157 | stat = append(stat, `DROP FUNCTION IF EXISTS "`+onTable+`_trig_`+strings.ToLower(strings.Split(bf_af_UpdateInsertDelete, " ")[1])+`_func"();`) 158 | 159 | // Create function for trigger 160 | name := onTable + "_trig" 161 | if col != "" { 162 | name += "_" + col 163 | } else { 164 | name += "_" + strings.ToLower(strings.Split(bf_af_UpdateInsertDelete, " ")[1]) 165 | } 166 | st := `CREATE OR REPLACE FUNCTION "` + name + `_func"() RETURNS trigger AS $$ 167 | BEGIN 168 | RAISE NOTICE 'Trigger executing for %s', TG_TABLE_NAME; 169 | ` + stmt + ` 170 | RAISE NOTICE 'Trigger completed for %s', TG_TABLE_NAME; 171 | IF (TG_OP = 'DELETE') THEN 172 | RETURN OLD; 173 | ELSE 174 | RETURN NEW; 175 | END IF; 176 | END; 177 | $$ LANGUAGE plpgsql;` 178 | stat = append(stat, st) 179 | 180 | // Create trigger 181 | trigCreate := `CREATE TRIGGER "` + name + `" ` + bf_af_UpdateInsertDelete + ` ON "` + onTable + `" FOR EACH ROW EXECUTE FUNCTION "` + name + `_func"();` 182 | stat = append(stat, trigCreate) 183 | case MYSQL, MARIA: 184 | // Drop existing triggers first 185 | dropTriggerName := onTable + "_trig" 186 | if strings.Contains(bf_af_UpdateInsertDelete, "INSERT") { 187 | dropTriggerName += "_insert" 188 | } else if strings.Contains(bf_af_UpdateInsertDelete, "UPDATE") { 189 | dropTriggerName += "_update" 190 | } else if strings.Contains(bf_af_UpdateInsertDelete, "DELETE") { 191 | dropTriggerName += "_delete" 192 | } 193 | if col != "" { 194 | dropTriggerName += "_" + col 195 | } 196 | stat = append(stat, "DROP TRIGGER IF EXISTS `"+dropTriggerName+"`;") 197 | 198 | // Create trigger with operation-specific name 199 | st := "CREATE TRIGGER `" + dropTriggerName + "` " + bf_af_UpdateInsertDelete + " ON `" + onTable + "` FOR EACH ROW BEGIN " + stmt 200 | if !strings.HasSuffix(stmt, ";") { 201 | st += ";" 202 | } 203 | st += " END;" 204 | stat = append(stat, st) 205 | default: 206 | return 207 | } 208 | 209 | if Debug { 210 | lg.InfoC("debug", "stat", stat) 211 | } 212 | 213 | for _, s := range stat { 214 | err := Exec(dbName[0], s) 215 | if err != nil { 216 | if !strings.Contains(err.Error(), "Trigger does not exist") { 217 | lg.ErrorC("could not add trigger", "err", err) 218 | return 219 | } 220 | } 221 | } 222 | } 223 | 224 | // DropTrigger drop trigger tablename_trig if column empty and tablename_trig_column if not 225 | func DropTrigger(tableName, column string, dbName ...string) { 226 | stat := "DROP TRIGGER " + tableName + "_trig" 227 | if column != "" { 228 | stat += "_" + column 229 | } 230 | stat += ";" 231 | if Debug { 232 | lg.InfoC("debug", "stat", stat) 233 | } 234 | n := databases[0].Name 235 | if len(dbName) > 0 { 236 | n = dbName[0] 237 | } 238 | err := Exec(n, stat) 239 | if err != nil { 240 | if !strings.Contains(err.Error(), "Trigger does not exist") { 241 | return 242 | } 243 | lg.CheckError(err) 244 | } 245 | } 246 | 247 | func StorageSize(dbName string) float64 { 248 | db, err := GetMemoryDatabase(dbName) 249 | if lg.CheckError(err) { 250 | return -1 251 | } 252 | var statement string 253 | switch db.Dialect { 254 | case SQLITE: 255 | statement = "select (page_count * page_size) as size FROM pragma_page_count(), pragma_page_size();" 256 | case POSTGRES, COCKROACH: 257 | statement = "select pg_database_size('" + db.Name + "') as size;" 258 | case MYSQL, MARIA: 259 | statement = "select SUM(data_length + index_length) as size FROM information_schema.tables WHERE table_schema = '" + db.Name + "';" 260 | default: 261 | return -1 262 | } 263 | 264 | m, err := Model[sizeDb]().Database(db.Name).QueryS(statement) 265 | if lg.CheckError(err) { 266 | return -1 267 | } 268 | if len(m) > 0 { 269 | return m[0].Size / (1024 * 1024) 270 | } 271 | return -1 272 | } 273 | 274 | // AddChangesTrigger 275 | func AddChangesTrigger(tableName string, dbName ...string) error { 276 | dName := defaultDB 277 | if len(dbName) > 0 { 278 | dName = dbName[0] 279 | } 280 | 281 | db, err := GetMemoryDatabase(dName) 282 | if err != nil { 283 | return err 284 | } 285 | 286 | var t TableEntity 287 | for _, tt := range db.Tables { 288 | if tt.Name == tableName { 289 | t = tt 290 | } 291 | } 292 | 293 | // Get table columns for constructing the change data 294 | cols := t.Types 295 | if len(cols) == 0 { 296 | return ErrTableNotFound 297 | } 298 | 299 | switch db.Dialect { 300 | case SQLITE: 301 | // SQLite trigger for each operation 302 | insertStmt := `INSERT INTO _triggers_queue(data) VALUES (json_object('operation','insert','table','` + tableName + `','data',json_object(` + buildJsonFields("NEW", cols) + `)))` 303 | updateStmt := `INSERT INTO _triggers_queue(data) VALUES (json_object('operation','update','table','` + tableName + `','old',json_object(` + buildJsonFields("OLD", cols) + `),'new',json_object(` + buildJsonFields("NEW", cols) + `)))` 304 | deleteStmt := `INSERT INTO _triggers_queue(data) VALUES (json_object('operation','delete','table','` + tableName + `','data',json_object(` + buildJsonFields("OLD", cols) + `)))` 305 | 306 | // Drop all existing triggers first 307 | Exec(dName, `DROP TRIGGER IF EXISTS `+tableName+`_trig_insert`) 308 | Exec(dName, `DROP TRIGGER IF EXISTS `+tableName+`_trig_update`) 309 | Exec(dName, `DROP TRIGGER IF EXISTS `+tableName+`_trig_delete`) 310 | 311 | // Create triggers for each operation with unique names 312 | AddTrigger(tableName, "", "AFTER INSERT", insertStmt, dName) 313 | AddTrigger(tableName, "", "AFTER UPDATE", updateStmt, dName) 314 | AddTrigger(tableName, "", "AFTER DELETE", deleteStmt, dName) 315 | 316 | // Start background worker to publish changes 317 | go func() { 318 | for { 319 | tx, err := db.Conn.Begin() 320 | if err != nil { 321 | time.Sleep(time.Second) 322 | continue 323 | } 324 | 325 | // Get rows with exclusive lock 326 | rows, err := tx.Query("SELECT rowid, data FROM _triggers_queue") 327 | if err != nil { 328 | tx.Rollback() 329 | time.Sleep(time.Second) 330 | continue 331 | } 332 | hasRows := false 333 | doFlush := false 334 | for rows.Next() { 335 | doFlush = true 336 | hasRows = true 337 | var jsonData string 338 | var rowid int64 339 | if err := rows.Scan(&rowid, &jsonData); err != nil { 340 | continue 341 | } 342 | 343 | ddd := HookData{} 344 | err = json.Unmarshal([]byte(jsonData), &ddd) 345 | if lg.CheckError(err) { 346 | continue 347 | } 348 | ddd.Pk = t.Pk 349 | // Delete processed row within transaction 350 | if _, err := tx.Exec("DELETE FROM _triggers_queue WHERE rowid = ?", rowid); err == nil { 351 | if hhh, ok := hooks.Get(ddd.Operation); ok { 352 | for _, h := range hhh { 353 | h(ddd) 354 | } 355 | } 356 | } 357 | } 358 | if doFlush { 359 | flushCache() 360 | } 361 | rows.Close() 362 | if !hasRows { 363 | tx.Rollback() 364 | time.Sleep(time.Second) 365 | continue 366 | } 367 | 368 | if err := tx.Commit(); err != nil { 369 | lg.Error("Failed to commit transaction:", err) 370 | tx.Rollback() 371 | } 372 | 373 | time.Sleep(time.Second) 374 | } 375 | }() 376 | 377 | case POSTGRES: 378 | // Postgres trigger for each operation 379 | insertStmt := `INSERT INTO "_triggers_queue"(data) VALUES (jsonb_build_object('operation', 'insert', 'table', '` + tableName + `', 'data', to_jsonb(NEW)));` 380 | updateStmt := `INSERT INTO "_triggers_queue"(data) VALUES (jsonb_build_object('operation', 'update', 'table', '` + tableName + `', 'old', to_jsonb(OLD), 'new', to_jsonb(NEW)));` 381 | deleteStmt := `INSERT INTO "_triggers_queue"(data) VALUES (jsonb_build_object('operation', 'delete', 'table', '` + tableName + `', 'data', to_jsonb(OLD)));` 382 | 383 | // Create triggers for each operation 384 | AddTrigger(tableName, "", "AFTER INSERT", insertStmt, dName) 385 | AddTrigger(tableName, "", "AFTER UPDATE", updateStmt, dName) 386 | AddTrigger(tableName, "", "AFTER DELETE", deleteStmt, dName) 387 | 388 | // Start background worker to publish changes 389 | go func() { 390 | for { 391 | // Start transaction 392 | tx, err := db.Conn.Begin() 393 | if err != nil { 394 | time.Sleep(time.Second) 395 | continue 396 | } 397 | 398 | // Get and lock a single row 399 | var jsonData string 400 | row := tx.QueryRow("SELECT data FROM \"_triggers_queue\" LIMIT 1 FOR UPDATE SKIP LOCKED") 401 | err = row.Scan(&jsonData) 402 | if err != nil { 403 | tx.Rollback() 404 | time.Sleep(time.Second) 405 | continue 406 | } 407 | 408 | ddd := HookData{} 409 | err = json.Unmarshal([]byte(jsonData), &ddd) 410 | if lg.CheckError(err) { 411 | continue 412 | } 413 | ddd.Pk = t.Pk 414 | // Delete the processed row 415 | _, err = tx.Exec("DELETE FROM \"_triggers_queue\" WHERE data = $1", jsonData) 416 | if err != nil { 417 | tx.Rollback() 418 | time.Sleep(time.Second) 419 | continue 420 | } 421 | 422 | // Commit transaction 423 | err = tx.Commit() 424 | if err != nil { 425 | tx.Rollback() 426 | time.Sleep(time.Second) 427 | continue 428 | } 429 | flushCache() 430 | if hhh, ok := hooks.Get(ddd.Operation); ok { 431 | for _, h := range hhh { 432 | h(ddd) 433 | } 434 | } 435 | time.Sleep(time.Second) 436 | } 437 | }() 438 | 439 | case MYSQL: 440 | // MySQL trigger for each operation 441 | insertStmt := `INSERT INTO ` + "`_triggers_queue`" + `(data) VALUES (JSON_OBJECT('operation', 'insert', 'table', '` + tableName + `', 'data', JSON_OBJECT(` + buildJsonFields("NEW", cols) + `)))` 442 | updateStmt := `INSERT INTO ` + "`_triggers_queue`" + `(data) VALUES (JSON_OBJECT('operation', 'update', 'table', '` + tableName + `', 'old', JSON_OBJECT(` + buildJsonFields("OLD", cols) + `), 'new', JSON_OBJECT(` + buildJsonFields("NEW", cols) + `)))` 443 | deleteStmt := `INSERT INTO ` + "`_triggers_queue`" + `(data) VALUES (JSON_OBJECT('operation', 'delete', 'table', '` + tableName + `', 'data', JSON_OBJECT(` + buildJsonFields("OLD", cols) + `)))` 444 | 445 | if Debug { 446 | lg.InfoC("debug mysql trigger statements", 447 | "insert", insertStmt, 448 | "update", updateStmt, 449 | "delete", deleteStmt) 450 | } 451 | 452 | // Create triggers for each operation 453 | AddTrigger(tableName, "", "AFTER INSERT", insertStmt, dName) 454 | AddTrigger(tableName, "", "AFTER UPDATE", updateStmt, dName) 455 | AddTrigger(tableName, "", "AFTER DELETE", deleteStmt, dName) 456 | 457 | // Start background worker to publish changes 458 | go func() { 459 | for { 460 | // Start transaction 461 | tx, err := db.Conn.Begin() 462 | if err != nil { 463 | time.Sleep(time.Second) 464 | continue 465 | } 466 | 467 | // Get and lock a single row 468 | var jsonData string 469 | var id int64 470 | row := tx.QueryRow("SELECT id, JSON_UNQUOTE(data) FROM `_triggers_queue` LIMIT 1 FOR UPDATE") 471 | err = row.Scan(&id, &jsonData) 472 | if err != nil { 473 | tx.Rollback() 474 | time.Sleep(time.Second) 475 | continue 476 | } 477 | 478 | // Delete the row within the transaction 479 | _, err = tx.Exec("DELETE FROM `_triggers_queue` WHERE id = ?", id) 480 | if err != nil { 481 | tx.Rollback() 482 | time.Sleep(time.Second) 483 | continue 484 | } 485 | 486 | // Commit transaction 487 | err = tx.Commit() 488 | if err != nil { 489 | tx.Rollback() 490 | time.Sleep(time.Second) 491 | continue 492 | } 493 | 494 | // Publish change only after successful commit 495 | ddd := HookData{} 496 | ddd.Pk = t.Pk 497 | err = json.Unmarshal([]byte(jsonData), &ddd) 498 | if lg.CheckError(err) { 499 | continue 500 | } 501 | flushCache() 502 | if hhh, ok := hooks.Get(ddd.Operation); ok { 503 | for _, h := range hhh { 504 | h(ddd) 505 | } 506 | } 507 | time.Sleep(time.Second) 508 | } 509 | }() 510 | } 511 | 512 | return nil 513 | } 514 | 515 | // Helper function to build JSON field pairs for triggers 516 | func buildJsonFields(prefix string, cols map[string]string) string { 517 | pairs := make([]string, 0, len(cols)) 518 | for col := range cols { 519 | pairs = append(pairs, "'"+col+"',"+prefix+"."+col) 520 | } 521 | return strings.Join(pairs, ",") 522 | } 523 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | var ( 8 | MaxDbTraces = 50 9 | ) 10 | 11 | const ( 12 | MIGRATION_FOLDER = "migrations" 13 | SQLITE Dialect = "sqlite3" 14 | POSTGRES Dialect = "postgres" 15 | MYSQL Dialect = "mysql" 16 | MARIA Dialect = "maria" 17 | COCKROACH Dialect = "cockroach" 18 | ) 19 | 20 | // Dialect db dialects are SQLITE, POSTGRES, MYSQL, MARIA, COCKROACH 21 | type Dialect = string 22 | 23 | // DatabaseEntity hold table state 24 | type TableEntity struct { 25 | Types map[string]string 26 | ModelTypes map[string]string 27 | Tags map[string][]string 28 | Columns []string 29 | Fkeys []kormFkey 30 | Pk string 31 | Name string 32 | } 33 | 34 | type TablesInfos struct { 35 | Id uint 36 | Name string 37 | Pk string 38 | Types map[string]string 39 | ModelTypes map[string]string 40 | Tags map[string][]string 41 | Columns []string 42 | Fkeys []string 43 | } 44 | 45 | // DatabaseEntity hold memory db state 46 | type DatabaseEntity struct { 47 | Tables []TableEntity 48 | Name string 49 | Dialect string 50 | Conn *sql.DB 51 | } 52 | 53 | type dbCache struct { 54 | limit int 55 | page int 56 | database string 57 | table string 58 | selected string 59 | orderBys string 60 | whereQuery string 61 | offset string 62 | statement string 63 | args string 64 | } 65 | 66 | type DocsSuccess struct { 67 | Success string `json:"success" example:"success message"` 68 | } 69 | 70 | type DocsError struct { 71 | Error string `json:"error" example:"error message"` 72 | } 73 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package korm 2 | 3 | import ( 4 | "crypto/rand" 5 | "database/sql" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "net/mail" 12 | "os" 13 | "reflect" 14 | "runtime" 15 | "strings" 16 | "testing" 17 | "time" 18 | 19 | "github.com/kamalshkeir/kmap" 20 | "github.com/kamalshkeir/kstrct" 21 | ) 22 | 23 | func GenerateUUID() string { 24 | var uuid [16]byte 25 | _, err := io.ReadFull(rand.Reader, uuid[:]) 26 | if err != nil { 27 | return "" 28 | } 29 | uuid[6] = (uuid[6] & 0x0f) | 0x40 // Version 4 30 | uuid[8] = (uuid[8] & 0x3f) | 0x80 // Variant is 10 31 | var buf [36]byte 32 | encodeHex(buf[:], uuid) 33 | return string(buf[:]) 34 | } 35 | 36 | func encodeHex(dst []byte, uuid [16]byte) { 37 | hex.Encode(dst, uuid[:4]) 38 | dst[8] = '-' 39 | hex.Encode(dst[9:13], uuid[4:6]) 40 | dst[13] = '-' 41 | hex.Encode(dst[14:18], uuid[6:8]) 42 | dst[18] = '-' 43 | hex.Encode(dst[19:23], uuid[8:10]) 44 | dst[23] = '-' 45 | hex.Encode(dst[24:], uuid[10:]) 46 | } 47 | 48 | func RunEvery(t time.Duration, fn func(cancelChan chan struct{})) { 49 | //Usage : go RunEvery(2 * time.Second,func(){}) 50 | cancel := make(chan struct{}) 51 | fn(cancel) 52 | c := time.NewTicker(t) 53 | loop: 54 | for { 55 | select { 56 | case <-c.C: 57 | fn(cancel) 58 | case <-cancel: 59 | break loop 60 | } 61 | } 62 | } 63 | 64 | func SliceContains[T comparable](elems []T, vs ...T) bool { 65 | for _, s := range elems { 66 | for _, v := range vs { 67 | if v == s { 68 | return true 69 | } 70 | } 71 | } 72 | return false 73 | } 74 | 75 | func DifferenceBetweenSlices[T comparable](slice1 []T, slice2 []T) []T { 76 | var diff []T 77 | 78 | // Loop two times, first to find slice1 strings not in slice2, 79 | // second loop to find slice2 strings not in slice1 80 | for i := 0; i < 2; i++ { 81 | for _, s1 := range slice1 { 82 | found := false 83 | for _, s2 := range slice2 { 84 | if s1 == s2 { 85 | found = true 86 | break 87 | } 88 | } 89 | // String not found. We add it to return slice 90 | if !found { 91 | diff = append(diff, s1) 92 | } 93 | } 94 | // Swap the slices, only if it was the first loop 95 | if i == 0 { 96 | slice1, slice2 = slice2, slice1 97 | } 98 | } 99 | 100 | return diff 101 | } 102 | 103 | func RemoveFromSlice[T comparable](slice *[]T, elemsToRemove ...T) { 104 | for i, elem := range *slice { 105 | for _, e := range elemsToRemove { 106 | if e == elem { 107 | *slice = append((*slice)[:i], (*slice)[i+1:]...) 108 | } 109 | } 110 | } 111 | } 112 | 113 | // Benchmark benchmark a function 114 | func Benchmark(f func(), name string, iterations int) { 115 | // Start the timer 116 | start := time.Now() 117 | 118 | // Run the function multiple times 119 | var allocs int64 120 | for i := 0; i < iterations; i++ { 121 | allocs += int64(testing.AllocsPerRun(1, f)) 122 | } 123 | 124 | // Stop the timer and calculate the elapsed time 125 | elapsed := time.Since(start) 126 | 127 | // Calculate the number of operations per second 128 | opsPerSec := float64(iterations) / elapsed.Seconds() 129 | 130 | // Calculate the number of allocations per operation 131 | allocsPerOp := float64(allocs) / float64(iterations) 132 | 133 | // Print the results 134 | fmt.Println("---------------------------") 135 | fmt.Println("Function", name) 136 | fmt.Printf("Operations per second: %f\n", opsPerSec) 137 | fmt.Printf("Allocations per operation: %f\n", allocsPerOp) 138 | fmt.Println("---------------------------") 139 | } 140 | 141 | // GetMemoryTable get a table from memory for specified or first connected db 142 | func GetMemoryTable(tbName string, dbName ...string) (TableEntity, error) { 143 | dName := databases[0].Name 144 | if len(dbName) > 0 { 145 | dName = dbName[0] 146 | } 147 | db, err := GetMemoryDatabase(dName) 148 | if err != nil { 149 | return TableEntity{}, err 150 | } 151 | for _, t := range db.Tables { 152 | if t.Name == tbName { 153 | return t, nil 154 | } 155 | } 156 | return TableEntity{}, errors.New("nothing found") 157 | } 158 | 159 | // GetMemoryTable get a table from memory for specified or first connected db 160 | func GetMemoryTableAndDB(tbName string, dbName ...string) (TableEntity, DatabaseEntity, error) { 161 | dName := databases[0].Name 162 | if len(dbName) > 0 { 163 | dName = dbName[0] 164 | } 165 | db, err := GetMemoryDatabase(dName) 166 | if err != nil { 167 | return TableEntity{}, DatabaseEntity{}, err 168 | } 169 | for _, t := range db.Tables { 170 | if t.Name == tbName { 171 | return t, *db, nil 172 | } 173 | } 174 | return TableEntity{}, DatabaseEntity{}, errors.New("nothing found") 175 | } 176 | 177 | // GetMemoryDatabases get all databases from memory 178 | func GetMemoryDatabases() []DatabaseEntity { 179 | return databases 180 | } 181 | 182 | // GetMemoryDatabase return the first connected database korm.DefaultDatabase if dbName "" or "default" else the matched db 183 | func GetMemoryDatabase(dbName string) (*DatabaseEntity, error) { 184 | if defaultDB == "" { 185 | defaultDB = databases[0].Name 186 | } 187 | switch dbName { 188 | case "", "default": 189 | for i := range databases { 190 | if databases[i].Name == defaultDB { 191 | return &databases[i], nil 192 | } 193 | } 194 | return nil, errors.New(dbName + "database not found") 195 | default: 196 | for i := range databases { 197 | if databases[i].Name == dbName { 198 | return &databases[i], nil 199 | } 200 | } 201 | return nil, errors.New(dbName + "database not found") 202 | } 203 | } 204 | 205 | func DownloadFile(filepath string, url string) error { 206 | // Get the data 207 | resp, err := http.Get(url) 208 | if err != nil { 209 | return err 210 | } 211 | defer resp.Body.Close() 212 | 213 | // Create the file 214 | out, err := os.Create(filepath) 215 | if err != nil { 216 | return err 217 | } 218 | defer out.Close() 219 | 220 | // Write the body to file 221 | _, err = io.Copy(out, resp.Body) 222 | return err 223 | } 224 | 225 | // getStructInfos very useful to access all struct fields data using reflect package 226 | func getStructInfos[T any](strctt *T, ignoreZeroValues ...bool) (fields []string, fValues map[string]any, fTypes map[string]string, fTags map[string][]string) { 227 | fields = []string{} 228 | fValues = map[string]any{} 229 | fTypes = map[string]string{} 230 | fTags = map[string][]string{} 231 | 232 | s := reflect.ValueOf(strctt).Elem() 233 | typeOfT := s.Type() 234 | for i := 0; i < s.NumField(); i++ { 235 | f := s.Field(i) 236 | fname := typeOfT.Field(i).Name 237 | fname = kstrct.ToSnakeCase(fname) 238 | fvalue := f.Interface() 239 | ftype := f.Type() 240 | 241 | if len(ignoreZeroValues) > 0 && ignoreZeroValues[0] && strings.Contains(ftype.Name(), "Time") { 242 | if v, ok := fvalue.(time.Time); ok { 243 | if v.IsZero() { 244 | continue 245 | } 246 | } 247 | } 248 | fields = append(fields, fname) 249 | if f.Type().Kind() == reflect.Ptr { 250 | fTypes[fname] = f.Type().Elem().String() 251 | } else { 252 | fTypes[fname] = f.Type().String() 253 | } 254 | fValues[fname] = fvalue 255 | if ftag, ok := typeOfT.Field(i).Tag.Lookup("korm"); ok { 256 | tags := strings.Split(ftag, ";") 257 | fTags[fname] = tags 258 | } 259 | } 260 | return fields, fValues, fTypes, fTags 261 | } 262 | 263 | func indexExists(conn *sql.DB, tableName, indexName string, dialect Dialect) bool { 264 | var query string 265 | switch dialect { 266 | case SQLITE: 267 | query = `SELECT name FROM sqlite_master 268 | WHERE type='index' AND tbl_name=? AND name=?` 269 | case POSTGRES: 270 | query = `SELECT indexname FROM pg_indexes 271 | WHERE tablename = $1 AND indexname = $2` 272 | case MYSQL, MARIA: 273 | query = `SELECT INDEX_NAME FROM information_schema.statistics 274 | WHERE table_name = ? AND index_name = ?` 275 | default: 276 | return false 277 | } 278 | 279 | var name string 280 | err := conn.QueryRow(query, tableName, indexName).Scan(&name) 281 | return err == nil 282 | } 283 | 284 | // foreignkeyStat options are : "cascade","donothing", "noaction","setnull", "null","setdefault", "default" 285 | func foreignkeyStat(fName, toTable, onDelete, onUpdate string) string { 286 | toPk := "id" 287 | if strings.Contains(toTable, ".") { 288 | sp := strings.Split(toTable, ".") 289 | if len(sp) == 2 { 290 | toPk = sp[1] 291 | } 292 | } 293 | fkey := "FOREIGN KEY (" + fName + ") REFERENCES " + toTable + "(" + toPk + ")" 294 | switch onDelete { 295 | case "cascade": 296 | fkey += " ON DELETE CASCADE" 297 | case "donothing", "noaction": 298 | fkey += " ON DELETE NO ACTION" 299 | case "setnull", "null": 300 | fkey += " ON DELETE SET NULL" 301 | case "setdefault", "default": 302 | fkey += " ON DELETE SET DEFAULT" 303 | } 304 | 305 | switch onUpdate { 306 | case "cascade": 307 | fkey += " ON UPDATE CASCADE" 308 | case "donothing", "noaction": 309 | fkey += " ON UPDATE NO ACTION" 310 | case "setnull", "null": 311 | fkey += " ON UPDATE SET NULL" 312 | case "setdefault", "default": 313 | fkey += " ON UPDATE SET DEFAULT" 314 | } 315 | return fkey 316 | } 317 | 318 | func IsValidEmail(email string) bool { 319 | _, err := mail.ParseAddress(email) 320 | return err == nil 321 | } 322 | 323 | // SetCacheMaxMemory set max size of each cache cacheAllS AllM, minimum of 50 ... 324 | func SetCacheMaxMemory(megaByte int) { 325 | if megaByte < 100 { 326 | megaByte = 100 327 | } 328 | cacheMaxMemoryMb = megaByte 329 | caches = kmap.New[string, *kmap.SafeMap[dbCache, any]](cacheMaxMemoryMb) 330 | } 331 | 332 | // SystemMetrics holds memory and runtime statistics for the application 333 | type SystemMetrics struct { 334 | // Memory metrics 335 | HeapMemoryMB float64 // Currently allocated heap memory in MB 336 | SystemMemoryMB float64 // Total memory obtained from system in MB 337 | StackMemoryMB float64 // Memory used by goroutine stacks 338 | HeapObjects uint64 // Number of allocated heap objects 339 | HeapReleasedMB float64 // Memory released to the OS in MB 340 | 341 | // Garbage Collection metrics 342 | NumGC uint32 // Number of completed GC cycles 343 | LastGCTimeSec float64 // Time since last garbage collection in seconds 344 | GCCPUPercent float64 // Fraction of CPU time used by GC (0-100) 345 | 346 | // Runtime metrics 347 | NumGoroutines int // Current number of goroutines 348 | NumCPU int // Number of logical CPUs 349 | GoVersion string // Go version used to build the program 350 | } 351 | 352 | // GetSystemMetrics returns memory and runtime statistics for the application 353 | func GetSystemMetrics() SystemMetrics { 354 | var metrics SystemMetrics 355 | var m runtime.MemStats 356 | runtime.ReadMemStats(&m) 357 | 358 | // Memory metrics 359 | metrics.HeapMemoryMB = float64(m.Alloc) / (1024 * 1024) 360 | metrics.SystemMemoryMB = float64(m.Sys) / (1024 * 1024) 361 | metrics.StackMemoryMB = float64(m.StackSys) / (1024 * 1024) 362 | metrics.HeapObjects = m.HeapObjects 363 | metrics.HeapReleasedMB = float64(m.HeapReleased) / (1024 * 1024) 364 | 365 | // GC metrics 366 | metrics.NumGC = m.NumGC 367 | metrics.LastGCTimeSec = time.Since(time.Unix(0, int64(m.LastGC))).Seconds() 368 | metrics.GCCPUPercent = m.GCCPUFraction * 100 369 | 370 | // Runtime metrics 371 | metrics.NumGoroutines = runtime.NumGoroutine() 372 | metrics.NumCPU = runtime.NumCPU() 373 | metrics.GoVersion = runtime.Version() 374 | 375 | return metrics 376 | } 377 | 378 | // PrintSystemMetrics prints the current system metrics 379 | func PrintSystemMetrics() { 380 | metrics := GetSystemMetrics() 381 | fmt.Println("Memory Metrics:") 382 | fmt.Printf(" Heap Memory: %.2f MB\n", metrics.HeapMemoryMB) 383 | fmt.Printf(" System Memory: %.2f MB\n", metrics.SystemMemoryMB) 384 | fmt.Printf(" Stack Memory: %.2f MB\n", metrics.StackMemoryMB) 385 | fmt.Printf(" Heap Objects: %d\n", metrics.HeapObjects) 386 | fmt.Printf(" Heap Released: %.2f MB\n", metrics.HeapReleasedMB) 387 | 388 | fmt.Println("\nGarbage Collection:") 389 | fmt.Printf(" GC Cycles: %d\n", metrics.NumGC) 390 | fmt.Printf(" Last GC: %.2f seconds ago\n", metrics.LastGCTimeSec) 391 | fmt.Printf(" GC CPU Usage: %.2f%%\n", metrics.GCCPUPercent) 392 | 393 | fmt.Println("\nRuntime Info:") 394 | fmt.Printf(" Goroutines: %d\n", metrics.NumGoroutines) 395 | fmt.Printf(" CPUs: %d\n", metrics.NumCPU) 396 | fmt.Printf(" Go Version: %s\n", metrics.GoVersion) 397 | } 398 | --------------------------------------------------------------------------------