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
+
+
+
+
+
+
+
+
+
+
+