├── 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 |
--------------------------------------------------------------------------------