Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions tools/fxmigrate/mapping/mapping.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright IBM Corp. All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0

// Package mapping contains a minimal proof-of-concept for deterministic
// namespace transformation from Fabric channel-scoped namespaces into
// Fabric-X global namespace identifiers.
//
// The package intentionally uses a simple concatenation-based mapping
// (channel.namespace). This design is chosen for the POC to expose
// ambiguity and collision risks that a production migration pipeline
// would need to address. For example, the pairs (channel="a.b", namespace="c")
// and (channel="a", namespace="b.c") both map to "a.b.c" with the current
// scheme. This behaviour is deliberate for demonstration purposes.
//
// Possible future approaches to avoid such collisions include: escaping dots,
// length-prefixed encoding, hashing, or a structured encoding format.
// These are noted for future work but are NOT implemented here to keep the
// POC minimal and focused.
package mapping

import (
"encoding/json"
"errors"
"fmt"
"slices"
)

// ErrNamespaceCollision is returned when two different source namespaces
// map to the same Fabric-X namespace identifier.
var ErrNamespaceCollision = errors.New("namespace collision")

// KV is a simple key/value pair used in the mock snapshot model.
type KV struct {
Key string `json:"key"`
Value string `json:"value"`
}

// NamespaceState represents state for a single namespace in a channel snapshot.
type NamespaceState struct {
Namespace string `json:"namespace"`
KVs []KV `json:"kvs"`
}

// ChannelSnapshot is a minimal representation of a channel-scoped snapshot.
type ChannelSnapshot struct {
Channel string `json:"channel"`
Namespaces []NamespaceState `json:"namespaces"`
}

// FabricXState is the target structure produced by the POC transform.
type FabricXState struct {
Namespace string `json:"namespace"`
KVs []KV `json:"kvs"`
}

// MapNamespace deterministically maps a channel+namespace to a Fabric-X namespace.
// NOTE: This is intentionally a simple concatenation and therefore collision-prone.
// The POC aims to demonstrate this risk rather than silently hiding it.
// Do not use this as-is in production; consider safer encodings (see package doc).
func MapNamespace(channel, namespace string) string {
return fmt.Sprintf("%s.%s", channel, namespace)
}

// Transform converts a single ChannelSnapshot into FabricXState entries.
// It merges duplicate namespace entries within the same channel and
// produces deterministic ordering of namespaces and keys.
func Transform(snapshot ChannelSnapshot) ([]FabricXState, error) {
accum := map[string]FabricXState{}

for _, ns := range snapshot.Namespaces {
mapped := MapNamespace(snapshot.Channel, ns.Namespace)
cur := accum[mapped]
if cur.KVs == nil {
cur = FabricXState{Namespace: mapped, KVs: make([]KV, 0, len(ns.KVs))}
}
cur.KVs = append(cur.KVs, ns.KVs...)
accum[mapped] = cur
}

// Sort namespaces and keys to ensure deterministic output across runs.
// Determinism is important so the same input always yields identical output.
states := make([]FabricXState, 0, len(accum))
for _, s := range accum {
states = append(states, s)
}

// Use slices.SortFunc for clearer sorting intent.
slices.SortFunc(states, func(a, b FabricXState) int { return compareString(a.Namespace, b.Namespace) })

for i := range states {
slices.SortFunc(states[i].KVs, func(a, b KV) int {
if a.Key == b.Key {
return compareString(a.Value, b.Value)
}
return compareString(a.Key, b.Key)
})
}

return states, nil
}

// TransformMany converts multiple ChannelSnapshots and detects namespace collisions
// that occur when different source channel+namespace pairs map to the same target.
//
//nolint:gocognit // complexity acceptable for small POC mapping logic
func TransformMany(snapshots []ChannelSnapshot) ([]FabricXState, error) {
mappedSource := map[string]string{} // mapped -> source identifier
accum := map[string]FabricXState{}

for _, snap := range snapshots {
for _, ns := range snap.Namespaces {
mapped := MapNamespace(snap.Channel, ns.Namespace)
src := fmt.Sprintf("%s:%s", snap.Channel, ns.Namespace)
if prev, ok := mappedSource[mapped]; ok {
if prev != src {
return nil, fmt.Errorf("%w: %s vs %s", ErrNamespaceCollision, prev, src)
}
} else {
mappedSource[mapped] = src
}

cur := accum[mapped]
if cur.KVs == nil {
cur = FabricXState{Namespace: mapped, KVs: make([]KV, 0, len(ns.KVs))}
}
cur.KVs = append(cur.KVs, ns.KVs...)
accum[mapped] = cur
}
}

// Sort namespaces and keys to ensure deterministic output across runs.
// Determinism is important so the same input always yields identical output.
states := make([]FabricXState, 0, len(accum))
for _, s := range accum {
states = append(states, s)
}

// Sort namespaces deterministically.
slices.SortFunc(states, func(a, b FabricXState) int { return compareString(a.Namespace, b.Namespace) })

for i := range states {
// Sort keys within a namespace so output order is stable.
slices.SortFunc(states[i].KVs, func(a, b KV) int {
if a.Key == b.Key {
return compareString(a.Value, b.Value)
}
return compareString(a.Key, b.Key)
})
}

return states, nil
}

