diff --git a/progaudi.tarantool.sln b/progaudi.tarantool.sln index c0ca998f..90d3a478 100644 --- a/progaudi.tarantool.sln +++ b/progaudi.tarantool.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.12 +# Visual Studio Version 17 +VisualStudioVersion = 17.6.33815.320 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "progaudi.tarantool", "src\progaudi.tarantool\progaudi.tarantool.csproj", "{DD007E9F-FB2D-4351-AAB7-F2D367B295C4}" EndProject @@ -9,7 +9,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "progaudi.tarantool.tests", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{14BAEDF1-BEFC-4FB2-AAC9-08D397191216}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "progaudi.tarantool.benchmark", "src\progaudi.tarantool.benchmark\progaudi.tarantool.benchmark.csproj", "{CD96AC2D-505F-4FDF-82FB-76F51CC28FF4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "progaudi.tarantool.benchmark", "src\progaudi.tarantool.benchmark\progaudi.tarantool.benchmark.csproj", "{CD96AC2D-505F-4FDF-82FB-76F51CC28FF4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "progaudi.tarantool.integration.tests", "src\tests\progaudi.tarantool.integration.tests\progaudi.tarantool.integration.tests.csproj", "{B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -57,6 +59,18 @@ Global {CD96AC2D-505F-4FDF-82FB-76F51CC28FF4}.Release|x64.Build.0 = Release|Any CPU {CD96AC2D-505F-4FDF-82FB-76F51CC28FF4}.Release|x86.ActiveCfg = Release|Any CPU {CD96AC2D-505F-4FDF-82FB-76F51CC28FF4}.Release|x86.Build.0 = Release|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Debug|x64.Build.0 = Debug|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Debug|x86.Build.0 = Debug|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Release|Any CPU.Build.0 = Release|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Release|x64.ActiveCfg = Release|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Release|x64.Build.0 = Release|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Release|x86.ActiveCfg = Release|Any CPU + {B26A117F-6E7B-4AB0-B3D4-DDBCFC8732C8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/progaudi.tarantool/Converters/DateTimeConverter.cs b/src/progaudi.tarantool/Converters/DateTimeConverter.cs new file mode 100644 index 00000000..c90d337f --- /dev/null +++ b/src/progaudi.tarantool/Converters/DateTimeConverter.cs @@ -0,0 +1,87 @@ +using ProGaudi.MsgPack.Light; +using ProGaudi.Tarantool.Client.Model.Enums; +using ProGaudi.Tarantool.Client.Utils; +using System; +using System.Buffers.Binary; + +namespace ProGaudi.Tarantool.Client.Converters +{ + /// + /// Converter for Tarantool datetime values, implemeted as MsgPack extension. + /// See https://www.tarantool.io/ru/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type + /// + internal class DateTimeConverter : IMsgPackConverter, IMsgPackConverter + { + private const byte MP_DATETIME = 0x04; + private static readonly DateTime UnixEpocUtc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public void Initialize(MsgPackContext context) + { + } + + public DateTime Read(IMsgPackReader reader) + { + var dataType = reader.ReadByte(); + var mpHeader = reader.ReadByte(); + if (mpHeader != MP_DATETIME) + { + throw ExceptionHelper.UnexpectedMsgPackHeader(mpHeader, MP_DATETIME); + } + + if (dataType == MsgPackExtDataTypes.FixExt8) + { + var seconds = BinaryPrimitives.ReadInt32LittleEndian(reader.ReadBytes(4)); + var nanoSeconds = BinaryPrimitives.ReadInt16LittleEndian(reader.ReadBytes(2)); + var _ = reader.ReadBytes(2);// also need to extract tzoffset; tzindex; + return UnixEpocUtc.AddSeconds(seconds).AddTicks(nanoSeconds / 100); + } + else if (dataType == MsgPackExtDataTypes.FixExt16) + { + var seconds = BinaryPrimitives.ReadInt64LittleEndian(reader.ReadBytes(8)); + var nanoSeconds = BinaryPrimitives.ReadInt32LittleEndian(reader.ReadBytes(4)); + var _ = reader.ReadBytes(4);// also need to extract tzoffset; tzindex; + return UnixEpocUtc.AddSeconds(seconds).AddTicks(nanoSeconds / 100); + } + + throw ExceptionHelper.UnexpectedDataType(dataType, MsgPackExtDataTypes.FixExt8, MsgPackExtDataTypes.FixExt16); + } + + DateTimeOffset IMsgPackConverter.Read(IMsgPackReader reader) + { + return Read(reader); + } + + public void Write(DateTimeOffset value, IMsgPackWriter writer) + { + var timeSpan = value.ToUniversalTime().Subtract(UnixEpocUtc); + long seconds = (long)timeSpan.TotalSeconds; + timeSpan = timeSpan.Subtract(TimeSpan.FromSeconds(seconds)); + int nanoSeconds = (int)(timeSpan.Ticks * 100); + int _ = 0;// also need to extract tzoffset; tzindex; + + writer.Write(MsgPackExtDataTypes.FixExt16); + writer.Write(MP_DATETIME); + + var byteArray = new byte[8]; + var span = new Span(byteArray); + BinaryPrimitives.WriteInt64LittleEndian(span, seconds); + writer.Write(byteArray); + + byteArray = new byte[4]; + span = new Span(byteArray); + BinaryPrimitives.WriteInt32LittleEndian(span, nanoSeconds); + writer.Write(byteArray); + + byteArray = new byte[4]; + span = new Span(byteArray); + BinaryPrimitives.WriteInt32LittleEndian(span, _); + writer.Write(byteArray); + + } + + public void Write(DateTime value, IMsgPackWriter writer) + { + Write((DateTimeOffset)value, writer); + } + } +} diff --git a/src/progaudi.tarantool/Converters/DecimalConverter.cs b/src/progaudi.tarantool/Converters/DecimalConverter.cs new file mode 100644 index 00000000..ad08ff72 --- /dev/null +++ b/src/progaudi.tarantool/Converters/DecimalConverter.cs @@ -0,0 +1,239 @@ +using ProGaudi.MsgPack.Light; +using ProGaudi.Tarantool.Client.Model.Enums; +using ProGaudi.Tarantool.Client.Utils; +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +namespace ProGaudi.Tarantool.Client.Converters +{ + /// + /// Converter for Tarantool decimal values, implemented as MsgPack extension. + /// Format described in https://www.tarantool.io/ru/doc/latest/dev_guide/internals/msgpack_extensions/#the-decimal-type + /// Limitation: .NET decimal max scale is 28 digits, when Tarantool decimal max scale is 38 digits + /// + public class DecimalConverter : IMsgPackConverter + { + private static readonly byte[] SupportedFixTypes = new byte[5] + { + MsgPackExtDataTypes.FixExt1, + MsgPackExtDataTypes.FixExt2, + MsgPackExtDataTypes.FixExt4, + MsgPackExtDataTypes.FixExt8, + MsgPackExtDataTypes.FixExt16 + }; + private static readonly byte[] SupportedNonFixTypes = new byte[3] + { + MsgPackExtDataTypes.Ext8, + MsgPackExtDataTypes.Ext16, + MsgPackExtDataTypes.Ext32 + }; + + private const byte MP_DECIMAL = 0x01; + private const byte DECIMAL_PLUS = 0x0C; + private const byte DECIMAL_MINUS = 0x0D; + private const byte DECIMAL_MINUS_ALT = 0x0B; + + public void Initialize(MsgPackContext context) + { + } + + public decimal Read(IMsgPackReader reader) + { + var dataType = reader.ReadByte(); + var fixedDataType = true; + var len = 0; + switch (dataType) + { + case MsgPackExtDataTypes.Ext8: + case MsgPackExtDataTypes.Ext16: + case MsgPackExtDataTypes.Ext32: + fixedDataType = false; + break; + case MsgPackExtDataTypes.FixExt1: + len = 1; + break; + case MsgPackExtDataTypes.FixExt2: + len = 2; + break; + case MsgPackExtDataTypes.FixExt4: + len = 4; + break; + case MsgPackExtDataTypes.FixExt8: + len = 8; + break; + case MsgPackExtDataTypes.FixExt16: + len = 16; + break; + default: + throw ExceptionHelper.UnexpectedDataType(dataType, SupportedFixTypes.Union(SupportedNonFixTypes).ToArray()); + } + + if (!fixedDataType) + { + len = reader.ReadByte(); + } + + var mpHeader = reader.ReadByte(); + if (mpHeader != MP_DECIMAL) + { + throw ExceptionHelper.UnexpectedMsgPackHeader(mpHeader, MP_DECIMAL); + } + + var data = reader.ReadBytes((uint)len).ToArray(); + + // used Java impl https://github.com/tarantool/cartridge-java/blob/1ca12332b870167b86d3e38891ab74527dfc8a19/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToBigDecimalConverter.java + + // Extract sign from the last nibble + int signum = (byte)(SecondNibbleFromByte(data[len - 1])); + if (signum == DECIMAL_MINUS || signum == DECIMAL_MINUS_ALT) + { + signum = -1; + } + else if (signum <= 0x09) + { + throw new IOException("The sign nibble has wrong value"); + } + else + { + signum = 1; + } + + int scale = data[0]; + if (scale > 28) + { + throw new OverflowException($"Maximum .NET decimal scale is exceeded. Maximum: 28. Actual: {scale}"); + } + + int skipIndex = 1; //skip byte with scale + + int digitsNum = (len - skipIndex) << 1; + char digit = CharFromDigit(FirstNibbleFromByte(data[len - 1]), digitsNum - 1); + + char[] digits = new char[digitsNum]; + int pos = 2 * (len - skipIndex) - 1; + + digits[pos--] = digit; + for (int i = len - 2; i >= skipIndex; i--) + { + digits[pos--] = CharFromDigit(SecondNibbleFromByte(data[i]), pos); + digits[pos--] = CharFromDigit(FirstNibbleFromByte(data[i]), pos); + } + + return CreateDecimalFromDigits(digits, scale, signum < 0); + } + + public void Write(decimal value, IMsgPackWriter writer) + { + (int scale, decimal unscaledValue) = ExtractScaleFromDecimal(value); + + // used Java impl https://github.com/tarantool/cartridge-java/blob/1ca12332b870167b86d3e38891ab74527dfc8a19/src/main/java/io/tarantool/driver/mappers/converters/value/defaults/DefaultExtensionValueToBigDecimalConverter.java + var unscaledValueStr = unscaledValue.ToString(); + byte signum = value >= 0 ? DECIMAL_PLUS : DECIMAL_MINUS; + int digitsNum = unscaledValueStr.Length; + + int len = (digitsNum >> 1) + 1; + byte[] payload = new byte[len]; + payload[len - 1] = signum; + int pos = 0; + char[] digits = unscaledValueStr.Substring(pos).ToCharArray(); + pos = digits.Length - 1; + for (int i = len - 1; i > 0; i--) + { + payload[i] |= (byte)(DigitFromChar(digits[pos--]) << 4); + payload[i - 1] |= (byte)DigitFromChar(digits[pos--]); + } + if (pos == 0) + { + payload[0] |= (byte)(DigitFromChar(digits[pos]) << 4); + } + + writer.Write(MsgPackExtDataTypes.Ext8); + writer.Write((byte)(len + 1)); + writer.Write(MP_DECIMAL); + writer.Write((byte)scale); + writer.Write(payload); + } + + private static (int, decimal) ExtractScaleFromDecimal(decimal val) + { + var bits = decimal.GetBits(val); + int scale = (bits[3] >> 16) & 0x7F; + decimal unscaledValue = new Decimal(bits[0], bits[1], bits[2], false, 0); + return (scale, unscaledValue); + } + + private static int UnsignedRightShift(int signed, int places) + { + unchecked + { + var unsigned = (uint)signed; + unsigned >>= places; + return (int)unsigned; + } + } + + private static int FirstNibbleFromByte(byte val) + { + return UnsignedRightShift(val & 0xF0, 4); + } + + private static int SecondNibbleFromByte(byte val) + { + return val & 0x0F; + } + + private static char CharFromDigit(int val, int pos) + { + var digit = (char)val; + if (digit > 9) + { + throw new IOException(String.Format("Invalid digit at position %d", pos)); + } + return digit; + } + + private static int DigitFromChar(char val) + { + return val - '0'; + } + + private static decimal CreateDecimalFromDigits(char[] digits, int scale, bool isNegative) + { + int pos = 0; + while (pos < digits.Length && digits[pos] == 0) + { + pos++; + } + + if (pos == digits.Length) + { + return 0; + } + + StringBuilder sb = new StringBuilder(); + for (; pos < digits.Length; pos++) + { + sb.Append((int)digits[pos]); + } + + if (scale >= sb.Length) + { + sb.Insert(0, String.Join("", Enumerable.Range(0, scale - sb.Length + 1).Select(_ => "0"))); + } + + if (scale > 0) + { + sb.Insert(sb.Length - scale, "."); + } + + if (isNegative) + { + sb.Insert(0, '-'); + } + return Decimal.Parse(sb.ToString(), CultureInfo.InvariantCulture); + } + } +} diff --git a/src/progaudi.tarantool/Converters/GuidConverter.cs b/src/progaudi.tarantool/Converters/GuidConverter.cs new file mode 100644 index 00000000..19b9dd0c --- /dev/null +++ b/src/progaudi.tarantool/Converters/GuidConverter.cs @@ -0,0 +1,67 @@ +using ProGaudi.MsgPack.Light; +using ProGaudi.Tarantool.Client.Model.Enums; +using ProGaudi.Tarantool.Client.Utils; +using System; +using System.Buffers.Binary; +using System.Linq; + +namespace ProGaudi.Tarantool.Client.Converters +{ + /// + /// Converter for Tarantool uuid values, implemented as MsgPack extension. + /// See https://www.tarantool.io/ru/doc/latest/dev_guide/internals/msgpack_extensions/#the-uuid-type + /// + internal class GuidConverter : IMsgPackConverter + { + private static readonly byte GuidDataType = MsgPackExtDataTypes.FixExt16; + private const byte MP_UUID = 0x02; + + public void Initialize(MsgPackContext context) + { + } + + public Guid Read(IMsgPackReader reader) + { + var dataType = reader.ReadByte(); + if (dataType != GuidDataType) + { + throw ExceptionHelper.UnexpectedDataType(dataType, GuidDataType); + } + + var mpHeader = reader.ReadByte(); + if (mpHeader != MP_UUID) + { + throw ExceptionHelper.UnexpectedMsgPackHeader(mpHeader, MP_UUID); + } + + int intToken = BinaryPrimitives.ReadInt32BigEndian(reader.ReadBytes(4)); + short shortToken1 = BinaryPrimitives.ReadInt16BigEndian(reader.ReadBytes(2)); + short shortToken2 = BinaryPrimitives.ReadInt16BigEndian(reader.ReadBytes(2)); + + return new Guid(intToken, shortToken1, shortToken2, reader.ReadBytes(8).ToArray()); + } + + public void Write(Guid value, IMsgPackWriter writer) + { + writer.Write(GuidDataType); + writer.Write(MP_UUID); + + var byteArray = value.ToByteArray(); + + // big-endian swap + SwapTwoBytes(byteArray, 0, 3); + SwapTwoBytes(byteArray, 1, 2); + SwapTwoBytes(byteArray, 4, 5); + SwapTwoBytes(byteArray, 6, 7); + + writer.Write(byteArray); + } + + private static void SwapTwoBytes(byte[] array, int index1, int index2) + { + var temp = array[index1]; + array[index1] = array[index2]; + array[index2] = temp; + } + } +} diff --git a/src/progaudi.tarantool/Model/Enums/MsgPackExtDataTypes.cs b/src/progaudi.tarantool/Model/Enums/MsgPackExtDataTypes.cs new file mode 100644 index 00000000..58f0fcfe --- /dev/null +++ b/src/progaudi.tarantool/Model/Enums/MsgPackExtDataTypes.cs @@ -0,0 +1,16 @@ +namespace ProGaudi.Tarantool.Client.Model.Enums +{ + // probably the best decision is to move these values into DataTypes enum in MsgPack.Light.dll + // for now I decided not to touch it + internal class MsgPackExtDataTypes + { + public const byte Ext8 = 0xc7; + public const byte Ext16 = 0xc8; + public const byte Ext32 = 0xc9; + public const byte FixExt1 = 0xd4; + public const byte FixExt2 = 0xd5; + public const byte FixExt4 = 0xd6; + public const byte FixExt8 = 0xd7; + public const byte FixExt16 = 0xd8; + } +} diff --git a/src/progaudi.tarantool/TarantoolConvertersRegistrator.cs b/src/progaudi.tarantool/TarantoolConvertersRegistrator.cs index d2710f97..f42570ff 100644 --- a/src/progaudi.tarantool/TarantoolConvertersRegistrator.cs +++ b/src/progaudi.tarantool/TarantoolConvertersRegistrator.cs @@ -4,6 +4,7 @@ using ProGaudi.Tarantool.Client.Model; using ProGaudi.Tarantool.Client.Model.Enums; using ProGaudi.Tarantool.Client.Model.Responses; +using System; namespace ProGaudi.Tarantool.Client { @@ -52,6 +53,11 @@ public static void Register(MsgPackContext context) context.RegisterConverter(new PingPacketConverter()); context.RegisterConverter(new ExecuteSqlRequestConverter()); + context.RegisterConverter(new DecimalConverter()); + context.RegisterConverter(new GuidConverter()); + context.RegisterConverter(new DateTimeConverter()); + context.RegisterConverter(new DateTimeConverter()); + context.RegisterGenericConverter(typeof(TupleConverter<>)); context.RegisterGenericConverter(typeof(TupleConverter<,>)); context.RegisterGenericConverter(typeof(TupleConverter<,,>)); diff --git a/src/progaudi.tarantool/Utils/ExceptionHelper.cs b/src/progaudi.tarantool/Utils/ExceptionHelper.cs index c2b4df3e..2766b9c0 100644 --- a/src/progaudi.tarantool/Utils/ExceptionHelper.cs +++ b/src/progaudi.tarantool/Utils/ExceptionHelper.cs @@ -37,6 +37,16 @@ public static Exception UnexpectedDataType(DataTypes expected, DataTypes actual) return new ArgumentException($"Unexpected data type: {expected} is expected, but got {actual}."); } + public static Exception UnexpectedDataType(byte actualCode, params byte[] expectedCodes) + { + return new ArgumentException($"Unexpected data type: {String.Join(", ", expectedCodes)} is expected, but got {actualCode}."); + } + + public static Exception UnexpectedMsgPackHeader(byte actual, byte expected) + { + return new ArgumentException($"Unexpected msgpack header: {expected} is expected, but got {actual}."); + } + public static Exception NotConnected() { return new InvalidOperationException("Can't perform operation. Looks like we are not connected to tarantool. Call 'Connect' method before calling any other operations."); diff --git a/src/progaudi.tarantool/progaudi.tarantool.csproj b/src/progaudi.tarantool/progaudi.tarantool.csproj index a568dcb2..e43e8d33 100644 --- a/src/progaudi.tarantool/progaudi.tarantool.csproj +++ b/src/progaudi.tarantool/progaudi.tarantool.csproj @@ -29,6 +29,7 @@ + diff --git a/src/tests/README.md b/src/tests/README.md new file mode 100644 index 00000000..78203d35 --- /dev/null +++ b/src/tests/README.md @@ -0,0 +1,10 @@ +To run integration tests you should have runned Tarantool instance. +You can run it locally via command + +docker-compose up -d + +It runs empty Tarantool instances of different versions, exposed on different ports. +Command examples to run integration tests suite for particular Tarantool instance + +dotnet test --filter "DisplayName~progaudi.tarantool.integration.tests" -e TARANTOOL_HOST_FOR_TESTS=127.0.0.1:3310 +dotnet test --filter "DisplayName~progaudi.tarantool.integration.tests" -e TARANTOOL_HOST_FOR_TESTS=127.0.0.1:3311 diff --git a/src/tests/docker-compose/docker-compose.yml b/src/tests/docker-compose/docker-compose.yml new file mode 100644 index 00000000..96bd4281 --- /dev/null +++ b/src/tests/docker-compose/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.2' + +services: + tarantool_2_10: + image: tarantool/tarantool:2.10 + command: tarantool /usr/local/share/tarantool/tarantool.docker.lua + volumes: + - ./tarantool:/usr/local/share/tarantool + ports: + - "3310:3301" + environment: + TARANTOOL_USER_NAME: admin + TARANTOOL_USER_PASSWORD: adminPassword + + tarantool_2_11: + image: tarantool/tarantool:2.11 + command: tarantool /usr/local/share/tarantool/tarantool.docker.lua + volumes: + - ./tarantool:/usr/local/share/tarantool + ports: + - "3311:3301" + environment: + TARANTOOL_USER_NAME: admin + TARANTOOL_USER_PASSWORD: adminPassword diff --git a/src/tests/docker-compose/tarantool/tarantool.docker.lua b/src/tests/docker-compose/tarantool/tarantool.docker.lua new file mode 100644 index 00000000..fd40a404 --- /dev/null +++ b/src/tests/docker-compose/tarantool/tarantool.docker.lua @@ -0,0 +1,7 @@ +box.cfg +{ + pid_file = nil, + background = false, + log_level = 5, + listen = 3301 +} \ No newline at end of file diff --git a/src/tests/progaudi.tarantool.integration.tests/DataTypes/DeserializationTests.cs b/src/tests/progaudi.tarantool.integration.tests/DataTypes/DeserializationTests.cs new file mode 100644 index 00000000..ea313de8 --- /dev/null +++ b/src/tests/progaudi.tarantool.integration.tests/DataTypes/DeserializationTests.cs @@ -0,0 +1,256 @@ +using Shouldly; +using System.Text; +using NUnit.Framework; +using System.Globalization; + +namespace progaudi.tarantool.integration.tests.DataTypes +{ + /// + /// Test suite, where we create and return some values in Tarantool via Lua and Eval command, + /// and check that this value deserialize into correspoding C# class/structure correctly + /// + [TestFixture] + public class DeserializationTests : TarantoolBaseTest + { + [Test] + public async Task DeserializeNull_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval("return box.NULL"); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBeNull(); + } + + [Test] + public async Task DeserializeNil_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval("return nil"); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBeNull(); + } + + [TestCase(true)] + [TestCase(false)] + public async Task DeserializeBoolean_ShouldBeCorrectAsync(bool val) + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval($"return {(val ? "true" : "false")}"); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBe(val); + } + + [TestCase(1)] + [TestCase(0)] + [TestCase(-1)] + public async Task DeserializeInt_ShouldBeCorrectAsync(int val) + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval($"return {val}"); + result.Data.ShouldBe(new[] { val }); + } + + [Test] + public async Task DeserializeFloat64_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval("return math.sqrt(2)"); + result.Data.Length.ShouldBe(1); + Math.Abs(result.Data[0] - Math.Sqrt(2)).ShouldBeLessThan(double.Epsilon); + } + + [Test] + public async Task DeserializeString_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var expectedStr = "Tarantool tickles, makes spiders giggle"; + var result = await tarantoolClient.Eval($"return '{expectedStr}'"); + result.Data.ShouldBe(new[] { expectedStr }); + } + + [TestCase("2.7182818284590452353602874714")] + [TestCase("2.718281828459045235360287471")] + [TestCase("2.71828182845904523536028747")] + [TestCase("2.7182818284590452353602874")] + [TestCase("2.718281828459045235360287")] + [TestCase("2.71828182845904523536028")] + [TestCase("2.7182818284590452353602")] + [TestCase("2.718281828459045235360")] + [TestCase("2.71828182845904523536")] + [TestCase("2.7182818284590452353")] + [TestCase("2.718281828459045235")] + [TestCase("2.71828182845904523")] + [TestCase("2.7182818284590452")] + [TestCase("2.718281828459045")] + [TestCase("2.71828182845904")] + [TestCase("2.7182818284590")] + [TestCase("2.718281828459")] + [TestCase("2.71828182845")] + [TestCase("2.7182818284")] + [TestCase("2.718281828")] + [TestCase("2.71828182")] + [TestCase("2.7182818")] + [TestCase("2.718281")] + [TestCase("2.71828")] + [TestCase("2.7182")] + [TestCase("2.718")] + [TestCase("2.71")] + [TestCase("2.7")] + [TestCase("2")] + [TestCase("0")] + [TestCase("100000")] + [TestCase("0.1")] + [TestCase("0.01")] + [TestCase("0.001")] + [TestCase("0.0001")] + + public async Task DeserializeExtAsDecimal_CorrectValue_ShouldBeDeserializedCorrectlyAsync(string str) + { + decimal n = Decimal.Parse(str, CultureInfo.InvariantCulture); + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval($"local decimal = require(\"decimal\"); return decimal.new(\"{str}\")"); + result.Data.ShouldBe(new[] { n }); + + var negativeResult = await tarantoolClient.Eval($"local decimal = require(\"decimal\"); return decimal.new(\"-{str}\")"); + negativeResult.Data.ShouldBe(new[] { -n }); + } + + [TestCase("0.12345678901234567890123456789")]// scale == 29 (max possible in .net is 28) + [TestCase("0.12345678901234567890123456789012345678")]// scale == 38 (max possible in .net is 28) + [TestCase("79228162514264337593543950336")]// max .net decimal + 1 + [TestCase("-79228162514264337593543950336")]// min .net decimal - 1 + public async Task DeserializeExtAsDecimal_IncorrectValue_OverflowExceptionThrown(string str) + { + using var tarantoolClient = await GetTarantoolClient(); + + Assert.ThrowsAsync(async () => + await tarantoolClient.Eval($"local decimal = require(\"decimal\"); return decimal.new(\"{str}\")")); + } + + [Test] + public async Task DeserializeBinary8_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval($"local msgpack = require(\"msgpack\"); return msgpack.object_from_raw('\\xc4\\x06foobar')"); + var expectedByteArray = Encoding.ASCII.GetBytes("foobar"); + result.Data.ShouldBe(new[] { expectedByteArray }); + } + + [Test] + public async Task DeserializeBinary16_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var stringLen256 = String.Join("", Enumerable.Range(0, 256).Select(_ => "x")); + var result = await tarantoolClient.Eval($"local msgpack = require(\"msgpack\"); return msgpack.object_from_raw('\\xc5\\x01\\x00{stringLen256}')"); + var expectedByteArray = Encoding.ASCII.GetBytes(stringLen256); + result.Data.ShouldBe(new[] { expectedByteArray }); + } + + [Test] + public async Task DeserializeExtAsGuid_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var guid = new Guid(); + var result = await tarantoolClient.Eval($"local uuid = require(\"uuid\"); return uuid.fromstr(\"{guid}\")"); + result.Data.ShouldBe(new[] { guid }); + } + + [Test] + public async Task DeserializeExtAsDatetime_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var dt = DateTime.UtcNow; + var query = $"local dt = require(\"datetime\"); " + + $"return dt.new {{ msec = {dt.Millisecond}, sec = {dt.Second}, min = {dt.Minute}, hour = {dt.Hour}, day = {dt.Day}, month = {dt.Month}, year = {dt.Year} }}"; + // TODO: test tzoffset, nsec/usec additionally + var result = await tarantoolClient.Eval(query); + result.Data.Length.ShouldBe(1); + var actualDt = result.Data[0]; + actualDt.Date.ShouldBe(dt.Date); + actualDt.Hour.ShouldBe(dt.Hour); + actualDt.Minute.ShouldBe(dt.Minute); + actualDt.Second.ShouldBe(dt.Second); + actualDt.Millisecond.ShouldBe(dt.Millisecond); + + var resultOffset = await tarantoolClient.Eval(query); + resultOffset.Data.Length.ShouldBe(1); + var actualOffset = resultOffset.Data[0]; + actualDt.Date.ShouldBe(dt.Date); + actualDt.Hour.ShouldBe(dt.Hour); + actualDt.Minute.ShouldBe(dt.Minute); + actualDt.Second.ShouldBe(dt.Second); + actualDt.Millisecond.ShouldBe(dt.Millisecond); + } + + [Test] + public async Task DeserializeIntArray_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var arr = new int[3] { 1, 2, 3}; + var result = await tarantoolClient.Eval($"return {{{String.Join(",", arr)}}}"); + result.Data.ShouldBe(new[] { arr }); + } + + [Test] + public async Task DeserializeBooleanArray_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var arr = new bool[3] { false, true, false }; + var result = await tarantoolClient.Eval($"return {{{String.Join(",", arr.Select(x => x.ToString().ToLower()))}}}"); + result.Data.ShouldBe(new[] { arr }); + } + + [Test] + public async Task DeserializeFloat64Array_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var arr = new double[3] { Math.Sqrt(2), Math.Sqrt(3), Math.Sqrt(5) }; + var result = await tarantoolClient.Eval("return { math.sqrt(2), math.sqrt(3), math.sqrt(5) }"); + result.Data.Length.ShouldBe(1); + result.Data[0].Length.ShouldBe(3); + Math.Abs(result.Data[0][0] - Math.Sqrt(2)).ShouldBeLessThan(double.Epsilon); + Math.Abs(result.Data[0][1] - Math.Sqrt(3)).ShouldBeLessThan(double.Epsilon); + Math.Abs(result.Data[0][2] - Math.Sqrt(5)).ShouldBeLessThan(double.Epsilon); + } + + [Test] + public async Task DeserializeStringArray_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var arr = new string[3] { "foo", "bar", "foobar" }; + var result = await tarantoolClient.Eval($"return {{{String.Join(",", arr.Select(x => "'" + x + "'"))}}}"); + result.Data.ShouldBe(new[] { arr }); + } + + [Test] + public async Task DeserializeMixedArrayToTuple_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval>("return { 1, true, 'foo'}"); + result.Data.Length.ShouldBe(1); + result.Data[0].Item1.ShouldBe(1); + result.Data[0].Item2.ShouldBe(true); + result.Data[0].Item3.ShouldBe("foo"); + } + + [Test] + public async Task DeserializeMapToDictionary_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var expectedDict = new Dictionary() + { + { "foo", 1 }, + { "bar", 2 }, + { "baz", 3 } + }; + var result = await tarantoolClient.Eval>($"a = {{}}; {String.Join("; ", expectedDict.Select(kvp => $"a['{kvp.Key}']={kvp.Value}"))} return a"); + result.Data.Length.ShouldBe(1); + var actualDict = result.Data[0]; + foreach (var key in expectedDict.Keys) // order doesn't preserve, so we need to check key by key + { + actualDict.ContainsKey(key).ShouldBeTrue(); + actualDict[key].ShouldBe(expectedDict[key]); + } + } + } +} \ No newline at end of file diff --git a/src/tests/progaudi.tarantool.integration.tests/DataTypes/SerializationTests.cs b/src/tests/progaudi.tarantool.integration.tests/DataTypes/SerializationTests.cs new file mode 100644 index 00000000..7b9e6242 --- /dev/null +++ b/src/tests/progaudi.tarantool.integration.tests/DataTypes/SerializationTests.cs @@ -0,0 +1,95 @@ +using ProGaudi.Tarantool.Client.Model; +using Shouldly; +using NUnit.Framework; +using System.Globalization; + +namespace progaudi.tarantool.integration.tests.DataTypes +{ + /// + /// Test suite, where we check correct data types serialization, when we pass values into Eval command, + /// and also check that this value deserialize into correspoding C# class/structure correctly + /// + [TestFixture] + public class SerializationTests : TarantoolBaseTest + { + [TestCase(true)] + [TestCase(false)] + public async Task SerializeBoolean_ShouldBeCorrectAsync(bool val) + { + await AssertThatYouGetWhatYouGive(val); + } + + [TestCase(0)] + [TestCase(-1)] + [TestCase(1000000)] + public async Task SerializeInt_ShouldBeCorrectAsync(int val) + { + await AssertThatYouGetWhatYouGive(val); + } + + [TestCase("test")] + public async Task SerializeString_ShouldBeCorrectAsync(string val) + { + await AssertThatYouGetWhatYouGive(val); + } + + [Test] + public async Task SerializeGuid_ShouldBeCorrectAsync() + { + await AssertThatYouGetWhatYouGive(Guid.NewGuid()); + } + + [TestCase("0")] + [TestCase("100500")] + [TestCase("0.1234567890123456789012345678")] + public async Task SerializeDecimal_ShouldBeCorrectAsync(string val) + { + decimal n = Decimal.Parse(val, CultureInfo.InvariantCulture); + await AssertThatYouGetWhatYouGive(n); + await AssertThatYouGetWhatYouGive(-n); + } + + [Test] + public async Task SerializeDatetime_ShouldBeCorrectAsync() + { + var dt = DateTime.UtcNow; + await AssertThatYouGetWhatYouGive(dt); + await AssertThatYouGetWhatYouGive((DateTimeOffset)dt); + } + + [Test] + public async Task SerializeTuple_ShouldBeCorrectAsync() + { + await AssertThatYouGetWhatYouGive(Tuple.Create(1, true, "test", 1m)); + } + + [Test] + public async Task SerializeDictionary_ShouldBeCorrectAsync() + { + using var tarantoolClient = await GetTarantoolClient(); + var expectedDict = new Dictionary() + { + { "foo", 1 }, + { "bar", 2 }, + { "baz", 3 } + }; + var result = await tarantoolClient.Eval>, Dictionary>($"return ...", TarantoolTuple.Create(expectedDict)); + + result.Data.Length.ShouldBe(1); + var actualDict = result.Data[0]; + foreach (var key in expectedDict.Keys) // order doesn't preserve, so we need to check key by key + { + actualDict.ContainsKey(key).ShouldBeTrue(); + actualDict[key].ShouldBe(expectedDict[key]); + } + } + + private static async Task AssertThatYouGetWhatYouGive(T val) + { + using var tarantoolClient = await GetTarantoolClient(); + var result = await tarantoolClient.Eval, T>($"return ...", TarantoolTuple.Create(val)); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBe(val); + } + } +} diff --git a/src/tests/progaudi.tarantool.integration.tests/TarantoolBaseTest.cs b/src/tests/progaudi.tarantool.integration.tests/TarantoolBaseTest.cs new file mode 100644 index 00000000..98c3cdcf --- /dev/null +++ b/src/tests/progaudi.tarantool.integration.tests/TarantoolBaseTest.cs @@ -0,0 +1,31 @@ +using ProGaudi.Tarantool.Client; + +namespace progaudi.tarantool.integration.tests +{ + public class TarantoolBaseTest + { + public static async Task GetTarantoolClient(string userName = null, string password = null) + { + userName ??= "admin"; + password ??= "adminPassword"; + return await Box.Connect(BuildConnectionString(userName, password)); + } + + public static string RandomSpaceName() + { + return "sp_" + Guid.NewGuid().ToString().Replace("-", ""); + } + + private static string BuildConnectionString(string userName, string password) + { + var userToken = (userName, password) + switch + { + (null, null) => "", + (_, null) => $"{userName}@", + _ => $"{userName}:{password}@", + }; + return $"{userToken}{Environment.GetEnvironmentVariable("TARANTOOL_HOST_FOR_TESTS") ?? "127.0.0.1:3310"}"; + } + } +} diff --git a/src/tests/progaudi.tarantool.integration.tests/TarantoolBox/InsertTests.cs b/src/tests/progaudi.tarantool.integration.tests/TarantoolBox/InsertTests.cs new file mode 100644 index 00000000..07fa4822 --- /dev/null +++ b/src/tests/progaudi.tarantool.integration.tests/TarantoolBox/InsertTests.cs @@ -0,0 +1,46 @@ +using NUnit.Framework; +using ProGaudi.Tarantool.Client.Model; +using Shouldly; + +namespace progaudi.tarantool.integration.tests.TarantoolBox +{ + [TestFixture] + internal class InsertTests : TarantoolBaseTest + { + static readonly string SpaceName = RandomSpaceName(); + const string IndexName = "primary_idx"; + + [SetUp] + public async Task Setup() + { + using var tarantoolClient = await GetTarantoolClient(); + await tarantoolClient.Eval($"local mysp = box.schema.space.create('{SpaceName}'); " + + $"mysp:format({{{{'id',type = 'scalar'}}, {{'a1',type = 'scalar', is_nullable = true}}, {{'a2',type = 'string', is_nullable = true}}, {{'a3',type = 'string', is_nullable = true}}}}); " + + $"mysp:create_index('{IndexName}', {{parts = {{'id'}}}}); " + + $"return 1"); + } + + [TearDown] + public async Task TearDown() + { + using var tarantoolClient = await GetTarantoolClient(); + await tarantoolClient.Eval($"box.space.{SpaceName}:drop(); return 1"); + } + + [TestCase(777)] + public async Task InsertIntToSpaceAndGetItBack_ShouldBeCorrectAsync(int val) + { + using var tarantoolClient = await GetTarantoolClient(); + var schema = tarantoolClient.GetSchema(); + var space = schema[SpaceName]; + space.Name.ShouldBe(SpaceName); + var id = 1; + + await space.Insert(TarantoolTuple.Create(id, val)); + var tuple = await space.Get, ValueTuple>(ValueTuple.Create(id)); + + tuple.Item1.ShouldBe(id); + tuple.Item2.ShouldBe(val); + } + } +} diff --git a/src/tests/progaudi.tarantool.integration.tests/TarantoolBox/SchemaTests.cs b/src/tests/progaudi.tarantool.integration.tests/TarantoolBox/SchemaTests.cs new file mode 100644 index 00000000..c3ff58d3 --- /dev/null +++ b/src/tests/progaudi.tarantool.integration.tests/TarantoolBox/SchemaTests.cs @@ -0,0 +1,73 @@ +using NUnit.Framework; +using Shouldly; + +namespace progaudi.tarantool.integration.tests.TarantoolBox +{ + [TestFixture] + public class SchemaTests : TarantoolBaseTest + { + const string SpaceName = "schema_test_space"; + const string IndexName = "primary_idx"; + + [SetUp] + public async Task Setup() + { + using var tarantoolClient = await GetTarantoolClient(); + await tarantoolClient.Eval($"local mysp = box.schema.space.create('{SpaceName}'); " + + $"mysp:format({{{{'a',type = 'number'}}}}); " + + $"mysp:create_index('{IndexName}', {{parts = {{1, 'number'}}}}); " + + $"return 1"); + } + + [TearDown] + public async Task TearDown() + { + using var tarantoolClient = await GetTarantoolClient(); + await tarantoolClient.Eval($"box.space.{SpaceName}:drop(); return 1"); + } + + [Test] + public async Task GetNotExistingSpace_ShouldThrowArgumentException() + { + using var tarantoolClient = await GetTarantoolClient(); + var schema = tarantoolClient.GetSchema(); + + Should.Throw(() => + { + var _ = schema["not_existing_space"]; + }); + } + + [Test] + public async Task GetExistingSpace_ShouldReturnCorrectly() + { + using var tarantoolClient = await GetTarantoolClient(); + var schema = tarantoolClient.GetSchema(); + var space = schema[SpaceName]; + space.Name.ShouldBe(SpaceName); + } + + [Test] + public async Task GetNotExistingIndex_ShouldThrowArgumentException() + { + using var tarantoolClient = await GetTarantoolClient(); + var schema = tarantoolClient.GetSchema(); + var space = schema[SpaceName]; + + Should.Throw(() => + { + var _ = schema["not_existing_index"]; + }); + } + + [Test] + public async Task GetExistingIndex_ShouldReturnCorrectly() + { + using var tarantoolClient = await GetTarantoolClient(); + var schema = tarantoolClient.GetSchema(); + var space = schema[SpaceName]; + var index = space[IndexName]; + index.Name.ShouldBe(IndexName); + } + } +} diff --git a/src/tests/progaudi.tarantool.integration.tests/progaudi.tarantool.integration.tests.csproj b/src/tests/progaudi.tarantool.integration.tests/progaudi.tarantool.integration.tests.csproj new file mode 100644 index 00000000..373d4922 --- /dev/null +++ b/src/tests/progaudi.tarantool.integration.tests/progaudi.tarantool.integration.tests.csproj @@ -0,0 +1,26 @@ + + + + net6.0 + enable + + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +