diff options
| -rw-r--r-- | .golangci.yaml | 1 | ||||
| -rw-r--r-- | internal/test/helpers.go | 21 | ||||
| -rw-r--r-- | option/option.go | 8 | ||||
| -rw-r--r-- | refined/scalar.go | 329 | ||||
| -rw-r--r-- | refined/scalar_test.go | 213 | ||||
| -rw-r--r-- | refined/shared.go | 76 | ||||
| -rw-r--r-- | refined/struct.go | 2 | ||||
| -rw-r--r-- | refined/validate_unmarshaled.go | 181 |
8 files changed, 823 insertions, 8 deletions
diff --git a/.golangci.yaml b/.golangci.yaml index 291e557..4b9fb44 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -42,6 +42,7 @@ linters: goconst: min-occurrences: 5 gocritic: + disable-all: true enabled-checks: - boolExprSimplify - builtinShadow diff --git a/internal/test/helpers.go b/internal/test/helpers.go index a93aa9e..fc52074 100644 --- a/internal/test/helpers.go +++ b/internal/test/helpers.go @@ -19,6 +19,27 @@ func AssertEqual[V any](t *testing.T, actual, expected V) { t.Errorf("expected %#v, but got %#v", expected, actual) } +func AssertErrorEqual(t *testing.T, actual error, expected string) { + t.Helper() + if actual == nil { + t.Errorf("expected err = %q, but got nil", expected) + } else if actual.Error() != expected { + t.Errorf("expected err = %q, but got %q", expected, actual.Error()) + } +} + +func AssertPanics(t *testing.T, action func()) (recovered any) { + t.Helper() + defer func() { + recovered = recover() + if recovered == nil { + t.Error("action did not panic as expected") + } + }() + action() + return +} + func PointerTo[V any](value V) *V { return &value } diff --git a/option/option.go b/option/option.go index a96e87d..776b16a 100644 --- a/option/option.go +++ b/option/option.go @@ -22,11 +22,11 @@ // // # Marshalling considerations // -// Marshaling into and from YAML using https://github.com/go-yaml/yaml is supported. +// Marshaling into and from YAML using https://github.com/go-yaml/yaml (or one of its many forks) is supported. // The "omitempty" flag works as expected. // // Marshaling into and from JSON using encoding/json is supported, but the "omitempty" flag does not work. -// You must use the "omitzero" flag to get the same effect, but note that this flag is only supported by Go 1.24 and newer. +// You must use the "omitzero" flag to get the same effect. // // # How to replace pointer types with Option types // @@ -367,7 +367,7 @@ func (o *Option[T]) UnmarshalJSON(buf []byte) error { return nil } -// MarshalYAML implements the yaml.Marshaler interface from gopkg.in/yaml.v2 and v3. +// MarshalYAML implements the yaml.Marshaler interface from gopkg.in/yaml.v2 and v3 as well as their forks. func (o Option[T]) MarshalYAML() (any, error) { if o.isSome { // If we just return o.value directly here, MarshalYAML will not be called @@ -385,7 +385,7 @@ func (o Option[T]) MarshalYAML() (any, error) { // UnmarshalYAML implements the yaml.Unmarshaler interface from gopkg.in/yaml.v2. // -// gopkg.in/yaml.v3 supports this interface via backwards-compatibility, +// gopkg.in/yaml.v3 and its forks support this interface via backwards-compatibility, // so we intentionally do not use the v3-only signature that refers to the yaml.Node type. func (o *Option[T]) UnmarshalYAML(unmarshal func(any) error) error { var data *T diff --git a/refined/scalar.go b/refined/scalar.go index b7099d6..f5fb650 100644 --- a/refined/scalar.go +++ b/refined/scalar.go @@ -3,11 +3,332 @@ package refined -import . "github.com/majewsky/gg/option" +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" -type Scalar struct { - value Option[any] + . "github.com/majewsky/gg/option" +) + +// Scalar provides support for refinement types derived from scalar base types (individual numbers or strings). +// This type is used to declare a refinement type, by placing a reflect.Scalar type inside a struct type as an embedded field: +// +// type AccountName struct { +// refined.Scalar[AccountName, string] +// } +// +// Like in this example, the first type argument of Scalar (S) is always the outer struct type, and the second type argument (V) is the base type. +// The construction using an embedded field on a struct type allows you to define additional convenience methods in the same way that you would on a newtype, +// while also allowing the struct type to expose method implementations provided by type Scalar (e.g. MarshalJSON): +// +// // using a newtype +// type AccountName string +// func (n AccountName) IsReserved() bool { +// return strings.HasPrefix(string(n), "__") +// } +// +// // using a refinement type instead +// type AccountName struct { +// refined.Scalar[AccountName, string] +// } +// func (n AccountName) IsReserved() bool { +// // retrieves the underlying value using the Unpack method of type Scalar +// return strings.HasPrefix(n.Unpack(), "__") +// } +// +// To make the refinement type work, type Scalar needs to know the predicate that applies to this type. +// This is why the struct type is given to type Scalar as the type argument S. +// The struct type must implement the IsAScalar interface. Continuing the example: +// +// var accountNameRx = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) +// +// func (AccountName) RefinedMatch(value string) bool { +// // This function decides which values are acceptable for the refinement type. +// return accountNameRx.MatchString(value) +// } +// func (AccountName) RefinedBuild(s refined.PreScalar[AccountName, string]) AccountName { +// // This function allows the library to cast a bare Scalar instance into the full struct type. +// return AccountName{refined.PromoteScalar(s)} +// } +// +// Marshaling into and from YAML using https://github.com/go-yaml/yaml (or one of its many forks) is supported. +// The "omitempty" flag works as expected. +// +// Marshaling into and from JSON using encoding/json is supported, but the "omitempty" flag does not work. +// You must use the "omitzero" flag to get the same effect. +type Scalar[S IsAScalar[S, V], V ScalarValue] struct { + value Option[V] +} + +// ScalarValue is an interface that is implemented by all scalar types that do not allow interior mutation. +// That is to say, any operation on a type in this interface must always yield a fresh value without editing the inside of the existing value. +// For example, []byte is not in this interface because edits made on a copy of a []byte value can affect the original: +// +// original := []byte("foo") +// copied := original +// copied[1] = 'x' +// fmt.Println(string(original)) // prints "fxo", not "foo"! +type ScalarValue interface { + ~bool | + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr | + ~float32 | ~float64 | + ~complex64 | ~complex128 | + ~string +} + +// IsAScalar is an interface that must be implemented by user-defined struct types holding an instance of type Scalar. +// See documentation on type Scalar for an explanation and example of how these two types are interconnected. +type IsAScalar[S any, V ScalarValue] interface { + // RefinedMatch implements the predicate for the refinement type. + // Given a value of the scalar base type, this function shall return whether that value is acceptable for the refinement type. + // If false is returned, package refined will ensure that no instance of Scalar[S, V] with that value is ever constructed. + // + // For technical reasons, this function is a method on type S, but the implementation shall not use the receiver value. + // Package refined will always call this method on an instance of S that is invalid and not ready to use other than for calling this method. + RefinedMatch(V) bool + + // RefinedBuild allows package refined to cast a bare Scalar instance into the full user-defined struct type. + // Instead of a Scalar instance, this method receives + // + // For technical reasons, this function is a method on type S, but the implementation shall not use the receiver value. + // Package refined will always call this method on an instance of S that is invalid and not ready to use other than for calling this method. + // The implementation shall always return a freshly constructed instance of type S that is not related to the receiver value. + RefinedBuild(PreScalar[S, V]) S +} + +// PreScalar is like Scalar, but with a weaker type bound. +// +// This type is only needed in one place, in the declaration of RefinedBuild(), +// to break a dependency cycle between the definition of type Scalar and type IsAScalar. +// +// It is not proper to construct instances of this type outside of package refined. +// Attempting to use a zero-initialized value of this type will result in a panic. +type PreScalar[S any, V ScalarValue] struct { + value Option[V] +} + +// PromoteScalar converts a PreScalar into a Scalar, thus strengthening its type bound. +// +// This function is only needed in one place, in implementations of RefinedBuild(), +// to break a dependency cycle between the definition of type Scalar and type IsAScalar. +func PromoteScalar[S IsAScalar[S, V], V ScalarValue](p PreScalar[S, V]) Scalar[S, V] { + if p.value.IsNone() { + // `value = None()` only occurs when user code outside of package refined + // creates a zero-valued instance of PreScalar like this: + // + // var ps refined.PreScalar[S, V] + // s := refined.PromoteScalar(ps) + // + // or through reflection. This is illegal. Only PreScalar instances + // constructed within package refined are legal to use because package + // refined will ensure that the predicate of S is upheld. + panic("PromoteScalar received an illegally constructed PreScalar instance") + } + return Scalar[S, V](p) +} + +//////////////////////////////////////////////////////////////////////////////// +// generic methods on Scalar[S, V] + +// refinedSeal implements the isARefinementType interface. +func (s Scalar[S, V]) refinedSeal() seal { + // This method is never called. It's only part of the interface to make it unimplementable outside this package. + return seal{} +} + +// refinedNew implements the isARefinementType interface. +func (s Scalar[S, V]) refinedNew(value V) (S, error) { + var empty S + err := checkScalarValue[S, V](value) + if err == nil { + return empty.RefinedBuild(PreScalar[S, V]{Some(value)}), nil + } else { + return empty, err + } +} + +func checkScalarValue[S IsAScalar[S, V], V ScalarValue](value V) error { + var empty S + if empty.RefinedMatch(value) { + return nil + } else { + return fmt.Errorf("value %#v is not acceptable for %T", value, empty) + } +} + +// IsValid returns whether the scalar holds a valid value. +// This will only ever be false for zero-valued Scalar instances: +// +// type AccountName struct { +// refined.Scalar[AccountName, string] +// } +// +// name1, err := refined.New[AccountName]("example") +// if err == nil { +// fmt.Println(name1.IsValid()) // prints true +// } +// +// var name2 AccountName // not initialized to a valid value! +// fmt.Println(name2.IsValid()) // prints false +// +// type AccountData struct { +// ID int64 +// Name AccountName +// } +// data := AccountData { +// ID: 42, +// // Name is not initialized! +// } +// fmt.Println(data.Name.IsValid()) // prints false +// +// Most functions handling refinement types should not have to call this method. +// Access the refinement type's value directly through Unpack() or any other method on Scalar. +// If that panics, you should be catching that in a test. +// +// The most common situation where IsValid() truly needs to be used is when unmarshaling data structures from JSON or similar formats. +// Unmarshalers often leave struct fields unfilled if they are not mentioned in the data, and package refined cannot intervene in that +// because technically nothing gets unmarshaled into the Scalar value and so no code is executed. For example: +// +// var accountData struct { +// ID int64 +// Name AccountName +// } +// input := `{"ID":42}` // "Name" key is missing! +// err := json.Unmarshal([]byte(input), &accountData) // will succeed (err == nil) +// fmt.Println(accountData.Name.IsValid()) // prints false +// +// When unmarshaling data structures that contain refinement type values, use func ValidateUnmarshaled instead of this method. +func (s Scalar[S, V]) IsValid() bool { + return s.value.IsSome() +} + +// Unpack returns a copy of the raw value inside this scalar. +// Panics if called on a zero value; see func IsValid for details. +func (s Scalar[S, V]) Unpack() V { + return s.value.UnwrapOrPanic("Unpack() called on an illegally constructed Scalar instance") +} + +//////////////////////////////////////////////////////////////////////////////// +// formatting/marshalling support for Scalar[S, V] + +// Format implements the fmt.Formatter interface. +// +// For most verbs, the contained value will be formatted as if it was given directly. +// For %v, the %v representation of the contained value is wrapped in "TypeName[]". +// For %#v, a refined.Literal() invocation is formatted. +func (s Scalar[S, V]) Format(f fmt.State, verb rune) { + val := s.Unpack() + if verb != 'v' { + fmt.Fprintf(f, fmt.FormatString(f, verb), val) + return + } + + var empty S + sName := fmt.Sprintf("%T", empty) + inner := fmt.Sprintf(fmt.FormatString(f, verb), val) + if f.Flag('#') { + fmt.Fprintf(f, "refined.Literal[%s](%s)", sName, inner) + } else { + fmt.Fprintf(f, "%s[%s]", sName, inner) + } +} + +// Value implements the database/sql/driver.Valuer interface. +// +// If you want to get the contained value, use Unpack(). +// This name is unfortunately taken by an interface from the standard library. +func (s Scalar[S, V]) Value() (driver.Value, error) { + return driver.DefaultParameterConverter.ConvertValue(s.Unpack()) +} + +// Scan implements the database/sql.Scanner interface. +func (s *Scalar[S, V]) Scan(src any) error { + // We cannot scan `src` into V directly because the required function (database/sql.convertAssign) is private. + // sql.Null[V].Scan() is the next best thing, but it allows `src = nil` even though no possible choice for V does, + // so we need to catch this case ourselves. + var ( + data sql.Null[V] + empty S + ) + err := data.Scan(src) + if err != nil { + return err + } + + if !data.Valid { + // this mimics the error message that database/sql would generate when scanning `src = nil` into V directly + return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, empty) + } + + err = checkScalarValue[S, V](data.V) + if err == nil { + *s = Scalar[S, V]{Some(data.V)} + } + return err +} + +// IsZero implements the IsZeroer interface as understood by encoding/json and github.com/go-yaml/yaml. +// It returns whether the underlying value is zero. +func (s Scalar[S, V]) IsZero() bool { + value := s.Unpack() + var zero V + return value == zero +} + +// MarshalJSON implements the encoding/json.Marshaler interface. +func (s Scalar[S, V]) MarshalJSON() ([]byte, error) { + return json.Marshal(s.Unpack()) +} + +// UnmarshalJSON implements the encoding/json.Unmarshaler interface. +func (s *Scalar[S, V]) UnmarshalJSON(buf []byte) error { + var raw V + err := json.Unmarshal(buf, &raw) + if err != nil { + return err + } + err = checkScalarValue[S, V](raw) + if err == nil { + *s = Scalar[S, V]{Some(raw)} + } + return err +} + +type yamlMarshaler interface { + MarshalYAML() (any, error) +} + +// MarshalYAML implements the yaml.Marshaler interface from gopkg.in/yaml.v2 and v3 as well as their forks. +func (s Scalar[S, V]) MarshalYAML() (any, error) { + value := s.Unpack() + // If we just return `value` directly here, MarshalYAML will not be called + // on the value even if it exists. For this one specific case, we have to + // take care ourselves. + if m, ok := any(value).(yamlMarshaler); ok { + return m.MarshalYAML() + } else { + return value, nil + } +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface from gopkg.in/yaml.v2. +// +// gopkg.in/yaml.v3 and its forks support this interface via backwards-compatibility, +// so we intentionally do not use the v3-only signature that refers to the yaml.Node type. +func (s *Scalar[S, V]) UnmarshalYAML(unmarshal func(any) error) error { + var raw V + err := unmarshal(&raw) + if err != nil { + return err + } + err = checkScalarValue[S, V](raw) + if err == nil { + *s = Scalar[S, V]{Some(raw)} + } + return err } // TODO: func ValidateUnmarshaled() -// TODO: constrain base type of Scalar to be a scalar via an explicit type enum diff --git a/refined/scalar_test.go b/refined/scalar_test.go new file mode 100644 index 0000000..281b74e --- /dev/null +++ b/refined/scalar_test.go @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package refined_test + +import ( + "database/sql" + "database/sql/driver" + "encoding/json" + "fmt" + "regexp" + "testing" + + . "github.com/majewsky/gg/internal/test" + "github.com/majewsky/gg/refined" +) + +//////////////////////////////////////////////////////////////////////////////// +// example refinement types + +type AccountName struct { + refined.Scalar[AccountName, string] +} + +var accountNameRx = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) + +func (AccountName) RefinedMatch(value string) bool { + return accountNameRx.MatchString(value) +} +func (AccountName) RefinedBuild(s refined.PreScalar[AccountName, string]) AccountName { + return AccountName{refined.PromoteScalar(s)} +} + +type DiceRoll struct { + refined.Scalar[DiceRoll, uint8] +} + +func (DiceRoll) RefinedMatch(value uint8) bool { + return value >= 1 && value <= 6 +} +func (DiceRoll) RefinedBuild(s refined.PreScalar[DiceRoll, uint8]) DiceRoll { + return DiceRoll{refined.PromoteScalar(s)} +} + +// We need this for a test that specifically uses complex numbers. +type PurelyImaginary struct { + refined.Scalar[PurelyImaginary, complex128] +} + +func (PurelyImaginary) RefinedMatch(value complex128) bool { + return real(value) == 0 +} +func (PurelyImaginary) RefinedBuild(s refined.PreScalar[PurelyImaginary, complex128]) PurelyImaginary { + return PurelyImaginary{refined.PromoteScalar(s)} +} + +// We need this for a test involving serialization of zero values to JSON. +type EmptyString struct { + refined.Scalar[EmptyString, string] +} + +func (EmptyString) RefinedMatch(value string) bool { + return value == "" +} +func (EmptyString) RefinedBuild(s refined.PreScalar[EmptyString, string]) EmptyString { + return EmptyString{refined.PromoteScalar(s)} +} + +//////////////////////////////////////////////////////////////////////////////// +// static assertions that refined.Scalar implements the intended interfaces +// (The YAML interfaces are not checked because we don't want to add 3rd-party lib deps here.) + +var ( + _ fmt.Formatter = AccountName{} + _ driver.Valuer = AccountName{} + _ sql.Scanner = &AccountName{} + _ json.Marshaler = AccountName{} + _ json.Unmarshaler = &AccountName{} +) + +//////////////////////////////////////////////////////////////////////////////// +// basic API + +func TestNewOnScalar(t *testing.T) { + value, err := refined.New[AccountName]("") + AssertErrorEqual(t, err, `value "" is not acceptable for refined_test.AccountName`) + AssertEqual(t, value.IsValid(), false) + + value, err = refined.New[AccountName]("a+b") + AssertErrorEqual(t, err, `value "a+b" is not acceptable for refined_test.AccountName`) + AssertEqual(t, value.IsValid(), false) + + value, err = refined.New[AccountName]("ab") + AssertEqual(t, err, nil) + AssertEqual(t, value.IsValid(), true) + AssertEqual(t, value.Unpack(), "ab") +} + +func TestLiteralOnScalar(t *testing.T) { + message := AssertPanics(t, func() { _ = refined.Literal[AccountName]("") }) + AssertEqual(t, message, `value "" is not acceptable for refined_test.AccountName`) + + message = AssertPanics(t, func() { _ = refined.Literal[AccountName]("a+b") }) + AssertEqual(t, message, `value "a+b" is not acceptable for refined_test.AccountName`) + + value := refined.Literal[AccountName]("ab") + AssertEqual(t, value.IsValid(), true) + AssertEqual(t, value.Unpack(), "ab") +} + +func TestIllegalScalarOperations(t *testing.T) { + var emptyScalar AccountName + AssertEqual(t, emptyScalar.IsValid(), false) + AssertPanics(t, func() { emptyScalar.Unpack() }) + + var emptyPreScalar refined.PreScalar[AccountName, string] + AssertPanics(t, func() { _ = refined.PromoteScalar(emptyPreScalar) }) +} + +func TestComparisonOnScalar(t *testing.T) { + n1 := refined.Literal[AccountName]("same") + n2 := refined.Literal[AccountName]("different") + n3 := refined.Literal[AccountName]("same") + AssertEqual(t, n1 == n2, false) + AssertEqual(t, n1 == n3, true) +} + +//////////////////////////////////////////////////////////////////////////////// +// interface implementations + +func TestFormatterOnScalar(t *testing.T) { + name := refined.Literal[AccountName]("abc") + AssertEqual(t, fmt.Sprintf("%s", name), `abc`) + AssertEqual(t, fmt.Sprintf("%q", name), `"abc"`) + AssertEqual(t, fmt.Sprintf("%v", name), `refined_test.AccountName[abc]`) + AssertEqual(t, fmt.Sprintf("%#v", name), `refined.Literal[refined_test.AccountName]("abc")`) + + roll := refined.Literal[DiceRoll](4) + AssertEqual(t, fmt.Sprintf("%d", roll), `4`) + AssertEqual(t, fmt.Sprintf("%02d", roll), `04`) + AssertEqual(t, fmt.Sprintf("%v", roll), `refined_test.DiceRoll[4]`) + AssertEqual(t, fmt.Sprintf("%#v", roll), `refined.Literal[refined_test.DiceRoll](0x4)`) +} + +func TestMarshalSQLOnScalar(t *testing.T) { + value, err := refined.Literal[AccountName]("hello").Value() + AssertEqual(t, err, nil) + AssertEqual(t, value, "hello") + + // complex64 and complex128 are the only types matching ScalarValue that can fail conversion into an SQL value + _, err = refined.Literal[PurelyImaginary](5i).Value() + AssertErrorEqual(t, err, `unsupported type complex128, a complex128`) +} + +func TestUnmarshalSQLOnScalar(t *testing.T) { + var n1 AccountName + err := n1.Scan("example") + AssertEqual(t, err, nil) + AssertEqual(t, n1.Unpack(), "example") + + var n2 AccountName + err = n2.Scan("a+b") + AssertErrorEqual(t, err, `value "a+b" is not acceptable for refined_test.AccountName`) + AssertEqual(t, n2.IsValid(), false) + + var n3 AccountName + err = n3.Scan(nil) + AssertErrorEqual(t, err, `unsupported Scan, storing driver.Value type <nil> into type refined_test.AccountName`) + AssertEqual(t, n3.IsValid(), false) +} + +func TestMarshalJSONOnScalar(t *testing.T) { + data := struct { + // test for serializing non-zero values + N AccountName `json:"n"` + // tests for serializing zero values + E1 EmptyString `json:"e1"` + E2 EmptyString `json:"e2,omitempty"` + E3 EmptyString `json:"e3,omitzero"` + }{ + N: refined.Literal[AccountName]("foo"), + E1: refined.Literal[EmptyString](""), + E2: refined.Literal[EmptyString](""), + E3: refined.Literal[EmptyString](""), + } + buf, err := json.Marshal(data) + AssertEqual(t, err, nil) + AssertEqual(t, string(buf), `{"n":"foo","e1":"","e2":""}`) +} + +func TestUnmarshalJSONOnScalar(t *testing.T) { + type payload struct { + Name AccountName `json:"n"` + } + + var p1 payload + err := json.Unmarshal([]byte(`{"n":"foo"}`), &p1) + AssertEqual(t, err, nil) + AssertEqual(t, p1.Name.Unpack(), "foo") + + var p2 payload + err = json.Unmarshal([]byte(`{"n":"a+b"}`), &p2) + AssertErrorEqual(t, err, `value "a+b" is not acceptable for refined_test.AccountName`) + + // This is the problematic case. If "n" is not mentioned in the JSON payload, + // UnmarshalJSON will never be called. + var p3 payload + err = json.Unmarshal([]byte(`{}`), &p3) + AssertEqual(t, err, nil) + AssertEqual(t, p3.Name.IsValid(), false) + + // TODO: demonstrate basic use of ValidateUnmarshaled() here +} diff --git a/refined/shared.go b/refined/shared.go new file mode 100644 index 0000000..67ee4d3 --- /dev/null +++ b/refined/shared.go @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package refined + +// A private type that appears in interfaces to make them unimplementable for types outside this package. +type seal struct{} + +// This interface is currently only implemented by Scalar[S, V]. +// But by having this interface as an intermediate, New() and Literal() can +// work with other classes of refinement types in the future. +type isARefinementType[S any, V any] interface { + refinedSeal() seal + refinedNew(V) (S, error) +} + +// This interface is currently only implemented by Scalar[S, V]. +// But by having this interface as an intermediate, ValidateUnmarshaled() can +// work with other classes of refinement types in the future. +// +// Because of how the reflection logic in ValidateUnmarshaled() is written, +// this interface is restricted to not have any type parameters. +type validationTarget interface { + refinedSeal() seal + IsValid() bool +} + +// New checks if the provided value is acceptable for the refinement type S, +// and returns an instance of S if so, or an error if not. +// For example, given a refinement type like: +// +// type AccountName struct { +// refined.Scalar[AccountName, string] +// } +// // not shown: implementation of refined.IsAScalar[AccountName, string] interface on AccountName +// +// An instance of this refinement type can be constructed like so: +// +// var userInput string +// accountName, err := refined.New[AccountName](userInput) +func New[S isARefinementType[S, V], V any](value V) (S, error) { + var empty S + return empty.refinedNew(value) +} + +// Literal is like New, but panics on error. +// This function is intended for dealing with literal values, where unexpected errors are not possible. +// For example: +// +// receiverName, err := refined.New[NonEmptyString]("Beitragsservice") +// handle(err) +// postCode, err := refined.New[PostCode](50616) +// handle(err) +// town, err := refined.New[NonEmptyString]("Cologne") +// handle(err) +// testAddress := Address { +// ReceiverName: receiverName, +// PostCode: postCode, +// Town: town, +// } +// +// can be shortened to: +// +// testAddress := Address { +// ReceiverName: refined.Literal[NonEmptyString]("Beitragsservice"), +// PostCode: refined.Literal[PostCode](50616), +// Town: refined.Literal[NonEmptyString]("Cologne"), +// } +func Literal[S isARefinementType[S, V], V any](value V) S { + var empty S + s, err := empty.refinedNew(value) + if err != nil { + panic(err.Error()) + } + return s +} diff --git a/refined/struct.go b/refined/struct.go index 924c32e..65734b6 100644 --- a/refined/struct.go +++ b/refined/struct.go @@ -3,11 +3,13 @@ package refined +/* import . "github.com/majewsky/gg/option" type Struct struct { value Option[struct{}] } +*/ // TODO: require base struct type to have a deep-clone method // TODO: func Get() that does a deep clone, func Set(), func Update(func(*BaseType)) diff --git a/refined/validate_unmarshaled.go b/refined/validate_unmarshaled.go new file mode 100644 index 0000000..2a51bb6 --- /dev/null +++ b/refined/validate_unmarshaled.go @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net> +// SPDX-License-Identifier: Apache-2.0 + +package refined + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "unsafe" + + . "github.com/majewsky/gg/option" +) + +// ValidateUnmarshaled calls IsValid() on each refinement type instance contained somewhere within the given data structure. +// For each instance of a refinement type where IsValid() returns false, an error is returned. +// Each returned error will be of type InvalidInstanceError. +// +// Invalid instances of refinement types can occur when unmarshaling data structures from JSON or similar formats. +// See documentation on func Scalar.IsValid() for a detailed explanation. +// After unmarshaling, the owner of the obtained data structure therefore needs to walk through it and check IsValid() on each instance of a refinement type. +// +// Because writing this check by hand is tedious, ValidateUnmarshaled() automates it. +// Call this function after your Unmarshal() call. +// For example with JSON: +// +// var payload SomeComplexStructWithNestedArraysMapsAndSubstructs +// err := json.Unmarshal(buf, &payload) +// handle(err) +// errs := refined.ValidateUnmarshaled(payload, 1) +// handle(errs) +// // if there were no errors, it is now guaranteed that +// // each refined.Scalar etc. contained within `payload` will be valid +// +// This function can handle inputs containing cyclic references (as can occur when unmarshaling YAML that contains anchor backreferences), +// but it is not equipped to search in unexported struct fields. +func ValidateUnmarshaled(input any) []error { + // As we traverse through the input, we need to keep track of where we are to report error locations correctly. + // But in the happy case of no errors, we want to do this tracking in a way that minimizes allocations. + // This is why we do all our tracking in this one path slice. + // When we recurse down, we add additional elements to the slice. + // And when we recurse back up, the previous function will still have the shorter slice and, + // by extending it again on the next call, overwrite the previous subpath. + path := make([]pathElement, 0, 16) + + // We could have validateRecursively() return a list of errors, but then we would have to do + // useless extra allocations when building and then concatenating those lists of errors. + // It is cheaper to have a single error slice that everyone appends into. + var errs []error + + // And as one final piece of setup, our cycle detection needs a place to remember visited objects in. + // We will only make entries here for heap-allocated objects that can actually participate in cycles. + // The design of this type is adapted from type visit used by reflect.DeepEqual(). + vm := make(visitedMap) + + validateRecursively(reflect.ValueOf(input), path, vm, &errs) + return errs +} + +type pathElement struct { + // If this is set, this path element is an index into an array. + Index Option[int] + // If this is set, this path element is a field within a struct. + Field string + // Otherwise, this path element is a key within a map using a non-string type. + MapKey reflect.Value +} + +type visitedKey struct { + Ptr unsafe.Pointer + Type reflect.Type +} + +type visitedMap map[visitedKey]bool + +// IsVisited returns false exactly once for each value, and then true for each successive call. +func (m visitedMap) IsVisited(v reflect.Value) bool { + k := visitedKey{v.UnsafePointer(), v.Type()} + if m[k] { + return true + } + m[k] = true // make next call to IsVisited return true instead + return false +} + +// InvalidInstanceError is the error type returned by ValidateUnmarshaled(). +type InvalidInstanceError struct { + // A user-readable description of where the invalid instance was found within the input. + // Currently, this uses the JSON Pointer format of RFC 6901, but future versions may change this. + Path string + // The type of the invalid instance. + Type reflect.Type +} + +func newInvalidInstanceError(path []pathElement, type_ reflect.Type) InvalidInstanceError { + // convert path into a JSON pointer [RFC6901] + tokens := make([]string, len(path)+1) + tokens[0] = "" // ensure leading slash + for idx, elem := range path { + var token string + switch { + case elem.Index.IsSome(): + token = strconv.FormatInt(int64(elem.Index.UnwrapOr(0)), 10) + case elem.Field != "": + token = elem.Field + case elem.MapKey.Kind() == reflect.String: + token = elem.MapKey.String() + default: + token = fmt.Sprintf("%v", elem.MapKey) + } + token = strings.ReplaceAll(token, "~", "~0") + token = strings.ReplaceAll(token, "/", "~1") + tokens[idx+1] = token + } + pathStr := strings.Join(tokens, "/") + return InvalidInstanceError{pathStr, type_} +} + +// Error implements the builtin/error interface. +func (e InvalidInstanceError) Error() string { + return fmt.Sprintf("found an invalid %s at %s", e.Type.String(), e.Path) +} + +func validateRecursively(v reflect.Value, path []pathElement, vm visitedMap, errs *[]error) { + // This switch writes out each kind specifically, because we want to have + // a loud error in the default case if later Go versions add new kinds. + switch v.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.Chan, reflect.Func, reflect.String: + // All these types can absolutely not hold any refined values inside of them, so no work is necessary. + + case reflect.Interface, reflect.Pointer: + if !v.IsNil() && !vm.IsVisited(v) { + validateRecursively(v.Elem(), path, vm, errs) + } + + case reflect.Slice: + if v.IsNil() || vm.IsVisited(v) { + return + } + fallthrough + case reflect.Array: + for idx := range v.Len() { + subpath := append(path, pathElement{Index: Some(idx)}) + validateRecursively(v.Index(idx), subpath, vm, errs) + } + + case reflect.Map: + if !v.IsNil() && !vm.IsVisited(v) { + iter := v.MapRange() + for iter.Next() { + subpath := append(path, pathElement{MapKey: iter.Key()}) + validateRecursively(iter.Value(), subpath, vm, errs) + } + } + + case reflect.Struct: + if vt, ok := v.Interface().(validationTarget); ok { + if !vt.IsValid() { + *errs = append(*errs, newInvalidInstanceError(path, v.Type())) + } + } else { + for idx := range v.NumField() { + f := v.Field(idx) + // We cannot recurse into unexported fields; otherwise v.Interface() above will panic when recursing into another struct. + if f.CanInterface() { + subpath := append(path, pathElement{Field: v.Type().Field(idx).Name}) + validateRecursively(f, subpath, vm, errs) + } + } + } + + case reflect.UnsafePointer: + // For this type, we do not have any method of looking inside, so we cannot do anything. + + case reflect.Invalid: + fallthrough + default: + panic(fmt.Sprintf("do not know how to handle value %#v of kind %s", v, v.Kind().String())) + } +} |
