Port Go server/subject_transform.go to .NET. Implements a compiled transform engine that parses source patterns with wildcards and destination templates with function tokens at Create() time, then evaluates them efficiently at Apply() time without runtime regex. Supports all 9 transform functions: wildcard/$N, partition (FNV-1a), split, splitFromLeft, splitFromRight, sliceFromLeft, sliceFromRight, left, and right. Used for stream mirroring, account imports/exports, and subject routing.
397 lines
12 KiB
C#
397 lines
12 KiB
C#
using NATS.Server.Subscriptions;
|
|
|
|
namespace NATS.Server.Tests;
|
|
|
|
public class SubjectTransformTests
|
|
{
|
|
[Fact]
|
|
public void WildcardReplacement_SingleToken()
|
|
{
|
|
// foo.* -> bar.{{wildcard(1)}}
|
|
var transform = SubjectTransform.Create("foo.*", "bar.{{wildcard(1)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.baz").ShouldBe("bar.baz");
|
|
}
|
|
|
|
[Fact]
|
|
public void DollarSyntax_ReversesOrder()
|
|
{
|
|
// foo.*.* -> bar.$2.$1 reverses captured tokens
|
|
var transform = SubjectTransform.Create("foo.*.*", "bar.$2.$1");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.A.B").ShouldBe("bar.B.A");
|
|
}
|
|
|
|
[Fact]
|
|
public void DollarSyntax_MultipleWildcardPositions()
|
|
{
|
|
// foo.*.bar.*.baz -> req.$2.$1
|
|
var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.$2.$1");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
|
|
}
|
|
|
|
[Fact]
|
|
public void WildcardFunction_MultiplePositions()
|
|
{
|
|
// foo.*.bar.*.baz -> req.{{wildcard(2)}}.{{wildcard(1)}}
|
|
var transform = SubjectTransform.Create("foo.*.bar.*.baz", "req.{{wildcard(2)}}.{{wildcard(1)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.A.bar.B.baz").ShouldBe("req.B.A");
|
|
}
|
|
|
|
[Fact]
|
|
public void FullWildcardCapture_MultiToken()
|
|
{
|
|
// baz.> -> my.pre.> captures multi-token remainder
|
|
var transform = SubjectTransform.Create("baz.>", "my.pre.>");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("baz.1.2.3").ShouldBe("my.pre.1.2.3");
|
|
}
|
|
|
|
[Fact]
|
|
public void FullWildcardCapture_FooBar()
|
|
{
|
|
// baz.> -> foo.bar.>
|
|
var transform = SubjectTransform.Create("baz.>", "foo.bar.>");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("baz.1.2.3").ShouldBe("foo.bar.1.2.3");
|
|
}
|
|
|
|
[Fact]
|
|
public void NoMatch_ReturnsNull()
|
|
{
|
|
var transform = SubjectTransform.Create("foo.*", "bar.$1");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("baz.qux").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void NoMatch_WrongTokenCount()
|
|
{
|
|
var transform = SubjectTransform.Create("foo.*", "bar.$1");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.a.b").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_DeterministicResult()
|
|
{
|
|
// Partition should produce deterministic 0..N-1 results
|
|
var transform = SubjectTransform.Create("*", "bar.{{partition(10)}}");
|
|
transform.ShouldNotBeNull();
|
|
|
|
// FNV-1a of "foo" mod 10 = 3
|
|
transform.Apply("foo").ShouldBe("bar.3");
|
|
// FNV-1a of "baz" mod 10 = 0
|
|
transform.Apply("baz").ShouldBe("bar.0");
|
|
// FNV-1a of "qux" mod 10 = 9
|
|
transform.Apply("qux").ShouldBe("bar.9");
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_ZeroBuckets()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "bar.{{partition(0)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("baz").ShouldBe("bar.0");
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_WithTokenIndexes()
|
|
{
|
|
// partition(10, 1, 2) hashes concatenation of wildcard 1 and wildcard 2
|
|
// For source *.*: wildcard 1 -> pos 0 ("foo"), wildcard 2 -> pos 1 ("bar")
|
|
// Key = "foobar" (no separator), FNV-1a("foobar") % 10 = 0
|
|
var transform = SubjectTransform.Create("*.*", "bar.{{partition(10,1,2)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.bar").ShouldBe("bar.0");
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_WithSpecificToken()
|
|
{
|
|
// partition(10, 0) with wildcard source: in Go, wildcard index 0 silently
|
|
// maps to source position 0 (Go map zero-value behavior). We match this.
|
|
var transform = SubjectTransform.Create("*", "bar.{{partition(10, 0)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo").ShouldBe("bar.3");
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_ShorthandNoWildcardsInSource()
|
|
{
|
|
// When source has no wildcards, partition(n) hashes the full subject
|
|
var transform = SubjectTransform.Create("foo.bar", "baz.{{partition(10)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.bar").ShouldBe("baz.6");
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_ShorthandWithWildcards()
|
|
{
|
|
// partition(10) with wildcards hashes all subject tokens joined
|
|
var transform = SubjectTransform.Create("*.*", "bar.{{partition(10)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.bar").ShouldBe("bar.6");
|
|
}
|
|
|
|
[Fact]
|
|
public void SplitFunction_BasicDelimiter()
|
|
{
|
|
// events.a-b-c with split(1,-) -> split.a.b.c
|
|
var transform = SubjectTransform.Create("*", "{{split(1,-)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("abc-def--ghi-").ShouldBe("abc.def.ghi");
|
|
}
|
|
|
|
[Fact]
|
|
public void SplitFunction_LeadingDelimiter()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "{{split(1,-)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("-abc-def--ghi-").ShouldBe("abc.def.ghi");
|
|
}
|
|
|
|
[Fact]
|
|
public void LeftFunction_BasicTrim()
|
|
{
|
|
// data.abcdef with left(1,3) -> prefix.abc
|
|
var transform = SubjectTransform.Create("*", "prefix.{{left(1,3)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("abcdef").ShouldBe("prefix.abc");
|
|
}
|
|
|
|
[Fact]
|
|
public void LeftFunction_LenExceedsToken()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "{{left(1,6)}}");
|
|
transform.ShouldNotBeNull();
|
|
// When len exceeds token length, return full token
|
|
transform.Apply("1234").ShouldBe("1234");
|
|
}
|
|
|
|
[Fact]
|
|
public void LeftFunction_SingleChar()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "{{left(1,1)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("1234").ShouldBe("1");
|
|
}
|
|
|
|
[Fact]
|
|
public void RightFunction_BasicTrim()
|
|
{
|
|
// data.abcdef with right(1,3) -> suffix.def
|
|
var transform = SubjectTransform.Create("*", "suffix.{{right(1,3)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("abcdef").ShouldBe("suffix.def");
|
|
}
|
|
|
|
[Fact]
|
|
public void RightFunction_LenExceedsToken()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "{{right(1,6)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("1234").ShouldBe("1234");
|
|
}
|
|
|
|
[Fact]
|
|
public void RightFunction_SingleChar()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "{{right(1,1)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("1234").ShouldBe("4");
|
|
}
|
|
|
|
[Fact]
|
|
public void RightFunction_ThreeChars()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "{{right(1,3)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("1234").ShouldBe("234");
|
|
}
|
|
|
|
[Fact]
|
|
public void SplitFromLeft_BasicSplit()
|
|
{
|
|
// data.abcdef with splitFromLeft(1,3) -> parts.abc.def
|
|
var transform = SubjectTransform.Create("*", "{{splitFromLeft(1,3)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("12345").ShouldBe("123.45");
|
|
}
|
|
|
|
[Fact]
|
|
public void SplitFromRight_BasicSplit()
|
|
{
|
|
// data.abcdef with splitFromRight(1,3) -> parts.abc.def
|
|
var transform = SubjectTransform.Create("*", "{{SplitFromRight(1,3)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("12345").ShouldBe("12.345");
|
|
}
|
|
|
|
[Fact]
|
|
public void SliceFromLeft_BasicSlice()
|
|
{
|
|
// data.abcdef with sliceFromLeft(1,2) -> chunks.ab.cd.ef
|
|
var transform = SubjectTransform.Create("*", "{{SliceFromLeft(1,3)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("1234567890").ShouldBe("123.456.789.0");
|
|
}
|
|
|
|
[Fact]
|
|
public void SliceFromRight_BasicSlice()
|
|
{
|
|
// data.abcdef with sliceFromRight(1,2) -> chunks.ab.cd.ef
|
|
var transform = SubjectTransform.Create("*", "{{SliceFromRight(1,3)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("1234567890").ShouldBe("1.234.567.890");
|
|
}
|
|
|
|
[Fact]
|
|
public void LiteralPassthrough_NoWildcards()
|
|
{
|
|
// Literal source with no wildcards: exact match, returns dest
|
|
var transform = SubjectTransform.Create("foo", "bar");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo").ShouldBe("bar");
|
|
}
|
|
|
|
[Fact]
|
|
public void LiteralPassthrough_NoMatchOnDifferentSubject()
|
|
{
|
|
var transform = SubjectTransform.Create("foo", "bar");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("baz").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void InvalidSource_ReturnsNull()
|
|
{
|
|
// foo.. is not a valid subject
|
|
SubjectTransform.Create("foo..", "bar").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void InvalidSource_EmptyToken()
|
|
{
|
|
SubjectTransform.Create(".foo", "bar").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void WildcardIndexOutOfRange_ReturnsNull()
|
|
{
|
|
// Source has 1 wildcard but dest references $2
|
|
SubjectTransform.Create("foo.*", "bar.$2").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void DestinationWithWildcard_ReturnsNull()
|
|
{
|
|
// Wildcards not allowed in destination (pwc)
|
|
SubjectTransform.Create("foo.*", "bar.*").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void FwcMismatch_ReturnsNull()
|
|
{
|
|
// If source has >, dest must also have >
|
|
SubjectTransform.Create("foo.*", "bar.$1.>").ShouldBeNull();
|
|
SubjectTransform.Create("foo.>", "bar.baz").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void UnknownFunction_ReturnsNull()
|
|
{
|
|
SubjectTransform.Create("foo.*", "foo.{{unimplemented(1)}}").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void SingleWildcardCapture_ExpandedToBarPrefix()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "foo.bar.$1");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo").ShouldBe("foo.bar.foo");
|
|
}
|
|
|
|
[Fact]
|
|
public void ComboTransform_SplitAndSplitFromLeft()
|
|
{
|
|
// Combo: split + splitFromLeft
|
|
var transform = SubjectTransform.Create("*.*", "{{split(2,-)}}.{{splitfromleft(1,2)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.-abc-def--ghij-").ShouldBe("abc.def.ghij.fo.o");
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_NoWildcardSource_FullSubjectHash()
|
|
{
|
|
// foo.baz -> qux.{{partition(10)}}
|
|
var transform = SubjectTransform.Create("foo.baz", "qux.{{partition(10)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.baz").ShouldBe("qux.4");
|
|
}
|
|
|
|
[Fact]
|
|
public void PartitionFunction_NoWildcardSource_TestSubject()
|
|
{
|
|
var transform = SubjectTransform.Create("test.subject", "result.{{partition(5)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("test.subject").ShouldBe("result.0");
|
|
}
|
|
|
|
[Fact]
|
|
public void WildcardFunction_CaseInsensitive()
|
|
{
|
|
// Function names are case-insensitive (e.g. Wildcard, wildcard, WILDCARD)
|
|
var transform = SubjectTransform.Create("foo.*", "bar.{{Wildcard(1)}}");
|
|
transform.ShouldNotBeNull();
|
|
transform.Apply("foo.test").ShouldBe("bar.test");
|
|
}
|
|
|
|
[Fact]
|
|
public void SplitFromLeft_CaseInsensitive()
|
|
{
|
|
var transform = SubjectTransform.Create("*", "{{splitfromleft(1,1)}}");
|
|
transform.ShouldNotBeNull();
|
|
// Single char split from left pos 1: "ab" -> "a.b"
|
|
}
|
|
|
|
[Fact]
|
|
public void NotEnoughTokensInDest_PartitionWithMissingArgs()
|
|
{
|
|
SubjectTransform.Create("foo.*", "foo.{{partition()}}").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void WildcardFunctionBadArg_ReturnsNull()
|
|
{
|
|
SubjectTransform.Create("foo.*", "foo.{{wildcard(foo)}}").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void WildcardFunctionNoArgs_ReturnsNull()
|
|
{
|
|
SubjectTransform.Create("foo.*", "foo.{{wildcard()}}").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void WildcardFunctionTooManyArgs_ReturnsNull()
|
|
{
|
|
SubjectTransform.Create("foo.*", "foo.{{wildcard(1,2)}}").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void BadMustacheFormat_ReturnsNull()
|
|
{
|
|
SubjectTransform.Create("foo.*", "foo.{{ wildcard5) }}").ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void NoWildcardSource_TransformFunctionNotAllowed()
|
|
{
|
|
// When source has no wildcards, only partition and random functions are allowed
|
|
SubjectTransform.Create("foo", "bla.{{wildcard(1)}}").ShouldBeNull();
|
|
}
|
|
}
|