Skip to content

Commit 3ed38b2

Browse files
committed
refactor: extract comment-parsing primitives to shared NpgsqlRest.Common
MCP-support prep (Phase 0). Moves StrEquals/StrEqualsToArray/SplitWords/ SplitWordsLower/SplitBySeparatorChar into NpgsqlRest.Common/CommentPrimitives.cs, shared via linked source (internal type, no separate dll/package) compiled into both NpgsqlRest core and NpgsqlRest.SqlFileSource. Core imports them project-wide via global usings; SqlFileSource's @single*/@Skip* matching now uses the shared StrEqualsToArray. Adds 22 characterization tests. No behavior change.
1 parent 74582bf commit 3ed38b2

7 files changed

Lines changed: 248 additions & 87 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
namespace NpgsqlRest.Common;
2+
3+
/// <summary>
4+
/// Shared comment/annotation parsing string primitives, compiled directly into each consuming
5+
/// assembly (NpgsqlRest core, NpgsqlRest.SqlFileSource, ...) via linked source — NOT a separate
6+
/// assembly or NuGet package. Declared <c>internal</c> on purpose: each consumer gets its own copy,
7+
/// avoiding duplicate-public-type collisions across the core→plugin reference boundary.
8+
///
9+
/// </summary>
10+
internal static class CommentPrimitives
11+
{
12+
// Canonical comment-annotation word separators (space, comma).
13+
private static readonly char[] WordSeparators = [' ', ','];
14+
15+
/// <summary>
16+
/// Case-insensitive compare with an optional leading '@' stripped from <paramref name="str1"/>
17+
/// (so "@authorize" equals "authorize"). The '@' is NOT stripped from <paramref name="str2"/>.
18+
/// </summary>
19+
public static bool StrEquals(string str1, string str2)
20+
{
21+
var s1 = str1.Length > 0 && str1[0] == '@' ? str1[1..] : str1;
22+
return s1.Equals(str2, StringComparison.OrdinalIgnoreCase);
23+
}
24+
25+
/// <summary>
26+
/// Case-insensitive match of <paramref name="str"/> (optional leading '@' stripped) against any
27+
/// of the supplied aliases.
28+
/// </summary>
29+
public static bool StrEqualsToArray(string str, params string[] arr)
30+
{
31+
var s = str.Length > 0 && str[0] == '@' ? str[1..] : str;
32+
for (var i = 0; i < arr.Length; i++)
33+
{
34+
if (s.Equals(arr[i], StringComparison.OrdinalIgnoreCase))
35+
{
36+
return true;
37+
}
38+
}
39+
return false;
40+
}
41+
42+
/// <summary>
43+
/// Split into trimmed words on space/comma, removing empty entries. Null input yields an empty
44+
/// array. Case preserved. Extension method.
45+
/// </summary>
46+
public static string[] SplitWords(this string? str)
47+
{
48+
if (str is null)
49+
{
50+
return [];
51+
}
52+
return [.. str
53+
.Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries)
54+
.Select(x => x.Trim())
55+
];
56+
}
57+
58+
/// <summary>
59+
/// As <see cref="SplitWords"/> but each word is lower-cased (invariant). Extension method.
60+
/// </summary>
61+
public static string[] SplitWordsLower(this string? str)
62+
{
63+
if (str is null)
64+
{
65+
return [];
66+
}
67+
return [.. str
68+
.Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries)
69+
.Select(x => x.Trim().ToLowerInvariant())
70+
];
71+
}
72+
73+
/// <summary>
74+
/// Splits <paramref name="str"/> on the first occurrence of <paramref name="sep"/> into a trimmed
75+
/// key/value pair. Returns false when the separator is absent, or when the key part contains a
76+
/// character that is not valid in a name (letters, digits, '-', '_', '@').
77+
/// </summary>
78+
public static bool SplitBySeparatorChar(string str, char sep, out string part1, out string part2)
79+
{
80+
part1 = null!;
81+
part2 = null!;
82+
if (str.Contains(sep) is false)
83+
{
84+
return false;
85+
}
86+
87+
var parts = str.Split(sep, 2);
88+
if (parts.Length == 2)
89+
{
90+
part1 = parts[0].Trim();
91+
part2 = parts[1].Trim();
92+
if (ContainsInvalidNameCharacter(part1))
93+
{
94+
return false;
95+
}
96+
return true;
97+
}
98+
return false;
99+
}
100+
101+
private static bool ContainsInvalidNameCharacter(string input)
102+
{
103+
foreach (char c in input)
104+
{
105+
// Allow '@' as a prefix (e.g., "@timeout = 30s"), plus '-' and '_'.
106+
if (char.IsLetterOrDigit(c) is false && c != '-' && c != '_' && c != '@')
107+
{
108+
return true;
109+
}
110+
}
111+
return false;
112+
}
113+
}