// WriteJSON returns an indented JSON representation of the states.
func WriteJSON(states []FabricXState) ([]byte, error) {
return json.MarshalIndent(states, "", " ")
}

// compareString is a small helper returning -1/0/1 for a < b / == / >.
func compareString(a, b string) int {
if a == b {
return 0
}
if a < b {
return -1
}
return 1
}
164 changes: 164 additions & 0 deletions tools/fxmigrate/mapping/mapping_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright IBM Corp. All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0

package mapping

import (
"bytes"
"errors"
"testing"
)

func TestDeterminism(t *testing.T) {
t.Parallel()
snap := ChannelSnapshot{
Channel: "chanA",
Namespaces: []NamespaceState{
{Namespace: "ns1", KVs: []KV{{Key: "k1", Value: "v1"}, {Key: "k2", Value: "v2"}}},
{Namespace: "ns2", KVs: []KV{{Key: "a", Value: "1"}}},
},
}

s1, err := Transform(snap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b1, err := WriteJSON(s1)
if err != nil {
t.Fatalf("json write failed: %v", err)
}

s2, err := Transform(snap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b2, err := WriteJSON(s2)
if err != nil {
t.Fatalf("json write failed: %v", err)
}

if !bytes.Equal(b1, b2) {
t.Fatal("determinism failed: outputs differ")
}
}

func TestCollisionDetection(t *testing.T) {
t.Parallel()
s1 := ChannelSnapshot{Channel: "a.b", Namespaces: []NamespaceState{{Namespace: "c"}}}
s2 := ChannelSnapshot{Channel: "a", Namespaces: []NamespaceState{{Namespace: "b.c"}}}

_, err := TransformMany([]ChannelSnapshot{s1, s2})
if err == nil {
t.Fatal("expected collision error, got nil")
}
if !errors.Is(err, ErrNamespaceCollision) {
t.Fatalf("expected ErrNamespaceCollision, got: %v", err)
}
}

func TestKeyPreservation(t *testing.T) {
t.Parallel()
snap := ChannelSnapshot{
Channel: "chan",
Namespaces: []NamespaceState{
{Namespace: "ns", KVs: []KV{{Key: "k1", Value: "v1"}, {Key: "k2", Value: "v2"}}},
},
}
states, err := Transform(snap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
if len(states[0].KVs) != 2 {
t.Fatalf("expected 2 keys preserved, got %d", len(states[0].KVs))
}
}

func TestEdgeCases(t *testing.T) {
t.Parallel()
t.Run("empty namespace", func(t *testing.T) {
t.Parallel()
snap := ChannelSnapshot{Channel: "c", Namespaces: []NamespaceState{{Namespace: ""}}}
states, err := Transform(snap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(states) != 1 {
t.Fatalf("expected 1 state, got %d", len(states))
}
})

t.Run("dots in names and unicode and case", func(t *testing.T) {
t.Parallel()
snap := ChannelSnapshot{Channel: "Ch.An", Namespaces: []NamespaceState{
{Namespace: "Ns.One", KVs: []KV{{Key: "K", Value: "v"}}},
{Namespace: "ünîcøde", KVs: []KV{{Key: "k", Value: "v"}}},
{Namespace: "dup", KVs: []KV{{Key: "x", Value: "1"}, {Key: "x", Value: "2"}}},
}}
states, err := Transform(snap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// key counts preserved
totalIn := 0
for _, ns := range snap.Namespaces {
totalIn += len(ns.KVs)
}
totalOut := 0
for _, s := range states {
totalOut += len(s.KVs)
}
if totalIn != totalOut {
t.Fatalf("key preservation mismatch: in=%d out=%d", totalIn, totalOut)
}
})
}

func TestDuplicateNamespaceMerge(t *testing.T) {
t.Parallel()
snap := ChannelSnapshot{
Channel: "chanDup",
Namespaces: []NamespaceState{
{Namespace: "ns", KVs: []KV{{Key: "b", Value: "2"}, {Key: "a", Value: "1"}}},
{Namespace: "ns", KVs: []KV{{Key: "c", Value: "3"}, {Key: "a", Value: "0"}}},
},
}

states, err := Transform(snap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(states) != 1 {
t.Fatalf("expected 1 merged namespace, got %d", len(states))
}
if len(states[0].KVs) != 4 {
t.Fatalf("expected 4 keys after merge, got %d", len(states[0].KVs))
}

expected := []KV{{Key: "a", Value: "0"}, {Key: "a", Value: "1"}, {Key: "b", Value: "2"}, {Key: "c", Value: "3"}}
for i, kv := range states[0].KVs {
if kv != expected[i] {
t.Fatalf("unexpected ordered kv at %d: got %+v expected %+v", i, kv, expected[i])
}
}

// Determinism: repeated transform should produce identical JSON
b1, err := WriteJSON(states)
if err != nil {
t.Fatalf("json write failed: %v", err)
}
states2, err := Transform(snap)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
b2, err := WriteJSON(states2)
if err != nil {
t.Fatalf("json write failed: %v", err)
}
if !bytes.Equal(b1, b2) {
t.Fatal("determinism failed for merged namespace: outputs differ")
}
}