Repository: xataio/pgstream
Pull request #400 (2025-06-10 12:37:07)

Fix single letter first/last name neosync transformer
This PR adds the error catching mechanism for neosync transformers. As the first error to be catch, this PR catches `unable to find candidates with range` errors that are thrown by first name or last name transformers when trying to generate a name with single character. In that case we ignore the error and return a random letter as the new value.

Adding checks for `max_length` > 0 or not.

Also appending `neosync transformer:` to the beginning of all error messages that come from neosync `Transform` func.

fixes: #393 

github-actions[bot]: ### Merging this branch will **increase** overall coverage

| Impacted Packages | Coverage Δ | :robot: |
|-------------------|------------|---------|
| github.com/xataio/pgstream/pkg/transformers/neosync | 83.54% (**+0.97%**) | :thumbsup: |

---

<details>

<summary>Coverage by file</summary>

### Changed files (no unit tests)

| Changed File | Coverage Δ | Total | Covered | Missed | :robot: |
|--------------|------------|-------|---------|--------|---------|
| github.com/xataio/pgstream/pkg/transformers/neosync/errors.go | 50.00% (**+50.00%**) | 4 (+4) | 2 (+2) | 2 (+2) | :star2: |
| github.com/xataio/pgstream/pkg/transformers/neosync/neosync_firstname_transformer.go | 81.48% (**+6.48%**) | 27 (+11) | 22 (+10) | 5 (+1) | :thumbsup: |
| github.com/xataio/pgstream/pkg/transformers/neosync/neosync_lastname_transformer.go | 81.48% (**+6.48%**) | 27 (+11) | 22 (+10) | 5 (+1) | :thumbsup: |
| github.com/xataio/pgstream/pkg/transformers/neosync/neosync_transformer.go | 93.94% (**+3.03%**) | 33 | 31 (+1) | 2 (-1) | :thumbsup: |

_Please note that the "Total", "Covered", and "Missed" counts above refer to ***code statements*** instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code._

### Changed unit test files

- github.com/xataio/pgstream/pkg/transformers/neosync/neosync_firstname_transformer_test.go
- github.com/xataio/pgstream/pkg/transformers/neosync/neosync_lastname_transformer_test.go

</details>

github-actions[bot]: ### Merging this branch will **increase** overall coverage

| Impacted Packages | Coverage Δ | :robot: |
|-------------------|------------|---------|
| github.com/xataio/pgstream/pkg/transformers/neosync | 83.12% (**+0.55%**) | :thumbsup: |

---

<details>

<summary>Coverage by file</summary>

### Changed files (no unit tests)

| Changed File | Coverage Δ | Total | Covered | Missed | :robot: |
|--------------|------------|-------|---------|--------|---------|
| github.com/xataio/pgstream/pkg/transformers/neosync/errors.go | 50.00% (**+50.00%**) | 6 (+6) | 3 (+3) | 3 (+3) | :star2: |
| github.com/xataio/pgstream/pkg/transformers/neosync/neosync_firstname_transformer.go | 81.48% (**+6.48%**) | 27 (+11) | 22 (+10) | 5 (+1) | :thumbsup: |
| github.com/xataio/pgstream/pkg/transformers/neosync/neosync_lastname_transformer.go | 81.48% (**+6.48%**) | 27 (+11) | 22 (+10) | 5 (+1) | :thumbsup: |
| github.com/xataio/pgstream/pkg/transformers/neosync/neosync_transformer.go | 93.94% (**+3.03%**) | 33 | 31 (+1) | 2 (-1) | :thumbsup: |

_Please note that the "Total", "Covered", and "Missed" counts above refer to ***code statements*** instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code._

### Changed unit test files

- github.com/xataio/pgstream/pkg/transformers/neosync/neosync_firstname_transformer_test.go
- github.com/xataio/pgstream/pkg/transformers/neosync/neosync_lastname_transformer_test.go

</details>


