diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs new file mode 100644 index 0000000..34a4440 --- /dev/null +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringParserTests.cs @@ -0,0 +1,41 @@ +using pgLabII.PgUtils.ConnectionStrings; + +namespace pgLabII.PgUtils.Tests.ConnectionStrings; + +public class PqConnectionStringParserTests +{ + private readonly UnitTestTokenizer tokenizer = new(); + + private const string kw = "ab"; + private const string val = "cd"; + + public PqConnectionStringParserTests() + { + tokenizer + .AddString(kw) + .AddEquals() + .AddString(val); + } + + [Fact] + public void Success() + { + var parser = new PqConnectionStringParser(tokenizer); + IDictionary output = parser.Parse(); + + Assert.Single(output); + Assert.True(output.TryGetValue(kw, out string? result)); + Assert.Equal(val, result); + } + + [Fact] + public void StaticParse() + { + var output = PqConnectionStringParser.Parse("foo=bar"); + Assert.Single(output); + Assert.True(output.TryGetValue("foo", out string? result)); + Assert.Equal("bar", result); + } + // There are few tests here as this is a predictive parser and all error handling is done + // in the tokenizer +} diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringTokenizerTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringTokenizerTests.cs new file mode 100644 index 0000000..7ec407e --- /dev/null +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/PqConnectionStringTokenizerTests.cs @@ -0,0 +1,85 @@ +using pgLabII.PgUtils.ConnectionStrings; + +namespace pgLabII.PgUtils.Tests.ConnectionStrings; + +public class PqConnectionStringTokenizerTests +{ + [Theory] + [InlineData("abc", "abc")] + [InlineData("abc=", "abc")] + [InlineData(" abc =", "abc")] + public void GetKeyword_Success(string input, string expected) + { + PqConnectionStringTokenizer subject = new(input); + + Assert.Equal(expected, subject.GetKeyword()); + } + + [Theory] + [InlineData("=")] + [InlineData("")] + [InlineData(" ")] + public void GetKeyword_Throws(string input) + { + PqConnectionStringTokenizer subject = new(input); + Assert.Throws(() => subject.GetKeyword()); + } + + [Theory] + [InlineData("", true)] + [InlineData(" ", true)] + [InlineData(" \t", true)] + [InlineData("d", false)] + [InlineData("=", false)] + [InlineData(".", false)] + public void Eof(string input, bool expected) + { + PqConnectionStringTokenizer subject = new(input); + + Assert.Equal(expected, subject.Eof); + } + + [Theory] + [InlineData("=")] + [InlineData("=test")] + [InlineData(" = ")] + public void ConsumeEquals_Success(string input) + { + PqConnectionStringTokenizer subject = new(input); + subject.ConsumeEquals(); + } + + [Theory] + [InlineData("")] + [InlineData("t")] + [InlineData(" test")] + [InlineData(" ")] + public void ConsumeEquals_Throws(string input) + { + PqConnectionStringTokenizer subject = new(input); + Assert.Throws(() => subject.ConsumeEquals()); + } + + [Theory] + [InlineData("foo", "foo")] + [InlineData("foo ", "foo")] + [InlineData("foo=", "foo=")] + [InlineData("1.2.3.4", "1.2.3.4")] + [InlineData(@"'foo \'bar' ", "foo 'bar")] + public void GetValue_Success(string input, string expected) + { + PqConnectionStringTokenizer subject = new(input); + Assert.Equal(expected, subject.GetValue()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("'d")] + [InlineData(@"'\d'")] + public void GetValue_Throws(string input) + { + PqConnectionStringTokenizer subject = new(input); + Assert.Throws(() => subject.GetValue()); + } +} diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/PqToken.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/PqToken.cs new file mode 100644 index 0000000..101a05f --- /dev/null +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/PqToken.cs @@ -0,0 +1,9 @@ +namespace pgLabII.PgUtils.Tests.ConnectionStrings; + +internal enum PqToken +{ + String, + Equals, + Eof, + Exception +} diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizer.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizer.cs new file mode 100644 index 0000000..f3922e2 --- /dev/null +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizer.cs @@ -0,0 +1,84 @@ +using pgLabII.PgUtils.ConnectionStrings; + +namespace pgLabII.PgUtils.Tests.ConnectionStrings; + +internal class UnitTestTokenizer : IPqConnectionStringTokenizer +{ + private readonly struct Elem(PqToken result, object? output) + { + public readonly PqToken Result = result; + public readonly object? Output = output; + } + + private readonly List _tokens = []; + private int _position = 0; + + + public UnitTestTokenizer AddString(string? output) + { + _tokens.Add(new(PqToken.String, output)); + return this; + } + + public UnitTestTokenizer AddException(Exception? output) + { + _tokens.Add(new(PqToken.Exception, output)); + return this; + } + + public UnitTestTokenizer AddEquals() + { + _tokens.Add(new(PqToken.Equals, null)); + return this; + } + + // note we do no whitespace at end tests here + public bool Eof => _position >= _tokens.Count; + + public string GetKeyword() + { + EnsureNotEof(); + var elem = Consume(); + if (elem.Result == PqToken.String) + return (string)elem.Output!; + + throw new Exception("Unexpected call to GetKeyword"); + } + + + public void ConsumeEquals() + { + EnsureNotEof(); + var elem = Consume(); + if (elem.Result == PqToken.Equals) + return; + + throw new Exception("Unexpected call to ConsumeEquals"); + } + + public string GetValue() + { + EnsureNotEof(); + var elem = Consume(); + if (elem.Result == PqToken.String) + return (string)elem.Output!; + + throw new Exception("Unexpected call to GetValue"); + } + + private Elem Consume() + { + var elem = _tokens[_position++]; + if (elem.Result == PqToken.Exception) + { + throw (Exception)elem.Output!; + } + return elem; + } + + private void EnsureNotEof() + { + if (Eof) + throw new Exception("unexpected eof in test, wrong parser call?"); + } +} diff --git a/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizerTests.cs b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizerTests.cs new file mode 100644 index 0000000..6bcb053 --- /dev/null +++ b/pgLabII.PgUtils.Tests/ConnectionStrings/Util/UnitTestTokenizerTests.cs @@ -0,0 +1,81 @@ +namespace pgLabII.PgUtils.Tests.ConnectionStrings.Util; + +public class UnitTestTokenizerTests +{ + private readonly UnitTestTokenizer _sut = new(); + + [Fact] + public void Eof_True() + { + Assert.True(_sut.Eof); + } + + [Fact] + public void Eof_False() + { + _sut.AddString("a"); + Assert.False(_sut.Eof); + } + + [Fact] + public void GetKeyword_Success() + { + _sut.AddString("a"); + Assert.Equal("a", _sut.GetKeyword()); + } + + [Fact] + public void GetKeyword_Unexpected_Throws() + { + _sut.AddEquals(); + Assert.Throws(() => _sut.GetKeyword()); + } + + [Fact] + public void GetKeyword_SimulatesException() + { + _sut.AddException(new ArgumentNullException()); + Assert.Throws(() => _sut.GetKeyword()); + } + + [Fact] + public void GetValue_Success() + { + _sut.AddString("a"); + Assert.Equal("a", _sut.GetValue()); + } + + [Fact] + public void GetValue_Unexpected_Throws() + { + _sut.AddEquals(); + Assert.Throws(() => _sut.GetValue()); + } + + [Fact] + public void GetValue_SimulatesException() + { + _sut.AddException(new ArgumentNullException()); + Assert.Throws(() => _sut.GetValue()); + } + + [Fact] + public void ConsumeEquals_Success() + { + _sut.AddEquals(); + _sut.ConsumeEquals(); + } + + [Fact] + public void ConsumeEquals_Unexpected_Throws1() + { + Assert.Throws(() => _sut.ConsumeEquals()); + } + + [Fact] + public void ConsumeEquals_Unexpected_Throws2() + { + _sut.AddString("t"); + Assert.Throws(() => _sut.ConsumeEquals()); + } +} diff --git a/pgLabII.PgUtils/ConnectionStrings/PgConfigMapping.cs b/pgLabII.PgUtils/ConnectionStrings/PgConfigMapping.cs new file mode 100644 index 0000000..58ddad5 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/PgConfigMapping.cs @@ -0,0 +1,7 @@ +namespace pgLabII.PgUtils.ConnectionStrings; + +public readonly record struct PgConfigMapping( + string pqKeyword, + string? pqDumpParam, + string? pqEnvironment + ); diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/IPqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/IPqConnectionStringTokenizer.cs new file mode 100644 index 0000000..a2bef86 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/IPqConnectionStringTokenizer.cs @@ -0,0 +1,10 @@ +namespace pgLabII.PgUtils.ConnectionStrings; + +public interface IPqConnectionStringTokenizer +{ + bool Eof { get; } + //PqToken NextToken(out string? text); + string GetKeyword(); + void ConsumeEquals(); + string GetValue(); +} diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs new file mode 100644 index 0000000..d1c2228 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParser.cs @@ -0,0 +1,78 @@ +using System.Collections.ObjectModel; + +namespace pgLabII.PgUtils.ConnectionStrings; + +public class PqConnectionStringParser +{ + // Note possible keywords + // host + //hostaddr + //port + //dbname + //user + //password + //passfile + //channel_binding + //connect_timeout + //client_encoding + //options + //application_name + //fallback_application_name + //keepalives + //keepalives_idle + //keepalives_interval + //keepalives_count + //tcp_user_timeout + //replication + //gssencmode + //sslmode + //requiresll + //sslcompression + //sslcert + //sslkey + //sslpassword + //sslrootcert + //sslcrl + //sslcrldir + //sslsni + //requirepeer + //ssl_min_protocol_version + //ssl_max_protocol_version + //krbsrvname + //gsslib + //service + //target_session_attrs + + public static IDictionary Parse(string input) + { + return new PqConnectionStringParser( + new PqConnectionStringTokenizer(input) + ).Parse(); + } + + private readonly IPqConnectionStringTokenizer tokenizer; + private readonly Dictionary result = new(); + + public PqConnectionStringParser(IPqConnectionStringTokenizer tokenizer) + { + this.tokenizer = tokenizer; + } + + public IDictionary Parse() + { + result.Clear(); + + while (!tokenizer.Eof) + ParsePair(); + + return result; + } + + private void ParsePair() + { + string kw = tokenizer.GetKeyword(); + tokenizer.ConsumeEquals(); + string v = tokenizer.GetValue(); + result.Add(kw, v); + } +} diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParserException.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParserException.cs new file mode 100644 index 0000000..0a80ba6 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringParserException.cs @@ -0,0 +1,16 @@ +namespace pgLabII.PgUtils.ConnectionStrings; + +public class PqConnectionStringParserException : Exception +{ + public PqConnectionStringParserException() + { + } + + public PqConnectionStringParserException(string? message) : base(message) + { + } + + public PqConnectionStringParserException(string? message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs new file mode 100644 index 0000000..b8d7107 --- /dev/null +++ b/pgLabII.PgUtils/ConnectionStrings/Pq/PqConnectionStringTokenizer.cs @@ -0,0 +1,118 @@ +using System.Text; +using static System.Net.Mime.MediaTypeNames; + +namespace pgLabII.PgUtils.ConnectionStrings; + +public class PqConnectionStringTokenizer : IPqConnectionStringTokenizer +{ + private readonly string input; + private int position = 0; + + public bool Eof + { + get + { + ConsumeWhitespace(); + return position >= input.Length; + } + } + + public PqConnectionStringTokenizer(string input) + { + this.input = input; + position = 0; + } + + public string GetKeyword() + { + if (Eof) + throw new PqConnectionStringParserException($"Unexpected end of file was expecting a keyword at position {position}"); + + return GetString(forKeyword: true); + } + + public void ConsumeEquals() + { + ConsumeWhitespace(); + if (position < input.Length && input[position] == '=') + { + position++; + } + else + throw new PqConnectionStringParserException($"Was expecting '=' after keyword at position {position}"); + } + + public string GetValue() + { + if (Eof) + throw new PqConnectionStringParserException($"Unexpected end of file was expecting a keyword at position {position}"); + + return GetString(forKeyword: false); + } + + private string GetString(bool forKeyword) + { + if (forKeyword && input[position] == '=') + throw new PqConnectionStringParserException($"Unexpected '=' was expecting keyword at position {position}"); + + if (input[position] == '\'') + return ParseQuotedText(); + + return UnquotedString(forKeyword); + } + + private void ConsumeWhitespace() + { + while (position < input.Length && char.IsWhiteSpace(input[position])) + position++; + } + + private string UnquotedString(bool forKeyword) + { + int start = position; + while (++position < input.Length && !char.IsWhiteSpace(input[position]) && (!forKeyword || input[position] != '=')) + { } + return input.Substring(start, position - start); + } + + private string ParseQuotedText() + { + bool escape = false; + StringBuilder sb = new(); + int start = position; + while (++position < input.Length) + { + char c = input[position]; + if (escape) + { + switch (c) + { + case '\'': + case '\\': + sb.Append(c); + escape = false; + break; + default: + throw new PqConnectionStringParserException($"Invalid escape sequence at position {position}"); + } + } + else + { + if (c == '\'') + { + ++position; + return sb.ToString(); + } + else if (c == '\\') + { + escape = true; + } + else + { + sb.Append(c); + } + } + } + throw new PqConnectionStringParserException($"Missing end quote on value starting at {start}"); + } +}