mirror of
https://github.com/muety/wakapi.git
synced 2025-12-05 22:20:24 -08:00
implement
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: [sqlite, postgres, mysql, mariadb, sqlserver]
|
||||
db: [sqlite, postgres, mysql, mariadb, mssql]
|
||||
|
||||
steps:
|
||||
- name: Check out code into the Go module directory
|
||||
|
||||
@@ -173,7 +173,7 @@ You can specify configuration options either via a config file (default: `config
|
||||
| `db.user` /<br> `WAKAPI_DB_USER` | - | Database user |
|
||||
| `db.password` /<br> `WAKAPI_DB_PASSWORD` | - | Database password |
|
||||
| `db.name` /<br> `WAKAPI_DB_NAME` | `wakapi_db.db` | Database name |
|
||||
| `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`, `sqlserver`) |
|
||||
| `db.dialect` /<br> `WAKAPI_DB_TYPE` | `sqlite3` | Database type (one of `sqlite3`, `mysql`, `postgres`, `cockroach`, `mssql`) |
|
||||
| `db.charset` /<br> `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) |
|
||||
| `db.max_conn` /<br> `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections |
|
||||
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||
|
||||
@@ -50,10 +50,10 @@ db:
|
||||
user: # leave blank when using sqlite3
|
||||
password: # leave blank when using sqlite3
|
||||
name: wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3, sqlserver
|
||||
dialect: sqlite3 # mysql, postgres, sqlite3, mssql
|
||||
charset: utf8mb4 # only used for mysql connections
|
||||
max_conn: 2 # maximum number of concurrent connections to maintain
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite) (true means encrypt=true in sqlserver)
|
||||
ssl: false # whether to use tls for db connection (must be true for cockroachdb) (ignored for mysql and sqlite) (true means encrypt=true in mssql)
|
||||
automigrate_fail_silently: false # whether to ignore schema auto-migration failures when starting up
|
||||
|
||||
security:
|
||||
|
||||
@@ -25,10 +25,10 @@ import (
|
||||
const (
|
||||
DefaultConfigPath = "config.yml"
|
||||
|
||||
SQLDialectMysql = "mysql"
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
SQLDialectSqlserver = "sqlserver"
|
||||
SQLDialectMysql = "mysql"
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
SQLDialectMssql = "mssql"
|
||||
|
||||
KeyLatestTotalTime = "latest_total_time"
|
||||
KeyLatestTotalUsers = "latest_total_users"
|
||||
@@ -342,8 +342,8 @@ func (c *dbConfig) IsPostgres() bool {
|
||||
return c.Dialect == "postgres"
|
||||
}
|
||||
|
||||
func (c *dbConfig) IsSqlserver() bool {
|
||||
return c.Dialect == SQLDialectSqlserver
|
||||
func (c *dbConfig) IsMssql() bool {
|
||||
return c.Dialect == SQLDialectMssql
|
||||
}
|
||||
|
||||
func (c *serverConfig) GetPublicUrl() string {
|
||||
|
||||
@@ -89,18 +89,18 @@ func Test_sqliteConnectionString(t *testing.T) {
|
||||
assert.Equal(t, c.Name, sqliteConnectionString(c))
|
||||
}
|
||||
|
||||
func Test_sqlserverConnectionString(t *testing.T) {
|
||||
func Test_mssqlConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Name: "dbinstance",
|
||||
Host: "test_host",
|
||||
Port: 1433,
|
||||
User: "test_user",
|
||||
Password: "test_password",
|
||||
Dialect: "sqlserver",
|
||||
Dialect: "mssql",
|
||||
Ssl: true,
|
||||
}
|
||||
|
||||
assert.Equal(t,
|
||||
"sqlserver://test_user:test_password@test_host:1433?database=dbinstance&encrypt=true",
|
||||
sqlserverConnectionString(c))
|
||||
mssqlConnectionString(c))
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
})
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
case SQLDialectSqlserver:
|
||||
return sqlserver.Open(sqlserverConnectionString(c))
|
||||
case SQLDialectMssql:
|
||||
return sqlserver.Open(mssqlConnectionString(c))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -104,7 +104,7 @@ func sqliteConnectionString(config *dbConfig) string {
|
||||
return config.Name
|
||||
}
|
||||
|
||||
func sqlserverConnectionString(config *dbConfig) string {
|
||||
func mssqlConnectionString(config *dbConfig) string {
|
||||
|
||||
query := url.Values{}
|
||||
|
||||
|
||||
3
main.go
3
main.go
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"github.com/duke-git/lancet/v2/condition"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
@@ -12,6 +11,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/duke-git/lancet/v2/condition"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
@@ -14,7 +14,13 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec("UPDATE users SET has_data = TRUE WHERE TRUE").Error; err != nil {
|
||||
statement := "UPDATE users SET has_data = TRUE"
|
||||
|
||||
if cfg.Db.IsMssql() {
|
||||
statement = "UPDATE users SET has_data = 1"
|
||||
}
|
||||
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := db.Exec("UPDATE heartbeats SET created_at = time WHERE TRUE").Error; err != nil {
|
||||
if err := db.Exec("UPDATE heartbeats SET created_at = time").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -16,10 +19,7 @@ func init() {
|
||||
return nil
|
||||
}
|
||||
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
condition := fmt.Sprintf("%s = ?", utils.QuoteDbIdentifier(db, "key"))
|
||||
|
||||
imprintKv := &models.KeyStringValue{Key: "imprint", Value: "no content here"}
|
||||
if err := db.
|
||||
|
||||
@@ -30,15 +30,15 @@ func init() {
|
||||
|
||||
// update their heartbeats counter
|
||||
result := db.
|
||||
Table("summaries AS s1").
|
||||
Where("s1.id IN ?", faultyIds).
|
||||
Table("summaries").
|
||||
Where("summaries.id IN ?", faultyIds).
|
||||
Update(
|
||||
"num_heartbeats",
|
||||
db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Select("COUNT(*)").
|
||||
Where("user_id = ?", gorm.Expr("s1.user_id")).
|
||||
Where("time BETWEEN ? AND ?", gorm.Expr("s1.from_time"), gorm.Expr("s1.to_time")),
|
||||
Where("user_id = ?", gorm.Expr("summaries.user_id")).
|
||||
Where("time BETWEEN ? AND ?", gorm.Expr("summaries.from_time"), gorm.Expr("summaries.to_time")),
|
||||
)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func hasRun(name string, db *gorm.DB) bool {
|
||||
condition := "key = ?"
|
||||
if config.Get().Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
condition := fmt.Sprintf("%s = ?", utils.QuoteDbIdentifier(db, "key"))
|
||||
|
||||
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
logbuch.Info("no need to migrate '%s'", name)
|
||||
|
||||
@@ -2,10 +2,11 @@ package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/duke-git/lancet/v2/mathutil"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/duke-git/lancet/v2/mathutil"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -27,16 +28,20 @@ const DefaultProjectLabel = "default"
|
||||
type Summaries []*Summary
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
// Previously, all of the following properties created a foreign key constraint on the summary_items table back to this summary table
|
||||
// All of these created foreign key constraints are the same
|
||||
// But mssql will complains about circular cascades on update/delete between these two tables
|
||||
// So, creating one constraint should be enough
|
||||
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Languages SummaryItems `json:"languages"`
|
||||
Editors SummaryItems `json:"editors"`
|
||||
OperatingSystems SummaryItems `json:"operating_systems"`
|
||||
Machines SummaryItems `json:"machines"`
|
||||
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
||||
Branches SummaryItems `json:"branches" gorm:"-"` // branches are not persisted, but calculated at runtime in case a project Filter is applied
|
||||
Entities SummaryItems `json:"entities" gorm:"-"` // entities are not persisted, but calculated at runtime in case a project Filter is applied
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatRepository struct {
|
||||
@@ -113,9 +116,9 @@ func (r *HeartbeatRepository) GetLatestByFilters(user *models.User, filterMap ma
|
||||
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, min(time) as time").
|
||||
Select(fmt.Sprintf("users.id as %s, min(time) as %s", utils.QuoteDbIdentifier(r.db, "user"), utils.QuoteDbIdentifier(r.db, "time"))).
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user").
|
||||
Group("users.id").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
@@ -123,7 +126,7 @@ func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(time) as time").
|
||||
Select(fmt.Sprintf("users.id as %s, max(time) as %s", utils.QuoteDbIdentifier(r.db, "user"), utils.QuoteDbIdentifier(r.db, "time"))).
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
@@ -172,7 +175,7 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Select("user_id as user, count(id) as count").
|
||||
Select(fmt.Sprintf("user_id as %s, count(id) as %s", utils.QuoteDbIdentifier(r.db, "user"), utils.QuoteDbIdentifier(r.db, "count"))).
|
||||
Where("user_id in ?", userIds).
|
||||
Group("user").
|
||||
Find(&counts).Error; err != nil {
|
||||
|
||||
@@ -2,8 +2,10 @@ package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"fmt"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -37,10 +39,8 @@ func (r *KeyValueRepository) GetString(key string) (*models.KeyStringValue, erro
|
||||
|
||||
func (r *KeyValueRepository) Search(like string) ([]*models.KeyStringValue, error) {
|
||||
var keyValues []*models.KeyStringValue
|
||||
condition := "key like ?"
|
||||
if r.db.Config.Name() == config.SQLDialectMysql {
|
||||
condition = "`key` like ?"
|
||||
}
|
||||
condition := fmt.Sprintf("%s like ?", utils.QuoteDbIdentifier(r.db, "key"))
|
||||
|
||||
if err := r.db.Table("key_string_values").
|
||||
Where(condition, like).
|
||||
Find(&keyValues).
|
||||
|
||||
@@ -35,11 +35,12 @@ services:
|
||||
entrypoint: '/cockroach/cockroach start-single-node --insecure --sql-addr=:56257'
|
||||
network_mode: host
|
||||
|
||||
sqlserver:
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
SA_PASSWORD: "Hard!password123"
|
||||
MSSQL_PID: "Developer"
|
||||
# network_mode: host
|
||||
ports:
|
||||
- 1433:1433
|
||||
- 1433:1433
|
||||
|
||||
@@ -25,7 +25,7 @@ db:
|
||||
user: SA
|
||||
password: Hard!password123
|
||||
name: wakapi
|
||||
dialect: sqlserver
|
||||
dialect: mssql
|
||||
charset:
|
||||
max_conn: 2
|
||||
ssl: false
|
||||
@@ -46,7 +46,7 @@ trap cleanup EXIT
|
||||
|
||||
# Initialise test data
|
||||
case $DB_TYPE in
|
||||
postgres|mysql|mariadb|cockroach|sqlserver)
|
||||
postgres|mysql|mariadb|cockroach|mssql)
|
||||
docker compose -f "$script_dir/compose.yml" down
|
||||
|
||||
docker_down=1
|
||||
@@ -62,7 +62,7 @@ case $DB_TYPE in
|
||||
db_port=55432
|
||||
elif [ "$DB_TYPE" == "cockroach" ]; then
|
||||
db_port=56257
|
||||
elif [ "$DB_TYPE" == "sqlserver" ]; then
|
||||
elif [ "$DB_TYPE" == "mssql" ]; then
|
||||
db_port=1433
|
||||
else
|
||||
db_port=26257
|
||||
@@ -113,12 +113,12 @@ kill_wakapi() {
|
||||
kill -TERM $pid
|
||||
}
|
||||
|
||||
# Need to create database for sqlserver
|
||||
if [ "$DB_TYPE" == "sqlserver" ]; then
|
||||
# Need to create database for mssql
|
||||
if [ "$DB_TYPE" == "mssql" ]; then
|
||||
echo "Sleep for 5s to wait for db to be ready..."
|
||||
sleep 5
|
||||
echo "Creating database in sqlserver..."
|
||||
docker compose -f "$script_dir/compose.yml" exec sqlserver \
|
||||
echo "Creating database in mssql..."
|
||||
docker compose -f "$script_dir/compose.yml" exec mssql \
|
||||
/opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P 'Hard!password123' -Q 'CREATE DATABASE wakapi'
|
||||
fi
|
||||
|
||||
|
||||
26
utils/db.go
26
utils/db.go
@@ -2,9 +2,11 @@ package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"gorm.io/gorm"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
func IsCleanDB(db *gorm.DB) bool {
|
||||
@@ -49,3 +51,25 @@ func WithPaging(query *gorm.DB, limit, skip int) *gorm.DB {
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type stringWriter struct {
|
||||
*strings.Builder
|
||||
}
|
||||
|
||||
func (s stringWriter) WriteByte(c byte) error {
|
||||
return s.Builder.WriteByte(c)
|
||||
}
|
||||
|
||||
func (s stringWriter) WriteString(str string) (int, error) {
|
||||
return s.Builder.WriteString(str)
|
||||
}
|
||||
|
||||
// QuoteDbIdentifier quotes a column name used in a query.
|
||||
func QuoteDbIdentifier(query *gorm.DB, columnName string) string {
|
||||
|
||||
builder := stringWriter{Builder: &strings.Builder{}}
|
||||
|
||||
query.Dialector.QuoteTo(builder, columnName)
|
||||
|
||||
return builder.Builder.String()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user