NpgsqlRest/Defaults/CommentParsers/Utilities.cs

Lines changed: 3 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -50,86 +50,9 @@ private static void TrackAnnotation(string label)
5050
return result;
5151
}
5252

53-
public static bool StrEquals(string str1, string str2)
54-
{
55-
// Support optional @ prefix for annotations (e.g., "@authorize" == "authorize")
56-
var s1 = str1.Length > 0 && str1[0] == '@' ? str1[1..] : str1;
57-
return s1.Equals(str2, StringComparison.OrdinalIgnoreCase);
58-
}
59-
60-
public static bool StrEqualsToArray(string str, params string[] arr)
61-
{
62-
// Support optional @ prefix for annotations (e.g., "@authorize" matches "authorize")
63-
var s = str.Length > 0 && str[0] == '@' ? str[1..] : str;
64-
for (var i = 0; i < arr.Length; i++)
65-
{
66-
if (s.Equals(arr[i], StringComparison.OrdinalIgnoreCase))
67-
{
68-
return true;
69-
}
70-
}
71-
return false;
72-
}
73-
74-
public static string[] SplitWordsLower(this string str)
75-
{
76-
if (str is null)
77-
{
78-
return [];
79-
}
80-
return [.. str
81-
.Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries)
82-
.Select(x => x.Trim().ToLowerInvariant())
83-
];
84-
}
85-
86-
public static string[] SplitWords(this string str)
87-
{
88-
if (str is null)
89-
{
90-
return [];
91-
}
92-
return [.. str
93-
.Split(WordSeparators, StringSplitOptions.RemoveEmptyEntries)
94-
.Select(x => x.Trim())
95-
];
96-
}
97-
98-
public static bool SplitBySeparatorChar(string str, char sep, out string part1, out string part2)
99-
{
100-
part1 = null!;
101-
part2 = null!;
102-
if (str.Contains(sep) is false)
103-
{
104-
return false;
105-
}
106-
107-
var parts = str.Split(sep, 2);
108-
if (parts.Length == 2)
109-
{
110-
part1 = parts[0].Trim();
111-
part2 = parts[1].Trim();
112-
if (ContainsValidNameCharacter(part1))
113-
{
114-
return false;
115-
}
116-
return true;
117-
}
118-
return false;
119-
}
120-
121-
private static bool ContainsValidNameCharacter(string input)
122-
{
123-
foreach (char c in input)
124-
{
125-
// Allow @ as first character for annotation prefix (e.g., "@timeout = 30s")
126-
if (char.IsLetterOrDigit(c) is false && c != '-' && c != '_' && c != '@')
127-
{
128-
return true;
129-
}
130-
}
131-
return false;
132-
}
53+
// String primitives (StrEquals / StrEqualsToArray / SplitWords / SplitWordsLower /
54+
// SplitBySeparatorChar) live in the shared NpgsqlRest.Common.CommentPrimitives and are
55+
// imported project-wide via global usings (see NpgsqlRest.csproj) — called directly here.
13356

13457
/// <summary>
13558
/// For SQL file routines, update column type descriptors when @param annotations have

