Skip to content

Commit 899e6b4

Browse files
committed
feat: add ReplaceField transformation support
- Introduced ReplaceFieldTransformation to rename fields and filter by include/exclude criteria. - Updated TransformationSpec to include ReplaceField as a valid transformation type. - Implemented validation logic for ReplaceField transformation configuration, ensuring required fields and mutually exclusive options. - Added unit tests for ReplaceField transformation creation and validation scenarios. - Enhanced existing tests to cover new transformation functionality. - Updated CRDs to reflect the new ReplaceField transformation type in documentation.
1 parent 63bc727 commit 899e6b4

11 files changed

Lines changed: 413 additions & 14 deletions

api/v1/dataflow_types.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1342,7 +1342,7 @@ type SASLConfig struct {
13421342
// TransformationSpec defines a transformation to apply (type + config).
13431343
// +kubebuilder:pruning:PreserveUnknownFields
13441344
type TransformationSpec struct {
1345-
// Type of transformation: timestamp, flatten, filter, mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap
1345+
// Type of transformation: timestamp, flatten, filter, mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap, replaceField
13461346
Type string `json:"type"`
13471347

13481348
// Config holds transformation configuration. Structure depends on type.
@@ -1401,6 +1401,11 @@ func (t *TransformationSpec) GetDebeziumUnwrapConfig() (*DebeziumUnwrapTransform
14011401
return getTypedConfig[DebeziumUnwrapTransformation](t.Config)
14021402
}
14031403

1404+
// GetReplaceFieldConfig returns ReplaceField transformation config.
1405+
func (t *TransformationSpec) GetReplaceFieldConfig() (*ReplaceFieldTransformation, error) {
1406+
return getTypedConfig[ReplaceFieldTransformation](t.Config)
1407+
}
1408+
14041409
// TimestampTransformation adds a timestamp field
14051410
type TimestampTransformation struct {
14061411
// FieldName is the name of the timestamp field (default: created_at)
@@ -1494,6 +1499,23 @@ type DebeziumUnwrapTransformation struct {
14941499
SnapshotOperation string `json:"snapshotOperation,omitempty"`
14951500
}
14961501

1502+
// ReplaceFieldTransformation renames fields and optionally filters by include/exclude.
1503+
// Unlike select, include preserves nested structure (no key flattening).
1504+
// Include and Exclude are mutually exclusive. At least one of Renames, Include, or Exclude is required.
1505+
type ReplaceFieldTransformation struct {
1506+
// Renames is a list of oldPath:newPath mappings (JSONPath without requiring $.).
1507+
// +optional
1508+
Renames []string `json:"renames,omitempty"`
1509+
1510+
// Include keeps only the listed field paths, preserving nesting.
1511+
// +optional
1512+
Include []string `json:"include,omitempty"`
1513+
1514+
// Exclude removes the listed field paths.
1515+
// +optional
1516+
Exclude []string `json:"exclude,omitempty"`
1517+
}
1518+
14971519
// DataFlowStatus defines the observed state of DataFlow
14981520
type DataFlowStatus struct {
14991521
// Phase represents the current phase of the data flow

api/v1/dataflow_validation.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1032,11 +1032,41 @@ func validateTransformations(transformations []TransformationSpec, f *field.Path
10321032
} else {
10331033
all = append(all, field.Required(idx.Child("config"), "debeziumUnwrap transformation configuration is required"))
10341034
}
1035+
case transformtypes.ReplaceField:
1036+
if hasConfig {
1037+
var cfg ReplaceFieldTransformation
1038+
if err := json.Unmarshal(t.Config.Raw, &cfg); err != nil {
1039+
all = append(all, field.Invalid(idx.Child("config"), string(t.Config.Raw), "invalid replaceField config: "+err.Error()))
1040+
} else {
1041+
if len(cfg.Include) > 0 && len(cfg.Exclude) > 0 {
1042+
all = append(all, field.Invalid(idx.Child("config"), string(t.Config.Raw), "include and exclude are mutually exclusive"))
1043+
}
1044+
if len(cfg.Renames) == 0 && len(cfg.Include) == 0 && len(cfg.Exclude) == 0 {
1045+
all = append(all, field.Required(idx.Child("config"), "at least one of renames, include, or exclude is required"))
1046+
}
1047+
for j, rename := range cfg.Renames {
1048+
if !isValidReplaceFieldRename(rename) {
1049+
all = append(all, field.Invalid(idx.Child("config", "renames").Index(j), rename, "rename must be in oldPath:newPath format"))
1050+
}
1051+
}
1052+
}
1053+
} else {
1054+
all = append(all, field.Required(idx.Child("config"), "replaceField transformation configuration is required"))
1055+
}
10351056
}
10361057
}
10371058
return all
10381059
}
10391060

1061+
// isValidReplaceFieldRename reports whether rename is in oldPath:newPath form with non-empty sides.
1062+
func isValidReplaceFieldRename(rename string) bool {
1063+
oldPath, newPath, ok := strings.Cut(rename, ":")
1064+
if !ok {
1065+
return false
1066+
}
1067+
return strings.TrimSpace(oldPath) != "" && strings.TrimSpace(newPath) != ""
1068+
}
1069+
10401070
func validateResources(r *corev1.ResourceRequirements, f *field.Path) field.ErrorList {
10411071
var all field.ErrorList
10421072
if r == nil {

api/v1/dataflow_validation_transformations_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,106 @@ func TestValidateTransformations_DebeziumUnwrap(t *testing.T) {
8686
}
8787
})
8888
}
89+
90+
func TestValidateTransformations_ReplaceField(t *testing.T) {
91+
baseSpec := DataFlowSpec{
92+
Source: SourceSpec{
93+
Type: "kafka",
94+
Config: mustRawConfigForValidation(KafkaSourceSpec{Brokers: []string{"broker:9092"}, Topic: "src"}),
95+
},
96+
Sink: SinkSpec{
97+
Type: "kafka",
98+
Config: mustRawConfigForValidation(KafkaSinkSpec{Brokers: []string{"broker:9092"}, Topic: "dst"}),
99+
},
100+
}
101+
102+
t.Run("valid renames", func(t *testing.T) {
103+
spec := baseSpec
104+
spec.Transformations = []TransformationSpec{{
105+
Type: "replaceField",
106+
Config: mustRawConfigForValidation(ReplaceFieldTransformation{
107+
Renames: []string{"old:new", "a.b:c"},
108+
}),
109+
}}
110+
errs := ValidateDataFlowSpec(&spec)
111+
if len(errs) != 0 {
112+
t.Fatalf("expected no validation errors, got %v", errs)
113+
}
114+
})
115+
116+
t.Run("valid include", func(t *testing.T) {
117+
spec := baseSpec
118+
spec.Transformations = []TransformationSpec{{
119+
Type: "replaceField",
120+
Config: mustRawConfigForValidation(ReplaceFieldTransformation{
121+
Include: []string{"id", "user.name"},
122+
}),
123+
}}
124+
errs := ValidateDataFlowSpec(&spec)
125+
if len(errs) != 0 {
126+
t.Fatalf("expected no validation errors, got %v", errs)
127+
}
128+
})
129+
130+
t.Run("missing config", func(t *testing.T) {
131+
spec := baseSpec
132+
spec.Transformations = []TransformationSpec{{Type: "replaceField"}}
133+
errs := ValidateDataFlowSpec(&spec)
134+
if len(errs) == 0 {
135+
t.Fatal("expected validation error for missing config")
136+
}
137+
if !strings.Contains(errs.ToAggregate().Error(), "replaceField transformation configuration is required") {
138+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
139+
}
140+
})
141+
142+
t.Run("empty config", func(t *testing.T) {
143+
spec := baseSpec
144+
spec.Transformations = []TransformationSpec{{
145+
Type: "replaceField",
146+
Config: mustRawConfigForValidation(ReplaceFieldTransformation{}),
147+
}}
148+
errs := ValidateDataFlowSpec(&spec)
149+
if len(errs) == 0 {
150+
t.Fatal("expected validation error for empty replaceField config")
151+
}
152+
if !strings.Contains(errs.ToAggregate().Error(), "at least one of renames, include, or exclude is required") {
153+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
154+
}
155+
})
156+
157+
t.Run("include and exclude mutually exclusive", func(t *testing.T) {
158+
spec := baseSpec
159+
spec.Transformations = []TransformationSpec{{
160+
Type: "replaceField",
161+
Config: mustRawConfigForValidation(ReplaceFieldTransformation{
162+
Include: []string{"a"},
163+
Exclude: []string{"b"},
164+
}),
165+
}}
166+
errs := ValidateDataFlowSpec(&spec)
167+
if len(errs) == 0 {
168+
t.Fatal("expected validation error for include+exclude")
169+
}
170+
if !strings.Contains(errs.ToAggregate().Error(), "mutually exclusive") {
171+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
172+
}
173+
})
174+
175+
t.Run("invalid rename format", func(t *testing.T) {
176+
spec := baseSpec
177+
spec.Transformations = []TransformationSpec{{
178+
Type: "replaceField",
179+
Config: mustRawConfigForValidation(ReplaceFieldTransformation{
180+
Renames: []string{"bad-format"},
181+
}),
182+
}}
183+
errs := ValidateDataFlowSpec(&spec)
184+
if len(errs) == 0 {
185+
t.Fatal("expected validation error for invalid rename")
186+
}
187+
if !strings.Contains(errs.ToAggregate().Error(), "oldPath:newPath") {
188+
t.Fatalf("unexpected error: %v", errs.ToAggregate())
189+
}
190+
})
191+
}

