feat: wildcard aliases (resolve #607)

This commit is contained in:
Ferdinand Mütsch
2024-09-08 22:58:22 +02:00
parent 39ef066ce2
commit 9e97addb1a
12 changed files with 2759 additions and 2666 deletions

File diff suppressed because it is too large Load Diff

4
go.mod
View File

@@ -42,12 +42,11 @@ require (
gorm.io/gorm v1.25.11
)
require github.com/samber/slog-multi v1.2.1 // indirect
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/becheran/wildmatch-go v1.0.0
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -78,6 +77,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/samber/lo v1.44.0 // indirect
github.com/samber/slog-common v0.17.0 // indirect
github.com/samber/slog-multi v1.2.1
github.com/samber/slog-sentry/v2 v2.8.0
github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/files v1.0.1 // indirect

2
go.sum
View File

@@ -40,6 +40,8 @@ github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHc
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/alitto/pond v1.9.1 h1:OfCpIrMyrWJpn34f647DcFmUxjK8+7Nu3eoVN/WTP+o=
github.com/alitto/pond v1.9.1/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI=
github.com/becheran/wildmatch-go v1.0.0 h1:mE3dGGkTmpKtT4Z+88t8RStG40yN9T+kFEGj2PZFSzA=
github.com/becheran/wildmatch-go v1.0.0/go.mod h1:gbMvj0NtVdJ15Mg/mH9uxk2R1QCistMyU7d9KFzroX4=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View File

@@ -180,7 +180,7 @@ func main() {
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
durationService = services.NewDurationService(heartbeatService)
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, durationService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)

View File

@@ -1,5 +1,7 @@
package models
import "strings"
// AliasResolver returns the alias of an Entity, given its original name. I.e., it returns Alias.Key, given an Alias.Value
type AliasResolver func(t uint8, k string) string
@@ -16,7 +18,10 @@ type Alias struct {
}
func (a *Alias) IsValid() bool {
return a.Key != "" && a.Value != "" && a.validateType()
return a.Key != "" &&
a.Value != "" &&
a.validateType() &&
a.validateWildcard()
}
func (a *Alias) validateType() bool {
@@ -27,3 +32,13 @@ func (a *Alias) validateType() bool {
}
return false
}
func (a *Alias) validateWildcard() bool {
if !strings.Contains(a.Value, "*") && !strings.Contains(a.Value, "?") {
return true
}
v := a.Value
v = strings.ReplaceAll(v, "*", "")
v = strings.ReplaceAll(v, "?", "")
return len(v) >= 3 // don't allow "*" or "a*" or sth.
}

View File

@@ -356,15 +356,14 @@ func (s *Summary) MaxByToString(entityType uint8) string {
}
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
processAliases := func(origin []*SummaryItem) []*SummaryItem {
if origin == nil {
processAliases := func(items []*SummaryItem) []*SummaryItem {
if items == nil {
return nil
}
target := make([]*SummaryItem, 0)
itemsAliased := make([]*SummaryItem, 0)
findItem := func(key string) *SummaryItem {
for _, item := range target {
for _, item := range itemsAliased {
if item.Key == key {
return item
}
@@ -372,20 +371,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
return nil
}
for _, item := range origin {
for _, item := range items {
// Add all "top-level" items, i.e. such without aliases
if key := resolve(item.Type, item.Key); key == item.Key {
target = append(target, item)
itemsAliased = append(itemsAliased, item)
}
}
for _, item := range origin {
for _, item := range items {
// Add all remaining projects and merge with their alias
if key := resolve(item.Type, item.Key); key != item.Key {
if targetItem := findItem(key); targetItem != nil {
targetItem.Total += item.Total
} else {
target = append(target, &SummaryItem{
itemsAliased = append(itemsAliased, &SummaryItem{
ID: item.ID,
SummaryID: item.SummaryID,
Type: item.Type,
@@ -396,7 +395,7 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
}
}
return target
return itemsAliased
}
// Resolve aliases

View File

@@ -3,6 +3,7 @@ package services
import (
"errors"
"fmt"
"github.com/becheran/wildmatch-go"
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@@ -75,9 +76,13 @@ func (srv *AliasService) GetAliasOrDefault(userId string, summaryType uint8, val
srv.MayInitializeUser(userId)
}
match := func(aliasValue string, itemKey string) bool {
return wildmatch.NewWildMatch(aliasValue).IsMatch(itemKey)
}
if aliases, ok := userAliases.Load(userId); ok {
for _, a := range aliases.([]*models.Alias) {
if a.Type == summaryType && a.Value == value {
if a.Type == summaryType && match(a.Value, value) {
return a.Key, nil
}
}

View File

@@ -25,6 +25,12 @@ func (suite *AliasServiceTestSuite) SetupSuite() {
Key: "wakapi",
Value: "wakapi-mobile",
},
{
Type: models.SummaryProject,
UserID: suite.TestUserId,
Key: "telepush",
Value: "telepush-*",
},
}
aliasRepoMock := new(mocks.AliasRepositoryMock)
@@ -44,6 +50,8 @@ func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
result1, err1 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi-mobile")
result2, err2 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi")
result3, err3 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "anchr")
result4, err4 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "telepush-mobile")
result5, err5 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryLanguage, "telepush-mobile")
assert.Equal(suite.T(), "wakapi", result1)
assert.Nil(suite.T(), err1)
@@ -51,4 +59,8 @@ func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
assert.Nil(suite.T(), err2)
assert.Equal(suite.T(), "anchr", result3)
assert.Nil(suite.T(), err3)
assert.Equal(suite.T(), "telepush", result4)
assert.Nil(suite.T(), err4)
assert.Equal(suite.T(), "telepush-mobile", result5)
assert.Nil(suite.T(), err5)
}

View File

@@ -15,6 +15,7 @@ const (
TestProject1 = "test-project-1"
TestProject2 = "test-project-2"
TestProject3 = "test-project-3"
TestProject4 = "something-completely-different-4"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"

View File

@@ -2,7 +2,9 @@ package services
import (
"errors"
"github.com/becheran/wildmatch-go"
"github.com/duke-git/lancet/v2/datetime"
"github.com/duke-git/lancet/v2/slice"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@@ -20,17 +22,19 @@ type SummaryService struct {
cache *cache.Cache
eventBus *hub.Hub
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
durationService IDurationService
aliasService IAliasService
projectLabelService IProjectLabelService
}
func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, durationService IDurationService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
srv := &SummaryService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
eventBus: config.EventBus(),
repository: summaryRepo,
heartbeatService: heartbeatService,
durationService: durationService,
aliasService: aliasService,
projectLabelService: projectLabelService,
@@ -481,11 +485,36 @@ func (srv *SummaryService) getAliasReverseResolver(user *models.User) models.Ali
return func(t uint8, k string) []string {
aliases, err := srv.aliasService.GetByUserAndKeyAndType(user.ID, k, t)
if err != nil {
config.Log().Error("failed to fetch aliases for user", "user", user.ID, "error", err)
aliases = []*models.Alias{}
}
projects, err := srv.heartbeatService.GetEntitySetByUser(models.SummaryProject, user.ID)
if err != nil {
config.Log().Error("failed to fetch projects for alias resolution for user", "user", user.ID, "error", err)
}
isWildcard := func(alias string) bool {
return strings.Contains(alias, "*") || strings.Contains(alias, "?")
}
// for wildcard patterns like "anchr-" (e.g. resolving to "anchr-mobile", "anchr-web", ...), we need to fetch all projects matching the pattern
// this is mainly used for the filtering functionality
// proper way would be to make the filters support wildcards as well instead
matchProjects := func(aliasWildcard string) []string {
pattern := wildmatch.NewWildMatch(aliasWildcard)
return slice.Filter[string](projects, func(i int, project string) bool {
return pattern.IsMatch(project)
})
}
aliasStrings := make([]string, 0, len(aliases))
for _, a := range aliases {
aliasStrings = append(aliasStrings, a.Value)
if isWildcard(a.Value) {
aliasStrings = append(aliasStrings, matchProjects(a.Value)...)
} else {
aliasStrings = append(aliasStrings, a.Value)
}
}
return aliasStrings
}

View File

@@ -25,6 +25,7 @@ type SummaryServiceTestSuite struct {
TestDurations []*models.Duration
TestLabels []*models.ProjectLabel
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
DurationService *mocks.DurationServiceMock
AliasService *mocks.AliasServiceMock
ProjectLabelService *mocks.ProjectLabelServiceMock
@@ -96,6 +97,7 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.DurationService = new(mocks.DurationServiceMock)
suite.AliasService = new(mocks.AliasServiceMock)
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
@@ -106,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
var (
from time.Time
@@ -172,7 +174,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
var (
summaries []*models.Summary
@@ -331,7 +333,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
@@ -379,7 +381,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
suite.AliasService.On("InitializeUser", suite.TestUser.ID).Return(nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
@@ -423,7 +425,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
var (
from time.Time
@@ -462,8 +464,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels()
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
suite.HeartbeatService.On("GetEntitySetByUser", models.SummaryProject, suite.TestUser.ID).Return([]string{TestProject1, TestProject2, TestProject3, TestProject4}, nil)
suite.AliasService.On("InitializeUser", suite.TestUser.ID).Return(nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
@@ -478,6 +481,11 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
Key: TestProject1,
Value: TestProject2,
},
{
Type: models.SummaryProject,
Key: TestProject1,
Value: TestProject4[0:3] + "*", // wildcard alias
},
}, nil)
suite.ProjectLabelService.On("GetByUserGroupedInverted", suite.TestUser.ID).Return(map[string][]*models.ProjectLabel{
suite.TestLabels[0].Label: suite.TestLabels[0:1],
@@ -492,11 +500,12 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
assert.Contains(suite.T(), effectiveFilters.Project, TestProject1) // because actually requested
assert.Contains(suite.T(), effectiveFilters.Project, TestProject2) // because of alias
assert.Contains(suite.T(), effectiveFilters.Project, TestProject3) // because of label
assert.Contains(suite.T(), effectiveFilters.Project, TestProject4) // because of wildcard alias
assert.Contains(suite.T(), effectiveFilters.Label, TestProjectLabel3)
}
func (suite *SummaryServiceTestSuite) TestSummaryService_getMissingIntervals() {
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
from1, _ := time.Parse(time.RFC822, "25 Mar 22 11:00 UTC")
to1, _ := time.Parse(time.RFC822, "25 Mar 22 13:00 UTC")

View File

@@ -275,7 +275,7 @@
</div>
{{end}}
<form action="" method="post">
<form action="" method="post" class="mb-2">
<h3 class="inline-block font-semibold text-gray-300">Add Rule</h3>
<input type="hidden" name="action" value="add_alias">
@@ -301,6 +301,8 @@
</div>
</div>
</form>
<p class="text-sm text-gray-600">Wildcard patterns are supported, see <a href="https://github.com/becheran/wildmatch-go" target="_blank" rel="noopener noreferrer" class="link">here</a>.</p>
</div>
</div>
</div>