From d2dbce6e8bf2184d0a494fcc387173da3d059bf1 Mon Sep 17 00:00:00 2001 From: "Dr. Carsten Leue" Date: Wed, 12 Nov 2025 18:23:57 +0100 Subject: [PATCH] fix: improve lens handling Signed-off-by: Dr. Carsten Leue --- v2/cli/lens.go | 24 +- v2/cli/lens_test.go | 4 - v2/optics/lens/iso/iso.go | 6 +- v2/optics/lens/iso/types.go | 14 + v2/optics/lens/lens.go | 4 +- v2/optics/lens/option/from.go | 67 ++++ v2/optics/lens/option/from_test.go | 481 +++++++++++++++++++++++++++++ v2/optics/lens/option/types.go | 3 + v2/optics/lens/types.go | 3 + v2/samples/lens/example_test.go | 145 +++++++++ v2/samples/lens/gen_lens.go | 38 +-- 11 files changed, 747 insertions(+), 42 deletions(-) create mode 100644 v2/optics/lens/iso/types.go create mode 100644 v2/optics/lens/option/from_test.go diff --git a/v2/cli/lens.go b/v2/cli/lens.go index 23c373b..3154e2a 100644 --- a/v2/cli/lens.go +++ b/v2/cli/lens.go @@ -116,18 +116,20 @@ func Make{{.Name}}Lenses() {{.Name}}Lenses { // Make{{.Name}}RefLenses creates a new {{.Name}}RefLenses with lenses for all fields func Make{{.Name}}RefLenses() {{.Name}}RefLenses { -{{- range .Fields}} -{{- if .IsOptional}} - iso{{.Name}} := I.FromZero[{{.TypeName}}]() -{{- end}} -{{- end}} return {{.Name}}RefLenses{ {{- range .Fields}} {{- if .IsOptional}} - {{.Name}}: L.MakeLensRef( - func(s *{{$.Name}}) O.Option[{{.TypeName}}] { return iso{{.Name}}.Get(s.{{.Name}}) }, - func(s *{{$.Name}}, v O.Option[{{.TypeName}}]) *{{$.Name}} { s.{{.Name}} = iso{{.Name}}.ReverseGet(v); return s }, - ), +{{- if .IsComparable}} + {{.Name}}: LO.FromIso[*{{$.Name}}](I.FromZero[{{.TypeName}}]())(L.MakeLensStrict( + func(s *{{$.Name}}) {{.TypeName}} { return s.{{.Name}} }, + func(s *{{$.Name}}, v {{.TypeName}}) *{{$.Name}} { s.{{.Name}} = v; return s }, + )), +{{- else}} + {{.Name}}: LO.FromIso[*{{$.Name}}](I.FromZero[{{.TypeName}}]())(L.MakeLensRef( + func(s *{{$.Name}}) {{.TypeName}} { return s.{{.Name}} }, + func(s *{{$.Name}}, v {{.TypeName}}) *{{$.Name}} { s.{{.Name}} = v; return s }, + )), +{{- end}} {{- else}} {{- if .IsComparable}} {{.Name}}: L.MakeLensStrict( @@ -460,9 +462,7 @@ func parseFile(filename string) ([]structInfo, string, error) { // Check if the type is comparable (for non-optional fields) // For optional fields, we don't need to check since they use LensO - if !isOptional { - isComparable = isComparableType(field.Type) - } + isComparable = isComparableType(field.Type) // Extract imports from this field's type fieldImports := make(map[string]string) diff --git a/v2/cli/lens_test.go b/v2/cli/lens_test.go index 540e314..3816a72 100644 --- a/v2/cli/lens_test.go +++ b/v2/cli/lens_test.go @@ -472,7 +472,6 @@ type TypeTest struct { assert.Equal(t, "Pointer", typeTest.Fields[2].Name) assert.Equal(t, "*string", typeTest.Fields[2].TypeName) assert.True(t, typeTest.Fields[2].IsOptional) - assert.False(t, typeTest.Fields[2].IsComparable, "IsComparable not set for optional fields") // Slice - not comparable assert.Equal(t, "Slice", typeTest.Fields[3].Name) @@ -526,9 +525,6 @@ func TestLensRefTemplatesWithComparable(t *testing.T) { assert.Contains(t, constructorStr, "Data: L.MakeLensRef(", "non-comparable field Data should use MakeLensRef in RefLenses") - // Pointer field - optional, should use MakeLensRef - assert.Contains(t, constructorStr, "Pointer: L.MakeLensRef(", - "optional field Pointer should use MakeLensRef in RefLenses") } func TestGenerateLensHelpersWithComparable(t *testing.T) { diff --git a/v2/optics/lens/iso/iso.go b/v2/optics/lens/iso/iso.go index 17a6c44..53c29a4 100644 --- a/v2/optics/lens/iso/iso.go +++ b/v2/optics/lens/iso/iso.go @@ -24,18 +24,18 @@ import ( ) // FromNillable converts a nillable value to an option and back -func FromNillable[T any]() I.Iso[*T, O.Option[T]] { +func FromNillable[T any]() Iso[*T, Option[T]] { return I.MakeIso(F.Flow2( O.FromPredicate(F.IsNonNil[T]), O.Map(F.Deref[T]), ), - O.Fold(F.Constant((*T)(nil)), F.Ref[T]), + O.Fold(F.ConstNil[T], F.Ref[T]), ) } // Compose converts a Lens to a property of `A` into a lens to a property of type `B` // the transformation is done via an ISO -func Compose[S, A, B any](ab I.Iso[A, B]) func(sa L.Lens[S, A]) L.Lens[S, B] { +func Compose[S, A, B any](ab Iso[A, B]) Operator[S, A, B] { return F.Pipe2( ab, IL.IsoAsLens[A, B], diff --git a/v2/optics/lens/iso/types.go b/v2/optics/lens/iso/types.go new file mode 100644 index 0000000..5284929 --- /dev/null +++ b/v2/optics/lens/iso/types.go @@ -0,0 +1,14 @@ +package iso + +import ( + "github.com/IBM/fp-go/v2/optics/iso" + "github.com/IBM/fp-go/v2/optics/lens" + "github.com/IBM/fp-go/v2/option" +) + +type ( + Option[A any] = option.Option[A] + Iso[S, A any] = iso.Iso[S, A] + Lens[S, A any] = lens.Lens[S, A] + Operator[S, A, B any] = lens.Operator[S, A, B] +) diff --git a/v2/optics/lens/lens.go b/v2/optics/lens/lens.go index 4fc3701..77ab21e 100644 --- a/v2/optics/lens/lens.go +++ b/v2/optics/lens/lens.go @@ -435,7 +435,7 @@ func compose[GET ~func(S) B, SET ~func(S, B) S, S, A, B any](creator func(get GE // person := Person{Name: "Alice", Address: Address{Street: "Main St"}} // street := personStreetLens.Get(person) // "Main St" // updated := personStreetLens.Set("Oak Ave")(person) -func Compose[S, A, B any](ab Lens[A, B]) func(Lens[S, A]) Lens[S, B] { +func Compose[S, A, B any](ab Lens[A, B]) Operator[S, A, B] { return compose(MakeLens[func(S) B, func(S, B) S], ab) } @@ -477,7 +477,7 @@ func Compose[S, A, B any](ab Lens[A, B]) func(Lens[S, A]) Lens[S, B] { // ) // // personStreetLens := F.Pipe1(addressLens, lens.ComposeRef[Person](streetLens)) -func ComposeRef[S, A, B any](ab Lens[A, B]) func(Lens[*S, A]) Lens[*S, B] { +func ComposeRef[S, A, B any](ab Lens[A, B]) Operator[*S, A, B] { return compose(MakeLensRef[func(*S) B, func(*S, B) *S], ab) } diff --git a/v2/optics/lens/option/from.go b/v2/optics/lens/option/from.go index 7c29709..5502da5 100644 --- a/v2/optics/lens/option/from.go +++ b/v2/optics/lens/option/from.go @@ -3,6 +3,7 @@ package option import ( F "github.com/IBM/fp-go/v2/function" "github.com/IBM/fp-go/v2/optics/lens" + LI "github.com/IBM/fp-go/v2/optics/lens/iso" O "github.com/IBM/fp-go/v2/option" ) @@ -95,3 +96,69 @@ func FromOption[S, A any](defaultValue A) func(sa LensO[S, A]) Lens[S, A] { func FromOptionRef[S, A any](defaultValue A) func(sa Lens[*S, Option[A]]) Lens[*S, A] { return fromOption(lens.MakeLensRefCurried[S, A], defaultValue) } + +// FromIso converts a Lens[S, A] to a LensO[S, A] using an isomorphism. +// +// This function takes an isomorphism between A and Option[A] and uses it to +// transform a regular lens into an optional lens. It's particularly useful when +// you have a custom isomorphism that defines how to convert between a value +// and its optional representation. +// +// The isomorphism must satisfy the round-trip laws: +// 1. iso.ReverseGet(iso.Get(a)) == a for all a: A +// 2. iso.Get(iso.ReverseGet(opt)) == opt for all opt: Option[A] +// +// Type Parameters: +// - S: The structure type containing the field +// - A: The type of the field being focused on +// +// Parameters: +// - iso: An isomorphism between A and Option[A] that defines the conversion +// +// Returns: +// - A function that takes a Lens[S, A] and returns a LensO[S, A] +// +// Example: +// +// type Config struct { +// timeout int +// } +// +// // Create a lens to the timeout field +// timeoutLens := lens.MakeLens( +// func(c Config) int { return c.timeout }, +// func(c Config, t int) Config { c.timeout = t; return c }, +// ) +// +// // Create an isomorphism that treats 0 as None +// zeroAsNone := iso.MakeIso( +// func(t int) option.Option[int] { +// if t == 0 { +// return option.None[int]() +// } +// return option.Some(t) +// }, +// func(opt option.Option[int]) int { +// return option.GetOrElse(func() int { return 0 })(opt) +// }, +// ) +// +// // Convert to optional lens +// optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens) +// +// config := Config{timeout: 0} +// opt := optTimeoutLens.Get(config) // None[int]() +// updated := optTimeoutLens.Set(option.Some(30))(config) // Config{timeout: 30} +// +// Common Use Cases: +// - Converting between sentinel values (like 0, -1, "") and Option +// - Applying custom validation logic when converting to/from Option +// - Integrating with existing isomorphisms like FromNillable +// +// See also: +// - FromPredicate: For predicate-based optional conversion +// - FromNillable: For pointer-based optional conversion +// - FromOption: For converting from optional to non-optional with defaults +func FromIso[S, A any](iso Iso[A, Option[A]]) func(Lens[S, A]) LensO[S, A] { + return LI.Compose[S](iso) +} diff --git a/v2/optics/lens/option/from_test.go b/v2/optics/lens/option/from_test.go new file mode 100644 index 0000000..941f5ce --- /dev/null +++ b/v2/optics/lens/option/from_test.go @@ -0,0 +1,481 @@ +// Copyright (c) 2023 - 2025 IBM Corp. +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package option + +import ( + "testing" + + EQT "github.com/IBM/fp-go/v2/eq/testing" + F "github.com/IBM/fp-go/v2/function" + ISO "github.com/IBM/fp-go/v2/optics/iso" + L "github.com/IBM/fp-go/v2/optics/lens" + LT "github.com/IBM/fp-go/v2/optics/lens/testing" + O "github.com/IBM/fp-go/v2/option" + "github.com/stretchr/testify/assert" +) + +// Test types +type Config struct { + timeout int + retries int +} + +type Settings struct { + maxConnections int + bufferSize int +} + +// TestFromIsoBasic tests basic functionality of FromIso +func TestFromIsoBasic(t *testing.T) { + // Create an isomorphism that treats 0 as None + zeroAsNone := ISO.MakeIso( + func(t int) O.Option[int] { + if t == 0 { + return O.None[int]() + } + return O.Some(t) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(0))(opt) + }, + ) + + // Create a lens to the timeout field + timeoutLens := L.MakeLens( + func(c Config) int { return c.timeout }, + func(c Config, t int) Config { c.timeout = t; return c }, + ) + + // Convert to optional lens using FromIso + optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens) + + t.Run("GetNone", func(t *testing.T) { + config := Config{timeout: 0, retries: 3} + result := optTimeoutLens.Get(config) + assert.True(t, O.IsNone(result)) + }) + + t.Run("GetSome", func(t *testing.T) { + config := Config{timeout: 30, retries: 3} + result := optTimeoutLens.Get(config) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 30, O.GetOrElse(F.Constant(0))(result)) + }) + + t.Run("SetNone", func(t *testing.T) { + config := Config{timeout: 30, retries: 3} + updated := optTimeoutLens.Set(O.None[int]())(config) + assert.Equal(t, 0, updated.timeout) + assert.Equal(t, 3, updated.retries) // Other fields unchanged + }) + + t.Run("SetSome", func(t *testing.T) { + config := Config{timeout: 0, retries: 3} + updated := optTimeoutLens.Set(O.Some(60))(config) + assert.Equal(t, 60, updated.timeout) + assert.Equal(t, 3, updated.retries) // Other fields unchanged + }) + + t.Run("SetPreservesOriginal", func(t *testing.T) { + original := Config{timeout: 30, retries: 3} + _ = optTimeoutLens.Set(O.Some(60))(original) + // Original should be unchanged + assert.Equal(t, 30, original.timeout) + assert.Equal(t, 3, original.retries) + }) +} + +// TestFromIsoWithNegativeSentinel tests using -1 as a sentinel value +func TestFromIsoWithNegativeSentinel(t *testing.T) { + // Create an isomorphism that treats -1 as None + negativeOneAsNone := ISO.MakeIso( + func(n int) O.Option[int] { + if n == -1 { + return O.None[int]() + } + return O.Some(n) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(-1))(opt) + }, + ) + + retriesLens := L.MakeLens( + func(c Config) int { return c.retries }, + func(c Config, r int) Config { c.retries = r; return c }, + ) + + optRetriesLens := FromIso[Config, int](negativeOneAsNone)(retriesLens) + + t.Run("GetNoneForNegativeOne", func(t *testing.T) { + config := Config{timeout: 30, retries: -1} + result := optRetriesLens.Get(config) + assert.True(t, O.IsNone(result)) + }) + + t.Run("GetSomeForZero", func(t *testing.T) { + config := Config{timeout: 30, retries: 0} + result := optRetriesLens.Get(config) + assert.True(t, O.IsSome(result)) + assert.Equal(t, 0, O.GetOrElse(F.Constant(-1))(result)) + }) + + t.Run("SetNoneToNegativeOne", func(t *testing.T) { + config := Config{timeout: 30, retries: 5} + updated := optRetriesLens.Set(O.None[int]())(config) + assert.Equal(t, -1, updated.retries) + }) +} + +// TestFromIsoLaws verifies that FromIso satisfies lens laws +func TestFromIsoLaws(t *testing.T) { + // Create an isomorphism + zeroAsNone := ISO.MakeIso( + func(t int) O.Option[int] { + if t == 0 { + return O.None[int]() + } + return O.Some(t) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(0))(opt) + }, + ) + + timeoutLens := L.MakeLens( + func(c Config) int { return c.timeout }, + func(c Config, t int) Config { c.timeout = t; return c }, + ) + + optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens) + + eqOptInt := O.Eq(EQT.Eq[int]()) + eqConfig := EQT.Eq[Config]() + + config := Config{timeout: 30, retries: 3} + newValue := O.Some(60) + + // Law 1: GetSet - lens.Set(lens.Get(s))(s) == s + t.Run("GetSetLaw", func(t *testing.T) { + result := optTimeoutLens.Set(optTimeoutLens.Get(config))(config) + assert.True(t, eqConfig.Equals(config, result)) + }) + + // Law 2: SetGet - lens.Get(lens.Set(a)(s)) == a + t.Run("SetGetLaw", func(t *testing.T) { + result := optTimeoutLens.Get(optTimeoutLens.Set(newValue)(config)) + assert.True(t, eqOptInt.Equals(newValue, result)) + }) + + // Law 3: SetSet - lens.Set(a2)(lens.Set(a1)(s)) == lens.Set(a2)(s) + t.Run("SetSetLaw", func(t *testing.T) { + a1 := O.Some(60) + a2 := O.None[int]() + result1 := optTimeoutLens.Set(a2)(optTimeoutLens.Set(a1)(config)) + result2 := optTimeoutLens.Set(a2)(config) + assert.True(t, eqConfig.Equals(result1, result2)) + }) + + // Use the testing helper to verify all laws + t.Run("AllLaws", func(t *testing.T) { + laws := LT.AssertLaws(t, eqOptInt, eqConfig)(optTimeoutLens) + assert.True(t, laws(config, O.Some(100))) + assert.True(t, laws(Config{timeout: 0, retries: 5}, O.None[int]())) + }) +} + +// TestFromIsoComposition tests composing FromIso with other lenses +func TestFromIsoComposition(t *testing.T) { + type Application struct { + config Config + } + + // Isomorphism for zero as none + zeroAsNone := ISO.MakeIso( + func(t int) O.Option[int] { + if t == 0 { + return O.None[int]() + } + return O.Some(t) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(0))(opt) + }, + ) + + // Lens to config field + configLens := L.MakeLens( + func(a Application) Config { return a.config }, + func(a Application, c Config) Application { a.config = c; return a }, + ) + + // Lens to timeout field + timeoutLens := L.MakeLens( + func(c Config) int { return c.timeout }, + func(c Config, t int) Config { c.timeout = t; return c }, + ) + + // Compose: Application -> Config -> timeout (as Option) + optTimeoutFromConfig := FromIso[Config, int](zeroAsNone)(timeoutLens) + optTimeoutFromApp := F.Pipe1( + configLens, + L.Compose[Application](optTimeoutFromConfig), + ) + + app := Application{config: Config{timeout: 0, retries: 3}} + + t.Run("ComposedGet", func(t *testing.T) { + result := optTimeoutFromApp.Get(app) + assert.True(t, O.IsNone(result)) + }) + + t.Run("ComposedSet", func(t *testing.T) { + updated := optTimeoutFromApp.Set(O.Some(45))(app) + assert.Equal(t, 45, updated.config.timeout) + assert.Equal(t, 3, updated.config.retries) + }) +} + +// TestFromIsoModify tests using Modify with FromIso-based lenses +func TestFromIsoModify(t *testing.T) { + zeroAsNone := ISO.MakeIso( + func(t int) O.Option[int] { + if t == 0 { + return O.None[int]() + } + return O.Some(t) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(0))(opt) + }, + ) + + timeoutLens := L.MakeLens( + func(c Config) int { return c.timeout }, + func(c Config, t int) Config { c.timeout = t; return c }, + ) + + optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens) + + t.Run("ModifyNoneToSome", func(t *testing.T) { + config := Config{timeout: 0, retries: 3} + // Map None to Some(10) + modified := L.Modify[Config](O.Map(func(x int) int { return x + 10 }))(optTimeoutLens)(config) + // Since it was None, Map doesn't apply, stays None (0) + assert.Equal(t, 0, modified.timeout) + }) + + t.Run("ModifySomeValue", func(t *testing.T) { + config := Config{timeout: 30, retries: 3} + // Double the timeout value + modified := L.Modify[Config](O.Map(func(x int) int { return x * 2 }))(optTimeoutLens)(config) + assert.Equal(t, 60, modified.timeout) + }) + + t.Run("ModifyWithAlt", func(t *testing.T) { + config := Config{timeout: 0, retries: 3} + // Use Alt to provide a default + modified := L.Modify[Config](func(opt O.Option[int]) O.Option[int] { + return O.Alt(F.Constant(O.Some(10)))(opt) + })(optTimeoutLens)(config) + assert.Equal(t, 10, modified.timeout) + }) +} + +// TestFromIsoWithStringEmpty tests using empty string as None +func TestFromIsoWithStringEmpty(t *testing.T) { + type User struct { + name string + email string + } + + // Isomorphism that treats empty string as None + emptyAsNone := ISO.MakeIso( + func(s string) O.Option[string] { + if s == "" { + return O.None[string]() + } + return O.Some(s) + }, + func(opt O.Option[string]) string { + return O.GetOrElse(F.Constant(""))(opt) + }, + ) + + emailLens := L.MakeLens( + func(u User) string { return u.email }, + func(u User, e string) User { u.email = e; return u }, + ) + + optEmailLens := FromIso[User, string](emptyAsNone)(emailLens) + + t.Run("EmptyStringAsNone", func(t *testing.T) { + user := User{name: "Alice", email: ""} + result := optEmailLens.Get(user) + assert.True(t, O.IsNone(result)) + }) + + t.Run("NonEmptyStringAsSome", func(t *testing.T) { + user := User{name: "Alice", email: "alice@example.com"} + result := optEmailLens.Get(user) + assert.True(t, O.IsSome(result)) + assert.Equal(t, "alice@example.com", O.GetOrElse(F.Constant(""))(result)) + }) + + t.Run("SetNoneToEmpty", func(t *testing.T) { + user := User{name: "Alice", email: "alice@example.com"} + updated := optEmailLens.Set(O.None[string]())(user) + assert.Equal(t, "", updated.email) + }) +} + +// TestFromIsoRoundTrip tests round-trip conversions +func TestFromIsoRoundTrip(t *testing.T) { + zeroAsNone := ISO.MakeIso( + func(t int) O.Option[int] { + if t == 0 { + return O.None[int]() + } + return O.Some(t) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(0))(opt) + }, + ) + + maxConnectionsLens := L.MakeLens( + func(s Settings) int { return s.maxConnections }, + func(s Settings, m int) Settings { s.maxConnections = m; return s }, + ) + + optMaxConnectionsLens := FromIso[Settings, int](zeroAsNone)(maxConnectionsLens) + + t.Run("RoundTripThroughGet", func(t *testing.T) { + settings := Settings{maxConnections: 100, bufferSize: 1024} + // Get the value, then Set it back + opt := optMaxConnectionsLens.Get(settings) + restored := optMaxConnectionsLens.Set(opt)(settings) + assert.Equal(t, settings, restored) + }) + + t.Run("RoundTripThroughSet", func(t *testing.T) { + settings := Settings{maxConnections: 0, bufferSize: 1024} + // Set a new value, then Get it + newOpt := O.Some(200) + updated := optMaxConnectionsLens.Set(newOpt)(settings) + retrieved := optMaxConnectionsLens.Get(updated) + assert.True(t, O.Eq(EQT.Eq[int]()).Equals(newOpt, retrieved)) + }) + + t.Run("RoundTripWithNone", func(t *testing.T) { + settings := Settings{maxConnections: 100, bufferSize: 1024} + // Set None, then get it back + updated := optMaxConnectionsLens.Set(O.None[int]())(settings) + retrieved := optMaxConnectionsLens.Get(updated) + assert.True(t, O.IsNone(retrieved)) + }) +} + +// TestFromIsoChaining tests chaining multiple FromIso transformations +func TestFromIsoChaining(t *testing.T) { + // Create two different isomorphisms + zeroAsNone := ISO.MakeIso( + func(t int) O.Option[int] { + if t == 0 { + return O.None[int]() + } + return O.Some(t) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(0))(opt) + }, + ) + + timeoutLens := L.MakeLens( + func(c Config) int { return c.timeout }, + func(c Config, t int) Config { c.timeout = t; return c }, + ) + + optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens) + + config := Config{timeout: 30, retries: 3} + + t.Run("ChainedOperations", func(t *testing.T) { + // Chain multiple operations + result := F.Pipe2( + config, + optTimeoutLens.Set(O.Some(60)), + optTimeoutLens.Set(O.None[int]()), + ) + assert.Equal(t, 0, result.timeout) + }) +} + +// TestFromIsoMultipleFields tests using FromIso on multiple fields +func TestFromIsoMultipleFields(t *testing.T) { + zeroAsNone := ISO.MakeIso( + func(t int) O.Option[int] { + if t == 0 { + return O.None[int]() + } + return O.Some(t) + }, + func(opt O.Option[int]) int { + return O.GetOrElse(F.Constant(0))(opt) + }, + ) + + timeoutLens := L.MakeLens( + func(c Config) int { return c.timeout }, + func(c Config, t int) Config { c.timeout = t; return c }, + ) + + retriesLens := L.MakeLens( + func(c Config) int { return c.retries }, + func(c Config, r int) Config { c.retries = r; return c }, + ) + + optTimeoutLens := FromIso[Config, int](zeroAsNone)(timeoutLens) + optRetriesLens := FromIso[Config, int](zeroAsNone)(retriesLens) + + t.Run("IndependentFields", func(t *testing.T) { + config := Config{timeout: 0, retries: 5} + + // Get both fields + timeoutOpt := optTimeoutLens.Get(config) + retriesOpt := optRetriesLens.Get(config) + + assert.True(t, O.IsNone(timeoutOpt)) + assert.True(t, O.IsSome(retriesOpt)) + assert.Equal(t, 5, O.GetOrElse(F.Constant(0))(retriesOpt)) + }) + + t.Run("SetBothFields", func(t *testing.T) { + config := Config{timeout: 0, retries: 0} + + // Set both fields + updated := F.Pipe2( + config, + optTimeoutLens.Set(O.Some(30)), + optRetriesLens.Set(O.Some(3)), + ) + + assert.Equal(t, 30, updated.timeout) + assert.Equal(t, 3, updated.retries) + }) +} + +// Made with Bob diff --git a/v2/optics/lens/option/types.go b/v2/optics/lens/option/types.go index 6d1f110..3864892 100644 --- a/v2/optics/lens/option/types.go +++ b/v2/optics/lens/option/types.go @@ -17,6 +17,7 @@ package option import ( "github.com/IBM/fp-go/v2/endomorphism" + "github.com/IBM/fp-go/v2/optics/iso" "github.com/IBM/fp-go/v2/optics/lens" "github.com/IBM/fp-go/v2/option" ) @@ -91,4 +92,6 @@ type ( // optLens := lens.FromNillableRef(timeoutLens) // // optLens is a LensO[*Config, *int] LensO[S, A any] = Lens[S, Option[A]] + + Iso[S, A any] = iso.Iso[S, A] ) diff --git a/v2/optics/lens/types.go b/v2/optics/lens/types.go index 45dfa0f..0d26478 100644 --- a/v2/optics/lens/types.go +++ b/v2/optics/lens/types.go @@ -80,4 +80,7 @@ type ( // with the focused value updated to a. The original structure is never modified. Set func(a A) Endomorphism[S] } + + Kleisli[S, A, B any] = func(A) Lens[S, B] + Operator[S, A, B any] = Kleisli[S, Lens[S, A], B] ) diff --git a/v2/samples/lens/example_test.go b/v2/samples/lens/example_test.go index 66e45d6..81e731a 100644 --- a/v2/samples/lens/example_test.go +++ b/v2/samples/lens/example_test.go @@ -20,6 +20,7 @@ import ( F "github.com/IBM/fp-go/v2/function" L "github.com/IBM/fp-go/v2/optics/lens" + O "github.com/IBM/fp-go/v2/option" "github.com/stretchr/testify/assert" ) @@ -196,3 +197,147 @@ func TestPersonRefLensesIdempotent(t *testing.T) { assert.Equal(t, "bob@example.com", differentEmail.Email) assert.Equal(t, "alice@example.com", person.Email, "Original should be unchanged") } + +func TestPersonRefLensesOptionalIdempotent(t *testing.T) { + // Test that setting an optional field to the same value returns the identical pointer + // This is important for performance and correctness in functional programming + + // Test with Phone field set to a value + phoneValue := "555-1234" + person := &Person{ + Name: "Alice", + Age: 30, + Email: "alice@example.com", + Phone: &phoneValue, + } + + refLenses := MakePersonRefLenses() + + // Test that setting Phone to the same value returns the same pointer + samePhone := refLenses.Phone.Set(O.Some(&phoneValue))(person) + assert.Same(t, person, samePhone, "Setting Phone to same value should return identical pointer") + + // Test with Phone field set to nil + personNoPhone := &Person{ + Name: "Bob", + Age: 25, + Email: "bob@example.com", + Phone: nil, + } + + // Setting Phone to None when it's already nil should return same pointer + sameNilPhone := refLenses.Phone.Set(O.None[*string]())(personNoPhone) + assert.Same(t, personNoPhone, sameNilPhone, "Setting Phone to None when already nil should return identical pointer") + + // Test that setting to a different value creates a new pointer + newPhoneValue := "555-5678" + differentPhone := refLenses.Phone.Set(O.Some(&newPhoneValue))(person) + assert.NotSame(t, person, differentPhone, "Setting Phone to different value should return new pointer") + assert.Equal(t, &newPhoneValue, differentPhone.Phone) + assert.Equal(t, &phoneValue, person.Phone, "Original should be unchanged") + + // Test setting from nil to Some creates new pointer + somePhone := refLenses.Phone.Set(O.Some(&phoneValue))(personNoPhone) + assert.NotSame(t, personNoPhone, somePhone, "Setting Phone from nil to Some should return new pointer") + assert.Equal(t, &phoneValue, somePhone.Phone) + assert.Nil(t, personNoPhone.Phone, "Original should be unchanged") + + // Test setting from Some to None creates new pointer + nonePhone := refLenses.Phone.Set(O.None[*string]())(person) + assert.NotSame(t, person, nonePhone, "Setting Phone from Some to None should return new pointer") + assert.Nil(t, nonePhone.Phone) + assert.Equal(t, &phoneValue, person.Phone, "Original should be unchanged") +} + +func TestAddressRefLensesOptionalIdempotent(t *testing.T) { + // Test Address.State optional field idempotency + + stateValue := "California" + address := &Address{ + Street: "123 Main St", + City: "Los Angeles", + ZipCode: "90001", + Country: "USA", + State: &stateValue, + } + + refLenses := MakeAddressRefLenses() + + // Test that setting State to the same value returns the same pointer + sameState := refLenses.State.Set(O.Some(&stateValue))(address) + assert.Same(t, address, sameState, "Setting State to same value should return identical pointer") + + // Test with State field set to nil + addressNoState := &Address{ + Street: "456 Oak Ave", + City: "Boston", + ZipCode: "02101", + Country: "USA", + State: nil, + } + + // Setting State to None when it's already nil should return same pointer + sameNilState := refLenses.State.Set(O.None[*string]())(addressNoState) + assert.Same(t, addressNoState, sameNilState, "Setting State to None when already nil should return identical pointer") + + // Test that setting to a different value creates a new pointer + newStateValue := "New York" + differentState := refLenses.State.Set(O.Some(&newStateValue))(address) + assert.NotSame(t, address, differentState, "Setting State to different value should return new pointer") + assert.Equal(t, &newStateValue, differentState.State) + assert.Equal(t, &stateValue, address.State, "Original should be unchanged") +} + +func TestCompanyRefLensesOptionalIdempotent(t *testing.T) { + // Test Company.Website optional field idempotency + + websiteValue := "https://example.com" + company := &Company{ + Name: "Tech Inc", + Address: Address{ + Street: "789 Tech Blvd", + City: "San Francisco", + ZipCode: "94102", + Country: "USA", + }, + CEO: Person{ + Name: "Jane Doe", + Age: 45, + Email: "jane@techinc.com", + }, + Website: &websiteValue, + } + + refLenses := MakeCompanyRefLenses() + + // Test that setting Website to the same value returns the same pointer + sameWebsite := refLenses.Website.Set(O.Some(&websiteValue))(company) + assert.Same(t, company, sameWebsite, "Setting Website to same value should return identical pointer") + + // Test with Website field set to nil + companyNoWebsite := &Company{ + Name: "Startup LLC", + Address: Address{ + Street: "101 Innovation Way", + City: "Austin", + ZipCode: "78701", + Country: "USA", + }, + CEO: Person{ + Name: "John Smith", + Age: 35, + Email: "john@startup.com", + }, + } + + // Setting Website to None when it's already nil should return same pointer + sameNilWebsite := refLenses.Website.Set(O.None[*string]())(companyNoWebsite) + assert.Same(t, companyNoWebsite, sameNilWebsite, "Setting Website to None when already nil should return identical pointer") + + // Test that setting to a different value creates a new pointer + newWebsiteValue := "https://newsite.com" + differentWebsite := refLenses.Website.Set(O.Some(&newWebsiteValue))(company) + assert.NotSame(t, company, differentWebsite, "Setting Website to different value should return new pointer") + assert.Equal(t, &newWebsiteValue, differentWebsite.Website) + assert.Equal(t, &websiteValue, company.Website, "Original should be unchanged") +} diff --git a/v2/samples/lens/gen_lens.go b/v2/samples/lens/gen_lens.go index 94c701f..b557082 100644 --- a/v2/samples/lens/gen_lens.go +++ b/v2/samples/lens/gen_lens.go @@ -2,7 +2,7 @@ package lens // Code generated by go generate; DO NOT EDIT. // This file was generated by robots at -// 2025-11-12 17:16:40.1431921 +0100 CET m=+0.003694701 +// 2025-11-12 18:15:07.69943 +0100 CET m=+0.005345401 import ( L "github.com/IBM/fp-go/v2/optics/lens" @@ -53,7 +53,6 @@ func MakePersonLenses() PersonLenses { // MakePersonRefLenses creates a new PersonRefLenses with lenses for all fields func MakePersonRefLenses() PersonRefLenses { - isoPhone := I.FromZero[*string]() return PersonRefLenses{ Name: L.MakeLensStrict( func(s *Person) string { return s.Name }, @@ -67,10 +66,10 @@ func MakePersonRefLenses() PersonRefLenses { func(s *Person) string { return s.Email }, func(s *Person, v string) *Person { s.Email = v; return s }, ), - Phone: L.MakeLensRef( - func(s *Person) O.Option[*string] { return isoPhone.Get(s.Phone) }, - func(s *Person, v O.Option[*string]) *Person { s.Phone = isoPhone.ReverseGet(v); return s }, - ), + Phone: LO.FromIso[*Person](I.FromZero[*string]())(L.MakeLensStrict( + func(s *Person) *string { return s.Phone }, + func(s *Person, v *string) *Person { s.Phone = v; return s }, + )), } } @@ -121,7 +120,6 @@ func MakeAddressLenses() AddressLenses { // MakeAddressRefLenses creates a new AddressRefLenses with lenses for all fields func MakeAddressRefLenses() AddressRefLenses { - isoState := I.FromZero[*string]() return AddressRefLenses{ Street: L.MakeLensStrict( func(s *Address) string { return s.Street }, @@ -139,10 +137,10 @@ func MakeAddressRefLenses() AddressRefLenses { func(s *Address) string { return s.Country }, func(s *Address, v string) *Address { s.Country = v; return s }, ), - State: L.MakeLensRef( - func(s *Address) O.Option[*string] { return isoState.Get(s.State) }, - func(s *Address, v O.Option[*string]) *Address { s.State = isoState.ReverseGet(v); return s }, - ), + State: LO.FromIso[*Address](I.FromZero[*string]())(L.MakeLensStrict( + func(s *Address) *string { return s.State }, + func(s *Address, v *string) *Address { s.State = v; return s }, + )), } } @@ -187,7 +185,6 @@ func MakeCompanyLenses() CompanyLenses { // MakeCompanyRefLenses creates a new CompanyRefLenses with lenses for all fields func MakeCompanyRefLenses() CompanyRefLenses { - isoWebsite := I.FromZero[*string]() return CompanyRefLenses{ Name: L.MakeLensStrict( func(s *Company) string { return s.Name }, @@ -201,10 +198,10 @@ func MakeCompanyRefLenses() CompanyRefLenses { func(s *Company) Person { return s.CEO }, func(s *Company, v Person) *Company { s.CEO = v; return s }, ), - Website: L.MakeLensRef( - func(s *Company) O.Option[*string] { return isoWebsite.Get(s.Website) }, - func(s *Company, v O.Option[*string]) *Company { s.Website = isoWebsite.ReverseGet(v); return s }, - ), + Website: LO.FromIso[*Company](I.FromZero[*string]())(L.MakeLensStrict( + func(s *Company) *string { return s.Website }, + func(s *Company, v *string) *Company { s.Website = v; return s }, + )), } } @@ -237,15 +234,14 @@ func MakeCheckOptionLenses() CheckOptionLenses { // MakeCheckOptionRefLenses creates a new CheckOptionRefLenses with lenses for all fields func MakeCheckOptionRefLenses() CheckOptionRefLenses { - isoValue := I.FromZero[string]() return CheckOptionRefLenses{ Name: L.MakeLensRef( func(s *CheckOption) option.Option[string] { return s.Name }, func(s *CheckOption, v option.Option[string]) *CheckOption { s.Name = v; return s }, ), - Value: L.MakeLensRef( - func(s *CheckOption) O.Option[string] { return isoValue.Get(s.Value) }, - func(s *CheckOption, v O.Option[string]) *CheckOption { s.Value = isoValue.ReverseGet(v); return s }, - ), + Value: LO.FromIso[*CheckOption](I.FromZero[string]())(L.MakeLensStrict( + func(s *CheckOption) string { return s.Value }, + func(s *CheckOption, v string) *CheckOption { s.Value = v; return s }, + )), } }