Repository: amp-labs/connectors
Pull request #429 (2024-05-24 13:36:48)

add: Module naming; support Singular/Plural strings
#Description

This module is yet not used anywhere, this PR just introduces concepts.

Naming module provides:
* SingularString, PluralString `types` with slice type counterparts
* ToPlural, ToSingular respective `conversions`. (Unit tests check conversion between each other).
* Creation path:
  * via `constructor`, which enforces language format. (Better than raw string conversion, hence it is a struct)
  * via `JSON` file, where after realising itself into an object it will `automatically apply format` and acts as a string not a struct. (This can be useful for scrapping tool which reads/writes files and makes the contract clear).

@laurenzlong here is the `naming` package you mentioned.



Patch:
diff --git a/common/naming/plural.go b/common/naming/plural.go
new file mode 100644
index 00000000..fe5b7813
--- /dev/null
+++ b/common/naming/plural.go
@@ -0,0 +1,67 @@
+package naming
+
+import "encoding/json"
+
+// PluralString imposes plural form on a word conforming to English rules.
+// It is capable of self conversion to singular form.
+// You can use it as keys in maps, values, and it knows how to Marshal itself like a string.
+// Unmarshalling will apply plural formating.
+type PluralString struct {
+	text string
+}
+type PluralStrings []PluralString
+
+func NewPluralString(str string) PluralString {
+	return PluralString{text: pluralizer.Plural(str)}
+}
+
+func (s PluralString) String() string {
+	return s.text
+}
+
+func (s PluralString) Singular() SingularString {
+	return NewSingularString(s.String())
+}
+
+func NewPluralStrings(list []string) PluralStrings {
+	result := make(PluralStrings, len(list))
+	for i, str := range list {
+		result[i] = NewPluralString(str)
+	}
+
+	return result
+}
+
+func (s PluralStrings) Plural() SingularStrings {
+	result := make(SingularStrings, len(s))
+	for i, str := range s {
+		result[i] = str.Singular()
+	}
+
+	return result
+}
+
+func (s PluralString) MarshalJSON() ([]byte, error) {
+	return json.Marshal(s.text)
+}
+
+func (s *PluralString) UnmarshalJSON(bytes []byte) error {
+	var text string
+
+	err := json.Unmarshal(bytes, &text)
+	if err != nil {
+		return err
+	}
+
+	s.text = pluralizer.Plural(text)
+
+	return nil
+}
+
+func (s PluralString) MarshalText() ([]byte, error) {
+	return []byte(s.text), nil
+}
+
+func (s *PluralString) UnmarshalText(text []byte) error {
+	return s.UnmarshalJSON(text)
+}
diff --git a/common/naming/singular.go b/common/naming/singular.go
new file mode 100644
index 00000000..0ed28135
--- /dev/null
+++ b/common/naming/singular.go
@@ -0,0 +1,75 @@
+package naming
+
+import (
+	"encoding/json"
+
+	"github.com/gertd/go-pluralize"
+)
+
+// This client guides the behaviour of this package and sets all rules for SingularString and PluralString.
+var pluralizer = pluralize.NewClient() // nolint:gochecknoglobals
+
+// SingularString imposes singular form on a word conforming to English rules.
+// It is capable of self conversion to plural form.
+// You can use it as keys in maps, values, and it knows how to Marshal itself like a string.
+// Unmarshalling will apply singular formating.
+type SingularString struct {
+	text string
+}
+
+type SingularStrings []SingularString
+
+func NewSingularString(str string) SingularString {
+	return SingularString{text: pluralizer.Singular(str)}
+}
+
+func (s SingularString) String() string {
+	return s.text
+}
+
+func (s SingularString) Plural() PluralString {
+	return NewPluralString(s.String())
+}
+
+func NewSingularStrings(list []string) SingularStrings {
+	result := make(SingularStrings, len(list))
+	for i, str := range list {
+		result[i] = NewSingularString(str)
+	}
+
+	return result
+}
+
+func (s SingularStrings) Plural() PluralStrings {
+	result := make(PluralStrings, len(s))
+	for i, str := range s {
+		result[i] = str.Plural()
+	}
+
+	return result
+}
+
+func (s SingularString) MarshalJSON() ([]byte, error) {
+	return json.Marshal(s.text)
+}
+
+func (s *SingularString) UnmarshalJSON(bytes []byte) error {
+	var text string
+
+	err := json.Unmarshal(bytes, &text)
+	if err != nil {
+		return err
+	}
+
+	s.text = pluralizer.Singular(text)
+
+	return nil
+}
+
+func (s SingularString) MarshalText() ([]byte, error) {
+	return []byte(s.text), nil
+}
+
+func (s *SingularString) UnmarshalText(text []byte) error {
+	return s.UnmarshalJSON(text)
+}