Patch:
diff --git a/pkg/transformers/neosync/errors.go b/pkg/transformers/neosync/errors.go
new file mode 100644
index 0000000..34f4d46
--- /dev/null
+++ b/pkg/transformers/neosync/errors.go
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: Apache-2.0
+
+package neosync
+
+import (
+	"fmt"
+	"strings"
+)
+
+type errSingleCharName struct {
+	Details string
+}
+
+func (e *errSingleCharName) Error() string {
+	return fmt.Sprintf("no single character naming candidate: %s", e.Details)
+}
+
+func mapError(err error) error {
+	if err == nil {
+		return nil
+	}
+	if strings.HasPrefix(err.Error(), "unable to find candidates with range") && strings.HasSuffix(err.Error(), ":1]") {
+		return &errSingleCharName{Details: err.Error()}
+	}
+	return fmt.Errorf("neosync_transformer: %w", err)
+}
diff --git a/pkg/transformers/neosync/neosync_firstname_transformer.go b/pkg/transformers/neosync/neosync_firstname_transformer.go
index 316cd5c..694ab49 100644
--- a/pkg/transformers/neosync/neosync_firstname_transformer.go
+++ b/pkg/transformers/neosync/neosync_firstname_transformer.go
@@ -3,14 +3,19 @@
 package neosync
 
 import (
+	"context"
+	"errors"
 	"fmt"
 
 	neosynctransformers "github.com/nucleuscloud/neosync/worker/pkg/benthos/transformers"
+	transformer_utils "github.com/nucleuscloud/neosync/worker/pkg/benthos/transformers/utils"
+	"github.com/nucleuscloud/neosync/worker/pkg/rng"
 	"github.com/xataio/pgstream/pkg/transformers"
 )
 
 type FirstNameTransformer struct {
 	*transformer[string]
+	randomizer rng.Rand
 }
 
 var (
@@ -40,6 +45,8 @@ var (
 	firstNameCompatibleTypes = []transformers.SupportedDataType{
 		transformers.StringDataType,
 	}
+
+	errFirstNameLengthMustBeGreaterThanZero = errors.New("neosync_firstname: max_length must be greater than 0")
 )
 
 func NewFirstNameTransformer(params transformers.ParameterValues) (*FirstNameTransformer, error) {
@@ -53,21 +60,43 @@ func NewFirstNameTransformer(params transformers.ParameterValues) (*FirstNameTra
 		return nil, fmt.Errorf("neosync_firstname: max_length must be an integer: %w", err)
 	}
 
-	seed, err := findParameter[int](params, "seed")
+	if maxLength != nil && *maxLength < 1 {
+		return nil, errFirstNameLengthMustBeGreaterThanZero
+	}
+
+	seedParam, err := findParameter[int](params, "seed")
 	if err != nil {
 		return nil, fmt.Errorf("neosync_firstname: seed must be an integer: %w", err)
 	}
 
-	opts, err := neosynctransformers.NewTransformFirstNameOpts(toInt64Ptr(maxLength), preserveLength, toInt64Ptr(seed))
+	opts, err := neosynctransformers.NewTransformFirstNameOpts(toInt64Ptr(maxLength), preserveLength, toInt64Ptr(seedParam))
 	if err != nil {
 		return nil, err
 	}
 
+	seed, err := transformer_utils.GetSeedOrDefault(toInt64Ptr(seedParam))
+	if err != nil {
+		// not expected, but handle it gracefully
+		return nil, fmt.Errorf("neosync_firstname: unable to generate seed: %w", err)
+	}
+
 	return &FirstNameTransformer{
 		transformer: New[string](neosynctransformers.NewTransformFirstName(), opts),
+		randomizer:  rng.New(seed),
 	}, nil
 }
 
+func (t *FirstNameTransformer) Transform(ctx context.Context, value transformers.Value) (any, error) {
+	transformedValue, err := t.transformer.Transform(ctx, value)
+	if err != nil {
+		var errSingleChar *errSingleCharName
+		if errors.As(err, &errSingleChar) {
+			return string(uppercaseLetters[t.randomizer.Intn(len(uppercaseLetters))]), nil
+		}
+	}
+	return transformedValue, err
+}
+
 func (t *FirstNameTransformer) CompatibleTypes() []transformers.SupportedDataType {
 	return firstNameCompatibleTypes
 }
diff --git a/pkg/transformers/neosync/neosync_lastname_transformer.go b/pkg/transformers/neosync/neosync_lastname_transformer.go
index 8d76c5f..1443720 100644
--- a/pkg/transformers/neosync/neosync_lastname_transformer.go
+++ b/pkg/transformers/neosync/neosync_lastname_transformer.go
@@ -3,14 +3,19 @@
 package neosync
 
 import (
+	"context"
+	"errors"
 	"fmt"
 
 	neosynctransformers "github.com/nucleuscloud/neosync/worker/pkg/benthos/transformers"
+	transformer_utils "github.com/nucleuscloud/neosync/worker/pkg/benthos/transformers/utils"
+	"github.com/nucleuscloud/neosync/worker/pkg/rng"
 	"github.com/xataio/pgstream/pkg/transformers"
 )
 
 type LastNameTransformer struct {
 	*transformer[string]
+	randomizer rng.Rand
 }
 
 var (
@@ -40,6 +45,7 @@ var (
 	lastNameCompatibleTypes = []transformers.SupportedDataType{
 		transformers.StringDataType,
 	}
+	errLastNameLengthMustBeGreaterThanZero = errors.New("neosync_lastname: max_length must be greater than 0")
 )
 
 func NewLastNameTransformer(params transformers.ParameterValues) (*LastNameTransformer, error) {
@@ -53,21 +59,43 @@ func NewLastNameTransformer(params transformers.ParameterValues) (*LastNameTrans
 		return nil, fmt.Errorf("neosync_lastname: max_length must be an integer: %w", err)
 	}
 
-	seed, err := findParameter[int](params, "seed")
+	if maxLength != nil && *maxLength < 1 {
+		return nil, errLastNameLengthMustBeGreaterThanZero
+	}
+
+	seedParam, err := findParameter[int](params, "seed")
 	if err != nil {
-		return nil, fmt.Errorf("neosync_lastname: seed must be an integer: %w", err)
+		return nil, fmt.Errorf("neosync_firstname: seed must be an integer: %w", err)
 	}
 
-	opts, err := neosynctransformers.NewTransformLastNameOpts(toInt64Ptr(maxLength), preserveLength, toInt64Ptr(seed))
+	opts, err := neosynctransformers.NewTransformLastNameOpts(toInt64Ptr(maxLength), preserveLength, toInt64Ptr(seedParam))
 	if err != nil {
 		return nil, err
 	}
 
+	seed, err := transformer_utils.GetSeedOrDefault(toInt64Ptr(seedParam))
+	if err != nil {
+		// not expected, but handle it gracefully
+		return nil, fmt.Errorf("neosync_firstname: unable to generate seed: %w", err)
+	}
+
 	return &LastNameTransformer{
 		transformer: New[string](neosynctransformers.NewTransformLastName(), opts),
+		randomizer:  rng.New(seed),
 	}, nil
 }
 
+func (t *LastNameTransformer) Transform(ctx context.Context, value transformers.Value) (any, error) {
+	transformedValue, err := t.transformer.Transform(ctx, value)
+	if err != nil {
+		var errSingleChar *errSingleCharName
+		if errors.As(err, &errSingleChar) {
+			return string(uppercaseLetters[t.randomizer.Intn(len(uppercaseLetters))]), nil
+		}
+	}
+	return transformedValue, err
+}
+
 func (t *LastNameTransformer) CompatibleTypes() []transformers.SupportedDataType {
 	return lastNameCompatibleTypes
 }
diff --git a/pkg/transformers/neosync/neosync_transformer.go b/pkg/transformers/neosync/neosync_transformer.go
index fd8b7e7..a073a24 100644
--- a/pkg/transformers/neosync/neosync_transformer.go
+++ b/pkg/transformers/neosync/neosync_transformer.go
@@ -8,6 +8,11 @@ import (
 	"github.com/xataio/pgstream/pkg/transformers"
 )
 
+const (
+	uppercaseLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+	lowercaseLetters = "abcdefghijklmnopqrstuvwxyz"
+)
+
 // transformer is a wrapper around a neosync transformer. Neosync transformers
 // return a pointer to the type, so this implementation is generic to ensure
 // different types are supported.
@@ -30,7 +35,7 @@ func New[T any](t neosyncTransformer, opts any) *transformer[T] {
 func (t *transformer[T]) Transform(_ context.Context, value transformers.Value) (any, error) {
 	retPtr, err := t.neosyncTransformer.Transform(value.TransformValue, t.opts)
 	if err != nil {
-		return nil, err
+		return nil, mapError(err)
 	}
 
 	ret, ok := retPtr.(*T)


Test patch:
diff --git a/pkg/transformers/neosync/neosync_firstname_transformer_test.go b/pkg/transformers/neosync/neosync_firstname_transformer_test.go
index b550042..ca45826 100644
--- a/pkg/transformers/neosync/neosync_firstname_transformer_test.go
+++ b/pkg/transformers/neosync/neosync_firstname_transformer_test.go
@@ -33,6 +33,27 @@ func TestFirstnameTransformer_Transform(t *testing.T) {
 			wantName: "Ute",
 			wantErr:  nil,
 		},
+		{
+			name:  "ok - length 1",
+			value: "alice",
+			params: map[string]any{
+				"max_length": 1,
+				"seed":       12,
+			},
+
+			wantName: "I",
+			wantErr:  nil,
+		},
+		{
+			name:  "error - length -1",
+			value: "alice",
+			params: map[string]any{
+				"max_length": -1,
+				"seed":       12,
+			},
+
+			wantErr: errFirstNameLengthMustBeGreaterThanZero,
+		},
 		{
 			name:  "error - invalid preserve length",
 			value: "alice",
diff --git a/pkg/transformers/neosync/neosync_lastname_transformer_test.go b/pkg/transformers/neosync/neosync_lastname_transformer_test.go
index d3392ae..c50e62f 100644
--- a/pkg/transformers/neosync/neosync_lastname_transformer_test.go
+++ b/pkg/transformers/neosync/neosync_lastname_transformer_test.go
@@ -30,6 +30,27 @@ func TestNewLastnameTransformer(t *testing.T) {
 			wantErr:  nil,
 			wantName: "Fournaris",
 		},
+		{
+			name:  "ok - length 1",
+			input: "Liddell",
+			params: map[string]any{
+				"max_length": 1,
+				"seed":       12,
+			},
+
+			wantName: "I",
+			wantErr:  nil,
+		},
+		{
+			name:  "error - length -1",
+			input: "alice",
+			params: map[string]any{
+				"max_length": -1,
+				"seed":       12,
+			},
+
+			wantErr: errLastNameLengthMustBeGreaterThanZero,
+		},
 		{
 			name: "error - invalid preserve_length",
 			params: transformers.ParameterValues{