NpgsqlRest/Defaults/DefaultCommentParser.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ namespace NpgsqlRest.Defaults;
55
internal static partial class DefaultCommentParser
66
{
77
private static readonly char[] NewlineSeparator = ['\r', '\n'];
8-
private static readonly char[] WordSeparators = [Consts.Space, Consts.Comma];
98

109
// All annotation keys moved to their respective handler files in CommentParsers directory
1110

NpgsqlRest/NpgsqlRest.csproj

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,4 +66,20 @@
6666
<InternalsVisibleTo Include="NpgsqlRestClient" />
6767
<InternalsVisibleTo Include="BenchmarkTests" />
6868
</ItemGroup>
69+
70+
<!-- Shared source (NpgsqlRest.Common): compiled directly into this assembly, not a separate
71+
dll/package. Type is internal, so each consumer gets its own private copy. -->
72+
<ItemGroup>
73+
<Compile Include="..\NpgsqlRest.Common\*.cs">
74+
<Link>Common\%(Filename)%(Extension)</Link>
75+
</Compile>
76+
</ItemGroup>
77+
78+
<!-- Import the shared comment-parsing primitives project-wide: the namespace enables the
79+
SplitWords/SplitWordsLower extension methods; the static import enables bare calls to
80+
StrEquals / StrEqualsToArray / SplitBySeparatorChar across the comment parsers. -->
81+
<ItemGroup>
82+
<Using Include="NpgsqlRest.Common" />
83+
<Using Include="NpgsqlRest.Common.CommentPrimitives" Static="true" />
84+
</ItemGroup>
6985
</Project>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using NpgsqlRest.Common;
2+
3+
namespace NpgsqlRestTests.ParserTests;
4+
5+
// Characterization tests pinning the behavior of the shared comment-parsing string primitives
6+
// (NpgsqlRest.Common.CommentPrimitives) — extracted from DefaultCommentParser in MCP plan Phase 0.
7+
// They document behavior, quirks included.
8+
public class CommentPrimitivesTests
9+
{
10+
// ---- StrEquals: optional leading '@' stripped from str1, then OrdinalIgnoreCase compare ----
11+
12+
[Theory]
13+
[InlineData("authorize", "authorize", true)]
14+
[InlineData("@authorize", "authorize", true)] // '@' prefix on str1 is stripped
15+
[InlineData("AUTHORIZE", "authorize", true)] // case-insensitive
16+
[InlineData("authorize", "AUTHORIZE", true)]
17+
[InlineData("authorize", "auth", false)]
18+
[InlineData("authorize", "@authorize", false)] // '@' is NOT stripped from str2
19+
[InlineData("@", "", true)] // "@" -> "" equals ""
20+
[InlineData("", "", true)]
21+
public void StrEquals_MatchesCurrentBehavior(string str1, string str2, bool expected)
22+
{
23+
CommentPrimitives.StrEquals(str1, str2).Should().Be(expected);
24+
}
25+
26+
// ---- StrEqualsToArray: '@' stripped from str, then OrdinalIgnoreCase match against any ----
27+
28+
[Fact]
29+
public void StrEqualsToArray_MatchesAnyAlias()
30+
{
31+
CommentPrimitives.StrEqualsToArray("cached", "cached", "cache").Should().BeTrue();
32+
CommentPrimitives.StrEqualsToArray("@cache", "cached", "cache").Should().BeTrue();
33+
CommentPrimitives.StrEqualsToArray("CACHE", "cached", "cache").Should().BeTrue();
34+
}
35+
36+
[Fact]
37+
public void StrEqualsToArray_NoMatch_ReturnsFalse()
38+
{
39+
CommentPrimitives.StrEqualsToArray("x", "a", "b").Should().BeFalse();
40+
}
41+
42+
[Fact]
43+
public void StrEqualsToArray_EmptyArray_ReturnsFalse()
44+
{
45+
CommentPrimitives.StrEqualsToArray("anything").Should().BeFalse();
46+
}
47+
48+
// ---- SplitWords: split on space/comma, RemoveEmptyEntries, trim, preserve case ----
49+
50+
[Fact]
51+
public void SplitWords_SplitsOnSpaceAndComma_Trimmed_PreservingCase()
52+
{
53+
"a, b, c".SplitWords().Should().Equal("a", "b", "c");
54+
"a b".SplitWords().Should().Equal("a", "b");
55+
" a , , b ".SplitWords().Should().Equal("a", "b"); // empty entries removed
56+
"Foo Bar".SplitWords().Should().Equal("Foo", "Bar"); // case preserved
57+
}
58+
59+
[Fact]
60+
public void SplitWords_NullOrEmpty_ReturnsEmpty()
61+
{
62+
((string)null!).SplitWords().Should().BeEmpty();
63+
"".SplitWords().Should().BeEmpty();
64+
}
65+
66+
// ---- SplitWordsLower: same as SplitWords but lowercased ----
67+
68+
[Fact]
69+
public void SplitWordsLower_LowercasesEachWord()
70+
{
71+
"Foo, BAR".SplitWordsLower().Should().Equal("foo", "bar");
72+
"MixedCase Word".SplitWordsLower().Should().Equal("mixedcase", "word");
73+
}
74+
75+
[Fact]
76+
public void SplitWordsLower_NullOrEmpty_ReturnsEmpty()
77+
{
78+
((string)null!).SplitWordsLower().Should().BeEmpty();
79+
"".SplitWordsLower().Should().BeEmpty();
80+
}
81+
82+
// ---- SplitBySeparatorChar: split on first separator; false if absent or part1 has invalid name char ----
83+
// Valid name chars: letter, digit, '-', '_', '@'. Anything else in part1 => not a key/value pair.
84+
85+
[Theory]
86+
[InlineData("key = value", '=', true, "key", "value")]
87+
[InlineData("Content-Type: application/json", ':', true, "Content-Type", "application/json")] // '-' allowed
88+
[InlineData("@timeout = 30s", '=', true, "@timeout", "30s")] // '@' allowed
89+
[InlineData("no separator here", '=', false, null, null)] // no separator
90+
[InlineData("a b = value", '=', false, null, null)] // space in part1 -> invalid
91+
[InlineData("key=", '=', true, "key", "")] // empty value
92+
[InlineData("=value", '=', true, "", "value")] // empty key
93+
public void SplitBySeparatorChar_MatchesCurrentBehavior(string input, char sep, bool expected, string? p1, string? p2)
94+
{
95+
var result = CommentPrimitives.SplitBySeparatorChar(input, sep, out var part1, out var part2);
96+
result.Should().Be(expected);
97+
if (expected)
98+
{
99+
part1.Should().Be(p1);
100+
part2.Should().Be(p2);
101+
}
102+
}
103+
}

plugins/NpgsqlRest.SqlFileSource/NpgsqlRest.SqlFileSource.csproj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,13 @@
5252
<ItemGroup>
5353
<ProjectReference Include="..\..\NpgsqlRest\NpgsqlRest.csproj" />
5454
</ItemGroup>
55+
56+
<!-- Shared source (NpgsqlRest.Common): compiled directly into this assembly, not a separate
57+
dll/package. Type is internal, so this plugin gets its own private copy (no clash with the
58+
copy compiled into the referenced NpgsqlRest core). -->
59+
<ItemGroup>
60+
<Compile Include="..\..\NpgsqlRest.Common\*.cs">
61+
<Link>Common\%(Filename)%(Extension)</Link>
62+
</Compile>
63+
</ItemGroup>
5564
</Project>

plugins/NpgsqlRest.SqlFileSource/SqlFileParser.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using NpgsqlRest.Common;
2+
13
namespace NpgsqlRest.SqlFileSource;
24

35
/// <summary>
@@ -464,18 +466,14 @@ private static void ExtractPerCommandAnnotations(string comment, int statementIn
464466
var s = trimmed.StartsWith('@') ? trimmed[1..] : trimmed;
465467

466468
// @single / @single_record / @single_result
467-
if (s.Equals("single", StringComparison.OrdinalIgnoreCase) ||
468-
s.Equals("single_record", StringComparison.OrdinalIgnoreCase) ||
469-
s.Equals("single_result", StringComparison.OrdinalIgnoreCase))
469+
if (CommentPrimitives.StrEqualsToArray(s, "single", "single_record", "single_result"))
470470
{
471471
result.SingleCommands.Add(statementIndex);
472472
continue;
473473
}
474474

475475
// @skip / @skip_result / @no_result
476-
if (s.Equals("skip", StringComparison.OrdinalIgnoreCase) ||
477-
s.Equals("skip_result", StringComparison.OrdinalIgnoreCase) ||
478-
s.Equals("no_result", StringComparison.OrdinalIgnoreCase))
476+
if (CommentPrimitives.StrEqualsToArray(s, "skip", "skip_result", "no_result"))
479477
{
480478
result.SkipCommands.Add(statementIndex);
481479
continue;

0 commit comments

Comments
 (0)