diff --git a/Directory.Packages.props b/Directory.Packages.props index cc3db9f5..f09a6f23 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,7 @@ + diff --git a/docker-compose.yml b/docker-compose.yml index 5aa18efb..3155c5ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,14 @@ services: TARANTOOL_USER_NAME: admin TARANTOOL_USER_PASSWORD: adminPassword TARANTOOL_SLAB_ALLOC_ARENA: 0.1 + + tarantool_2_11: + image: tarantool/tarantool:2.11 + ports: + - "3311:3301" + environment: + TARANTOOL_USER_NAME: admin + TARANTOOL_USER_PASSWORD: adminPassword redis: image: redis:3.0-alpine 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..541dc835 --- /dev/null +++ b/src/progaudi.tarantool/Converters/DecimalConverter.cs @@ -0,0 +1,240 @@ +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..5547d067 --- /dev/null +++ b/src/progaudi.tarantool/Converters/GuidConverter.cs @@ -0,0 +1,65 @@ +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) + { + (array[index1], array[index2]) = (array[index2], array[index1]); + } + } +} diff --git a/src/progaudi.tarantool/Model/Enums/MsgPackExtDataTypes.cs b/src/progaudi.tarantool/Model/Enums/MsgPackExtDataTypes.cs new file mode 100644 index 00000000..ff9d86aa --- /dev/null +++ b/src/progaudi.tarantool/Model/Enums/MsgPackExtDataTypes.cs @@ -0,0 +1,14 @@ +namespace ProGaudi.Tarantool.Client.Model.Enums +{ + 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 6f3a7633..d2fc56ec 100644 --- a/src/progaudi.tarantool/progaudi.tarantool.csproj +++ b/src/progaudi.tarantool/progaudi.tarantool.csproj @@ -31,6 +31,7 @@ + diff --git a/tests/progaudi.tarantool.tests/ConnectionStringFactory.cs b/tests/progaudi.tarantool.tests/ConnectionStringFactory.cs index ad0b6c25..8b99a6bf 100644 --- a/tests/progaudi.tarantool.tests/ConnectionStringFactory.cs +++ b/tests/progaudi.tarantool.tests/ConnectionStringFactory.cs @@ -23,6 +23,25 @@ public static async Task GetRedisConnectionString() return $"{await ResolveHostname(redisUrl)}:6379"; } + + public static string GetLatestTarantoolConnectionString(string userName = null, string password = null) + { + userName ??= "admin"; + password ??= "adminPassword"; + return BuildConnectionString(userName, password, 3311); + } + + private static string BuildConnectionString(string userName, string password, int port) + { + var userToken = (userName, password) + switch + { + (null, null) => "", + (_, null) => $"{userName}@", + _ => $"{userName}:{password}@", + }; + return $"{userToken}127.0.0.1:{port}"; + } private static async Task ResolveHostname(string host) { diff --git a/tests/progaudi.tarantool.tests/DataTypes/DeserializationTests.cs b/tests/progaudi.tarantool.tests/DataTypes/DeserializationTests.cs new file mode 100644 index 00000000..7966c117 --- /dev/null +++ b/tests/progaudi.tarantool.tests/DataTypes/DeserializationTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Xunit; +using Shouldly; +using System.Text; +using System.Linq; +using System.Threading.Tasks; + +namespace ProGaudi.Tarantool.Client.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 corresponding C# class/structure correctly + /// + public class DeserializationTests + { + [Fact] + public async Task DeserializeNull_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var result = await tarantoolClient.Eval("return box.NULL"); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBeNull(); + } + + [Fact] + public async Task DeserializeNil_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var result = await tarantoolClient.Eval("return nil"); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBeNull(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DeserializeBoolean_ShouldBeCorrectAsync(bool val) + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var result = await tarantoolClient.Eval($"return {(val ? "true" : "false")}"); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBe(val); + } + + [Theory] + [InlineData(1)] + [InlineData(0)] + [InlineData(-1)] + public async Task DeserializeInt_ShouldBeCorrectAsync(int val) + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var result = await tarantoolClient.Eval($"return {val}"); + result.Data.ShouldBe(new[] { val }); + } + + [Fact] + public async Task DeserializeFloat64_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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); + } + + [Fact] + public async Task DeserializeString_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var expectedStr = "Tarantool tickles, makes spiders giggle"; + var result = await tarantoolClient.Eval($"return '{expectedStr}'"); + result.Data.ShouldBe(new[] { expectedStr }); + } + + [Theory] + [InlineData("2.7182818284590452353602874714")] + [InlineData("2.718281828459045235360287471")] + [InlineData("2.71828182845904523536028747")] + [InlineData("2.7182818284590452353602874")] + [InlineData("2.718281828459045235360287")] + [InlineData("2.71828182845904523536028")] + [InlineData("2.7182818284590452353602")] + [InlineData("2.718281828459045235360")] + [InlineData("2.71828182845904523536")] + [InlineData("2.7182818284590452353")] + [InlineData("2.718281828459045235")] + [InlineData("2.71828182845904523")] + [InlineData("2.7182818284590452")] + [InlineData("2.718281828459045")] + [InlineData("2.71828182845904")] + [InlineData("2.7182818284590")] + [InlineData("2.718281828459")] + [InlineData("2.71828182845")] + [InlineData("2.7182818284")] + [InlineData("2.718281828")] + [InlineData("2.71828182")] + [InlineData("2.7182818")] + [InlineData("2.718281")] + [InlineData("2.71828")] + [InlineData("2.7182")] + [InlineData("2.718")] + [InlineData("2.71")] + [InlineData("2.7")] + [InlineData("2")] + [InlineData("0")] + [InlineData("100000")] + [InlineData("0.1")] + [InlineData("0.01")] + [InlineData("0.001")] + [InlineData("0.0001")] + public async Task DeserializeExtAsDecimal_CorrectValue_ShouldBeDeserializedCorrectlyAsync(string str) + { + var n = decimal.Parse(str, CultureInfo.InvariantCulture); + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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 }); + } + + [Theory] + [InlineData("0.12345678901234567890123456789")]// scale == 29 (max possible in .net is 28) + [InlineData("0.12345678901234567890123456789012345678")]// scale == 38 (max possible in .net is 28) + [InlineData("79228162514264337593543950336")]// max .net decimal + 1 + [InlineData("-79228162514264337593543950336")]// min .net decimal - 1 + public async Task DeserializeExtAsDecimal_IncorrectValue_OverflowExceptionThrown(string str) + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + + await Assert.ThrowsAsync(async () => + await tarantoolClient.Eval($"local decimal = require(\"decimal\"); return decimal.new(\"{str}\")")); + } + + [Fact] + public async Task DeserializeBinary8_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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 }); + } + + [Fact] + public async Task DeserializeBinary16_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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 }); + } + + [Fact] + public async Task DeserializeExtAsGuid_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var guid = new Guid(); + var result = await tarantoolClient.Eval($"local uuid = require(\"uuid\"); return uuid.fromstr(\"{guid}\")"); + result.Data.ShouldBe(new[] { guid }); + } + + [Fact] + public async Task DeserializeExtAsDatetime_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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]; + actualOffset.Date.ShouldBe(dt.Date); + actualOffset.Hour.ShouldBe(dt.Hour); + actualOffset.Minute.ShouldBe(dt.Minute); + actualOffset.Second.ShouldBe(dt.Second); + actualOffset.Millisecond.ShouldBe(dt.Millisecond); + } + + [Fact] + public async Task DeserializeIntArray_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var arr = new[] { 1, 2, 3}; + var result = await tarantoolClient.Eval($"return {{{string.Join(",", arr)}}}"); + result.Data.ShouldBe(new[] { arr }); + } + + [Fact] + public async Task DeserializeBooleanArray_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var arr = new[] { false, true, false }; + var result = await tarantoolClient.Eval($"return {{{string.Join(",", arr.Select(x => x.ToString().ToLower()))}}}"); + result.Data.ShouldBe(new[] { arr }); + } + + [Fact] + public async Task DeserializeFloat64Array_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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); + } + + [Fact] + public async Task DeserializeStringArray_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var arr = new[] { "foo", "bar", "foobar" }; + var result = await tarantoolClient.Eval($"return {{{string.Join(",", arr.Select(x => "'" + x + "'"))}}}"); + result.Data.ShouldBe(new[] { arr }); + } + + [Fact] + public async Task DeserializeMixedArrayToTuple_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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"); + } + + [Fact] + public async Task DeserializeMapToDictionary_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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/tests/progaudi.tarantool.tests/DataTypes/SerializationTests.cs b/tests/progaudi.tarantool.tests/DataTypes/SerializationTests.cs new file mode 100644 index 00000000..7b44c7df --- /dev/null +++ b/tests/progaudi.tarantool.tests/DataTypes/SerializationTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using ProGaudi.Tarantool.Client.Model; +using Shouldly; +using Xunit; + +namespace ProGaudi.Tarantool.Client.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 corresponding C# class/structure correctly + /// + public class SerializationTests + { + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task SerializeBoolean_ShouldBeCorrectAsync(bool val) + { + await AssertThatYouGetWhatYouGive(val); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(1000000)] + public async Task SerializeInt_ShouldBeCorrectAsync(int val) + { + await AssertThatYouGetWhatYouGive(val); + } + + [Theory] + [InlineData("test")] + public async Task SerializeString_ShouldBeCorrectAsync(string val) + { + await AssertThatYouGetWhatYouGive(val); + } + + [Fact] + public async Task SerializeGuid_ShouldBeCorrectAsync() + { + await AssertThatYouGetWhatYouGive(Guid.NewGuid()); + } + + [Theory] + [InlineData("0")] + [InlineData("100500")] + [InlineData("0.1234567890123456789012345678")] + public async Task SerializeDecimal_ShouldBeCorrectAsync(string val) + { + var n = decimal.Parse(val, CultureInfo.InvariantCulture); + await AssertThatYouGetWhatYouGive(n); + await AssertThatYouGetWhatYouGive(-n); + } + + [Fact] + public async Task SerializeDatetime_ShouldBeCorrectAsync() + { + var dt = DateTime.UtcNow; + await AssertThatYouGetWhatYouGive(dt); + await AssertThatYouGetWhatYouGive((DateTimeOffset)dt); + } + + [Fact] + public async Task SerializeTuple_ShouldBeCorrectAsync() + { + await AssertThatYouGetWhatYouGive(Tuple.Create(1, true, "test", 1m)); + } + + [Fact] + public async Task SerializeDictionary_ShouldBeCorrectAsync() + { + using var tarantoolClient = await Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + 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 Client.Box.Connect(ConnectionStringFactory.GetLatestTarantoolConnectionString()); + var result = await tarantoolClient.Eval, T>($"return ...", TarantoolTuple.Create(val)); + result.Data.Length.ShouldBe(1); + result.Data[0].ShouldBe(val); + } + } +}