Test patch:
diff --git a/common/naming/singular_test.go b/common/naming/singular_test.go
new file mode 100644
index 00000000..17375fb5
--- /dev/null
+++ b/common/naming/singular_test.go
@@ -0,0 +1,186 @@
+package naming
+
+import (
+	"encoding/json"
+	"reflect"
+	"testing"
+
+	"github.com/go-test/deep"
+)
+
+func TestSingularString(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name     string
+		input    string
+		singular string
+		plural   string
+	}{
+		{
+			name:     "Singular input",
+			input:    "admin",
+			singular: "admin",
+			plural:   "admins",
+		},
+		{
+			name:     "Plural input",
+			input:    "subscription_types",
+			singular: "subscription_type",
+			plural:   "subscription_types",
+		},
+	}
+
+	for _, tt := range tests {
+		// nolint:varnamelen
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			singular := NewSingularString(tt.input)
+			singular = singular.Plural().Singular() // tautology
+			output := singular.String()
+
+			if output != tt.singular {
+				failedExpectation(t, tt.name, "to_singular", tt.singular, output)
+			}
+
+			output = singular.Plural().String()
+			if output != tt.plural {
+				failedExpectation(t, tt.name, "to_plural", tt.plural, output)
+			}
+		})
+	}
+}
+
+func TestSingularMarshal(t *testing.T) { // nolint:funlen
+	t.Parallel()
+
+	type RegistryS struct {
+		Title SingularString            `json:"title"` // test encoding as value
+		Data  map[SingularString]string `json:"data"`  // struct can be key
+		Meta  map[string]string         `json:"meta"`
+		Extra SingularString            `json:"extra,omitempty"` // can be empty
+	}
+
+	type RegistryP struct {
+		Title PluralString            `json:"title"` // test encoding as value
+		Data  map[PluralString]string `json:"data"`  // struct can be key
+		Meta  map[string]string       `json:"meta"`
+		Extra PluralString            `json:"extra,omitempty"` // can be empty
+	}
+
+	tests := []struct {
+		name string
+		run  func(t *testing.T)
+	}{
+		{
+			name: "Singular, Marshal and unmarshal",
+			run: func(t *testing.T) {
+				t.Helper()
+
+				singular := NewSingularString("potatoes")
+
+				source := RegistryS{
+					Title: singular,
+					Data:  map[SingularString]string{singular: "McDonald's"},
+					Meta:  map[string]string{"coca cola": "normal"},
+				}
+
+				var target RegistryS
+				marshalBackAndForth(t, source, &target)
+				if !reflect.DeepEqual(source, target) {
+					diff := deep.Equal(source, target)
+					t.Fatalf("%s:, marshalling data mismatching \ndiff: (%v)", t.Name(), diff)
+				}
+			},
+		},
+		{
+			name: "Plural, Marshal and unmarshal",
+			run: func(t *testing.T) {
+				t.Helper()
+
+				plural := NewPluralString("company")
+
+				source := RegistryP{
+					Title: plural,
+					Data:  map[PluralString]string{plural: "Enterprise"},
+					Meta:  map[string]string{"location": "California"},
+				}
+
+				var target RegistryP
+				marshalBackAndForth(t, source, &target)
+				if !reflect.DeepEqual(source, target) {
+					diff := deep.Equal(source, target)
+					t.Fatalf("%s:, marshalling data mismatching \ndiff: (%v)", t.Name(), diff)
+				}
+			},
+		},
+		{
+			name: "Convert to Singular format from byte data",
+			run: func(t *testing.T) {
+				t.Helper()
+
+				pluralData := `{"title":"potatoes","data":{"potatoes":"McDonald's"}}`
+				var registry RegistryS
+				err := json.Unmarshal([]byte(pluralData), &registry)
+				check(t, err)
+
+				_, found := registry.Data[NewSingularString("potato")]
+				titleMatch := registry.Title.String() == "potato"
+				if !titleMatch || !found {
+					t.Fatalf("%s format was not applied from byte data, %v", t.Name(), registry)
+				}
+			},
+		},
+		{
+			name: "Convert to Plural format from byte data",
+			run: func(t *testing.T) {
+				t.Helper()
+
+				singularData := `{"title":"company","data":{"company":"Enterprise"}}`
+				var registry RegistryP
+				err := json.Unmarshal([]byte(singularData), &registry)
+				check(t, err)
+
+				_, found := registry.Data[NewPluralString("companies")]
+				titleMatch := registry.Title.String() == "companies"
+				if !titleMatch || !found {
+					t.Fatalf("%s format was not applied from byte data, %v", t.Name(), registry)
+				}
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		tt := tt // rebind, omit loop side effects for parallel goroutine
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+
+			tt.run(t)
+		})
+	}
+}
+
+func marshalBackAndForth(t *testing.T, from, to any) {
+	t.Helper()
+
+	data, err := json.Marshal(from)
+	check(t, err)
+	err = json.Unmarshal(data, &to)
+	check(t, err)
+}
+
+func check(t *testing.T, err error) {
+	t.Helper()
+
+	if err != nil {
+		t.Fatalf("%s: failed because: %v", t.Name(), err)
+	}
+}
+
+func failedExpectation(t *testing.T, name, descr, expected, got string) {
+	t.Helper()
+
+	t.Fatalf("%s: [%v] expected: (%v), got: (%v)", name, descr, expected, got)
+}