api/v1/zz_generated.deepcopy.go

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/dataflow.dataflow.io_dataflowcrons.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,8 @@ spec:
12661266
x-kubernetes-preserve-unknown-fields: true
12671267
type:
12681268
description: 'Type of transformation: timestamp, flatten, filter,
1269-
mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap'
1269+
mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap,
1270+
replaceField'
12701271
type: string
12711272
required:
12721273
- type

config/crd/bases/dataflow.dataflow.io_dataflows.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1251,7 +1251,8 @@ spec:
12511251
x-kubernetes-preserve-unknown-fields: true
12521252
type:
12531253
description: 'Type of transformation: timestamp, flatten, filter,
1254-
mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap'
1254+
mask, router, select, remove, snakeCase, camelCase, debeziumUnwrap,
1255+
replaceField'
12551256
type: string
12561257
required:
12571258
- type

internal/transformers/factory.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ var transformerRegistry = map[string]transformerEntry{
4545
return NewDebeziumUnwrapTransformer(cfg)
4646
}),
4747
},
48+
transformtypes.ReplaceField: {create: createTransformer[v1.ReplaceFieldTransformation](transformtypes.ReplaceField, func(cfg *v1.ReplaceFieldTransformation) Transformer { return NewReplaceFieldTransformer(cfg) })},
4849
}
4950

5051
// createTransformer returns a factory function that unmarshals raw config into T and calls newFn.

internal/transformers/factory_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,29 @@ func TestCreateTransformer_DebeziumUnwrap(t *testing.T) {
259259
})
260260
}
261261

262+
func TestCreateTransformer_ReplaceField(t *testing.T) {
263+
runCreateTransformerTests(t, []transformerTestCase{
264+
{
265+
name: "valid replaceField transformation",
266+
transformation: &v1.TransformationSpec{
267+
Type: transformtypes.ReplaceField,
268+
Config: mustConfig(v1.ReplaceFieldTransformation{
269+
Renames: []string{"oldName:newName"},
270+
Include: []string{"id", "name"},
271+
}),
272+
},
273+
},
274+
{
275+
name: "replaceField without config",
276+
transformation: &v1.TransformationSpec{
277+
Type: transformtypes.ReplaceField,
278+
},
279+
wantErr: true,
280+
errContains: "replaceField transformation configuration is required",
281+
},
282+
})
283+
}
284+
262285
func TestCreateTransformer_UnsupportedType(t *testing.T) {
263286
transformation := &v1.TransformationSpec{
264287
Type: "unsupported",

0 commit comments

Comments
 (0)