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