From 179d3bb823d759b1210f0d22e68865fe6fbee8fa Mon Sep 17 00:00:00 2001 From: Semyon Krestyaninov <krestyaninov@perx.ru> Date: Tue, 23 Jul 2024 10:06:51 +0000 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=82=D0=BE=D0=BF=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20=D1=81=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- perxis-proto | 2 +- pkg/data/list.go | 12 +++ pkg/data/list_test.go | 15 +++ pkg/locales/errors.go | 1 + pkg/locales/locale.go | 82 ++++++++++++-- pkg/locales/locale_test.go | 216 +++++++++++++++++++++++++++++++++++++ 6 files changed, 316 insertions(+), 12 deletions(-) diff --git a/perxis-proto b/perxis-proto index fc23183a..2e4728f6 160000 --- a/perxis-proto +++ b/perxis-proto @@ -1 +1 @@ -Subproject commit fc23183a86463b2aa81e3b7570fad1f873c1e435 +Subproject commit 2e4728f6f3d5d63dcb9d5548b17af9efce605280 diff --git a/pkg/data/list.go b/pkg/data/list.go index 90042506..4da827db 100644 --- a/pkg/data/list.go +++ b/pkg/data/list.go @@ -192,3 +192,15 @@ func CloneSlice[T interface{ Clone() T }](s []T) []T { } return result } + +type Keyed[T comparable] interface { + Key() T +} + +func SliceToMap[K comparable, V Keyed[K]](s []V) map[K]V { + res := make(map[K]V, len(s)) + for _, elem := range s { + res[elem.Key()] = elem + } + return res +} diff --git a/pkg/data/list_test.go b/pkg/data/list_test.go index 8231aae9..c77c226e 100644 --- a/pkg/data/list_test.go +++ b/pkg/data/list_test.go @@ -139,3 +139,18 @@ keyB2: val20 }) } } + +type KV struct { + K string + V string +} + +func (kv *KV) Key() string { + return kv.K +} + +func TestSliceToMap(t *testing.T) { + s := []*KV{{"a", "1"}, {"b", "2"}, {"c", "3"}} + m := SliceToMap(s) + assert.Equal(t, map[string]*KV{"a": {"a", "1"}, "b": {"b", "2"}, "c": {"c", "3"}}, m) +} diff --git a/pkg/locales/errors.go b/pkg/locales/errors.go index f5673290..b5876aff 100644 --- a/pkg/locales/errors.go +++ b/pkg/locales/errors.go @@ -18,4 +18,5 @@ var ( ErrSetDefaultLocaleFallback = errors.New("cannot set fallback for default locale") ErrDeleteDefaultLocale = errors.New("cannot delete default locale") ErrDeleteFallbackLocale = errors.New("cannot delete locale that is used as fallback for other locales") + ErrCircularDependency = errors.New("fallback locales contain cycles") ) diff --git a/pkg/locales/locale.go b/pkg/locales/locale.go index 520bc68c..e0e32616 100644 --- a/pkg/locales/locale.go +++ b/pkg/locales/locale.go @@ -1,6 +1,8 @@ package locales import ( + "git.perx.ru/perxis/perxis-go/pkg/data" + "git.perx.ru/perxis/perxis-go/pkg/errors" pb "git.perx.ru/perxis/perxis-go/proto/locales" "golang.org/x/text/language" ) @@ -23,18 +25,18 @@ type Locale struct { Disabled bool `json:"disabled" bson:"disabled"` // Запретить использование локали. Нельзя создавать и редактировать контент для данной локали (кроме default) } -func (l Locale) Clone() *Locale { +func (locale *Locale) Clone() *Locale { return &Locale{ - ID: l.ID, - SpaceID: l.SpaceID, - Name: l.Name, - NativeName: l.NativeName, - Code: l.Code, - Fallback: l.Fallback, - Direction: l.Direction, - Weight: l.Weight, - NoPublish: l.NoPublish, - Disabled: l.Disabled, + ID: locale.ID, + SpaceID: locale.SpaceID, + Name: locale.Name, + NativeName: locale.NativeName, + Code: locale.Code, + Fallback: locale.Fallback, + Direction: locale.Direction, + Weight: locale.Weight, + NoPublish: locale.NoPublish, + Disabled: locale.Disabled, } } @@ -46,6 +48,13 @@ func (locale *Locale) Equal(other *Locale) bool { return locale == other || locale != nil && other != nil && *locale == *other } +func (locale *Locale) Key() string { + if locale == nil { + return "" + } + return locale.ID +} + // Возвращает язык локали, например "en", "ru" func (locale *Locale) GetLanguage() string { lang, err := language.Parse(locale.Code) @@ -102,3 +111,54 @@ func LocaleFromProto(protoLocale *pb.Locale) *Locale { } return locale } + +func FallbackSort(locales []*Locale) ([]*Locale, error) { + if len(locales) == 0 { + return locales, nil + } + + var ( + unsorted = data.SliceToMap(locales) + sorted = make([]*Locale, 0, len(locales)) + visited = make(map[string]bool, len(locales)) + ) + + for { + changed := false + for _, node := range locales { + + if visited[node.ID] { + continue + } + + if _, exist := unsorted[node.Fallback]; !exist && node.Fallback != "" { + return nil, errors.Wrap(ErrNotFound, node.Fallback) + } + + if node.IsDefault() { + sorted = append([]*Locale{node}, sorted...) + visited[node.ID] = true + changed = true + continue + } + + if node.Fallback == "" || visited[node.Fallback] { + sorted = append(sorted, node) + visited[node.ID] = true + changed = true + } + } + + // Все локали перенесены в список отсортированных + if len(sorted) == len(locales) { + break + } + + // Если не произошло изменений, но не все локали отсортированы - обнаружены циклические зависимости + if !changed { + return nil, ErrCircularDependency + } + } + + return sorted, nil +} diff --git a/pkg/locales/locale_test.go b/pkg/locales/locale_test.go index 5875107a..664e3692 100644 --- a/pkg/locales/locale_test.go +++ b/pkg/locales/locale_test.go @@ -3,6 +3,7 @@ package locales import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -79,3 +80,218 @@ func TestLocale_Equal(t *testing.T) { require.Equal(t, tt.want, got) } } + +func TestFallbackSort(t *testing.T) { + tests := []struct { + name string + input []*Locale + want []*Locale + wantErr bool + }{ + { + name: "Empty input", + input: nil, + want: nil, + wantErr: false, + }, + { + name: "Empty input", + input: []*Locale{}, + want: []*Locale{}, + wantErr: false, + }, + { + name: "One locale not default", + input: []*Locale{ + {ID: "a"}, + }, + want: []*Locale{ + {ID: "a"}, + }, + wantErr: false, + }, + { + name: "One default locale", + input: []*Locale{ + {ID: DefaultID}, + }, + want: []*Locale{ + {ID: DefaultID}, + }, + wantErr: false, + }, + { + name: "One locale with fallback", + input: []*Locale{ + {ID: DefaultID, Fallback: "a"}, + }, + wantErr: true, + }, + { + name: "Fallback locale not exists", + input: []*Locale{ + {ID: "a", Fallback: "c"}, + {ID: "b", Fallback: DefaultID}, + }, + want: nil, + wantErr: true, + }, + { + name: "Not found default", + input: []*Locale{ + {ID: "a", Fallback: DefaultID}, + {ID: "b", Fallback: "d"}, + {ID: "c", Fallback: DefaultID}, + {ID: "d", Fallback: "c"}, + {ID: "e", Fallback: "a"}, + }, + wantErr: true, + }, + { + name: "Simple", + input: []*Locale{ + {ID: DefaultID}, + {ID: "a", Fallback: DefaultID}, + {ID: "b", Fallback: "d"}, + {ID: "c", Fallback: DefaultID}, + {ID: "d", Fallback: "c"}, + {ID: "e", Fallback: "a"}, + }, + want: []*Locale{ + {ID: DefaultID}, + {ID: "a", Fallback: DefaultID}, + {ID: "c", Fallback: DefaultID}, + {ID: "d", Fallback: "c"}, + {ID: "e", Fallback: "a"}, + {ID: "b", Fallback: "d"}, + }, + wantErr: false, + }, + { + name: "Simple #2", + input: []*Locale{ + {ID: "a", Fallback: DefaultID}, + {ID: "b", Fallback: "d"}, + {ID: "c", Fallback: DefaultID}, + {ID: "d", Fallback: "c"}, + {ID: "e", Fallback: "a"}, + {ID: DefaultID}, + }, + want: []*Locale{ + {ID: DefaultID}, + {ID: "a", Fallback: DefaultID}, + {ID: "c", Fallback: DefaultID}, + {ID: "d", Fallback: "c"}, + {ID: "e", Fallback: "a"}, + {ID: "b", Fallback: "d"}, + }, + wantErr: false, + }, + { + name: "Simple #3", + input: []*Locale{ + {ID: DefaultID}, + {ID: "a"}, + {ID: "b"}, + }, + want: []*Locale{ + {ID: DefaultID}, + {ID: "a"}, + {ID: "b"}, + }, + wantErr: false, + }, + { + name: "Simple #4", + input: []*Locale{ + {ID: "a"}, + {ID: "b"}, + {ID: DefaultID}, + }, + want: []*Locale{ + {ID: DefaultID}, + {ID: "a"}, + {ID: "b"}, + }, + wantErr: false, + }, + { + name: "Simple #5", + input: []*Locale{ + {ID: DefaultID}, + {ID: "a", Fallback: "b"}, + {ID: "b", Fallback: "c"}, + {ID: "c"}, + }, + want: []*Locale{ + {ID: DefaultID}, + {ID: "c"}, + {ID: "b", Fallback: "c"}, + {ID: "a", Fallback: "b"}, + }, + wantErr: false, + }, + { + name: "Simple #6", + input: []*Locale{ + {ID: "a", Fallback: "b"}, + {ID: "b", Fallback: "c"}, + {ID: "c"}, + }, + want: []*Locale{ + {ID: "c"}, + {ID: "b", Fallback: "c"}, + {ID: "a", Fallback: "b"}, + }, + wantErr: false, + }, + { + name: "Cyclical dependency", + input: []*Locale{ + {ID: DefaultID}, + {ID: "a", Fallback: "b"}, + {ID: "b", Fallback: "c"}, + {ID: "c", Fallback: "a"}, + }, + want: nil, + wantErr: true, + }, + { + name: "Cyclical dependency #2", + input: []*Locale{ + {ID: DefaultID}, + {ID: "a", Fallback: DefaultID}, + {ID: "b", Fallback: "d"}, + {ID: "c", Fallback: "b"}, + {ID: "d", Fallback: "c"}, + {ID: "e", Fallback: "a"}, + }, + want: nil, + wantErr: true, + }, + { + name: "Cyclical dependency #3", + input: []*Locale{ + {ID: DefaultID}, + {ID: "a", Fallback: "b"}, + {ID: "b", Fallback: "a"}, + {ID: "c", Fallback: "d"}, + {ID: "d", Fallback: "c"}, + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := FallbackSort(tt.input) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} -- GitLab