implement

This commit is contained in:
Chen Junda
2024-01-10 20:03:38 +08:00
parent 152767cce5
commit a7d523b5ce
19 changed files with 103 additions and 63 deletions

View File

@@ -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

View File

@@ -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) |

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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))
}

View File

@@ -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{}

View File

@@ -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"

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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 {

View File

@@ -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).

View File

@@ -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

View File

@@ -25,7 +25,7 @@ db:
user: SA
password: Hard!password123
name: wakapi
dialect: sqlserver
dialect: mssql
charset:
max_conn: 2
ssl: false

View File

@@ -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

View File

@@ -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()
}