diff --git a/.github/scripts/publish.sh b/.github/scripts/publish.sh index 54853d33..77a63590 100644 --- a/.github/scripts/publish.sh +++ b/.github/scripts/publish.sh @@ -8,7 +8,7 @@ then exit 1; fi; -LAST_TAG=$(git tag | tail -n 1); +LAST_TAG=$(git tag --sort=-creatordate | head -n 1); MAJOR=$(echo $LAST_TAG | sed -E 's/v([0-9]+)\..*/\1/'); MINOR=$(echo $LAST_TAG | sed -E 's/v[0-9]+\.([0-9]+)\..*/\1/'); PATCH=$(echo $LAST_TAG | sed -E 's/v[0-9]+\.[0-9]+\.([0-9]+)($|-rc[0-9]+)/\1/'); diff --git a/CHANGELOG.md b/CHANGELOG.md index aa12afc4..362cf1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +* Fix bug: GetValue(int ordinal) return DBNull.Value if fetched NULL value. +* Fix: NextResult() moves to the next result and skip the first ResultSet. +* Added specification DbDataReaderTests. +* If dataOffset is larger than the length of data, GetChars and GetBytes methods will return 0. +* If YdbDataReader is closed: `throw new InvalidOperationException("The reader is closed")`. * InvalidOperationException on ConnectionString property has not been initialized. * One YdbTransaction per YdbConnection. Otherwise, throw an exception: InvalidOperationException("A transaction is already in progress; nested/concurrent transactions aren't supported."). * ConnectionString returns an empty.String when it is not set. diff --git a/src/Ydb.Sdk/src/Ado/ThrowHelper.cs b/src/Ydb.Sdk/src/Ado/ThrowHelper.cs new file mode 100644 index 00000000..e00113cb --- /dev/null +++ b/src/Ydb.Sdk/src/Ado/ThrowHelper.cs @@ -0,0 +1,16 @@ +using Ydb.Sdk.Value; + +namespace Ydb.Sdk.Ado; + +internal static class ThrowHelper +{ + internal static T ThrowInvalidCast(YdbValue ydbValue) + { + throw new InvalidCastException($"Field type {ydbValue.TypeId} can't be cast to {typeof(T)} type."); + } + + internal static void ThrowIndexOutOfRangeException(int columnCount) + { + throw new IndexOutOfRangeException("Ordinal must be between 0 and " + (columnCount - 1)); + } +} diff --git a/src/Ydb.Sdk/src/Ado/YdbCommand.cs b/src/Ydb.Sdk/src/Ado/YdbCommand.cs index c208e57f..e212b66d 100644 --- a/src/Ydb.Sdk/src/Ado/YdbCommand.cs +++ b/src/Ydb.Sdk/src/Ado/YdbCommand.cs @@ -52,9 +52,7 @@ public override async Task ExecuteNonQueryAsync(CancellationToken cancellat { await using var dataReader = await ExecuteReaderAsync(CommandBehavior.Default, cancellationToken); - var data = await dataReader.ReadAsync(cancellationToken) - ? dataReader.IsDBNull(0) ? null : dataReader.GetValue(0) - : null; + var data = await dataReader.ReadAsync(cancellationToken) ? dataReader.GetValue(0) : null; while (await dataReader.NextResultAsync(cancellationToken)) { diff --git a/src/Ydb.Sdk/src/Ado/YdbConnection.cs b/src/Ydb.Sdk/src/Ado/YdbConnection.cs index d24a5e44..11118368 100644 --- a/src/Ydb.Sdk/src/Ado/YdbConnection.cs +++ b/src/Ydb.Sdk/src/Ado/YdbConnection.cs @@ -173,7 +173,7 @@ public override string ConnectionString internal YdbDataReader? LastReader { get; set; } internal string LastCommand { get; set; } = string.Empty; - internal bool IsBusy => LastReader is { IsClosed: false }; + internal bool IsBusy => LastReader is { IsOpen: true }; internal YdbTransaction? CurrentTransaction { get; private set; } public override string DataSource => string.Empty; // TODO diff --git a/src/Ydb.Sdk/src/Ado/YdbDataReader.cs b/src/Ydb.Sdk/src/Ado/YdbDataReader.cs index c470ca38..13afad84 100644 --- a/src/Ydb.Sdk/src/Ado/YdbDataReader.cs +++ b/src/Ydb.Sdk/src/Ado/YdbDataReader.cs @@ -17,38 +17,41 @@ public sealed class YdbDataReader : DbDataReader, IAsyncEnumerable ColumnNameToOrdinal { get; } - IReadOnlyList Columns { get; } - int FieldCount { get; } int RowsCount { get; } + + Value.ResultSet.Column GetColumn(int ordinal); } - private State ReaderState { get; set; } private IMetadata ReaderMetadata { get; set; } = null!; private Value.ResultSet CurrentResultSet => this switch { - { ReaderState: State.ReadResultState, _currentRowIndex: >= 0 } => _currentResultSet!, - { ReaderState: State.Closed } => throw new InvalidOperationException("The reader is closed"), + { ReaderState: State.ReadResultSet, _currentRowIndex: >= 0 } => _currentResultSet!, + { ReaderState: State.Close } => throw new InvalidOperationException("The reader is closed"), _ => throw new InvalidOperationException("No row is available") }; private Value.ResultSet.Row CurrentRow => CurrentResultSet.Rows[_currentRowIndex]; private int RowsCount => ReaderMetadata.RowsCount; + private enum State + { + NewResultSet, + ReadResultSet, + IsConsumed, + Close + } + + private State ReaderState { get; set; } + + internal bool IsOpen => ReaderState is State.NewResultSet or State.ReadResultSet; + private YdbDataReader( IAsyncEnumerator resultSetStream, Action onNotSuccessStatus, @@ -72,12 +75,12 @@ internal static async Task CreateYdbDataReader( private async Task Init() { - if (State.Closed == await NextExecPart()) + if (State.IsConsumed == await NextExecPart()) { throw new YdbException("YDB server closed the stream"); } - ReaderState = State.Initialized; + ReaderState = State.ReadResultSet; } public override bool GetBoolean(int ordinal) @@ -109,10 +112,16 @@ public override long GetBytes(int ordinal, long dataOffset, byte[]? buffer, int if (buffer == null) { - return 0; + return bytes.Length; } var copyCount = Math.Min(bytes.Length - dataOffset, length); + + if (copyCount < 0) + { + return 0; + } + Array.Copy(bytes, (int)dataOffset, buffer, bufferOffset, copyCount); return copyCount; @@ -131,10 +140,16 @@ public override long GetChars(int ordinal, long dataOffset, char[]? buffer, int if (buffer == null) { - return 0; + return chars.Length; } var copyCount = Math.Min(chars.Length - dataOffset, length); + + if (copyCount < 0) + { + return 0; + } + Array.Copy(chars, (int)dataOffset, buffer, bufferOffset, copyCount); return copyCount; @@ -147,7 +162,7 @@ private static void CheckOffsets(long dataOffset, T[]? buffer, int bufferOffs throw new IndexOutOfRangeException($"dataOffset must be between 0 and {int.MaxValue}"); } - if (buffer != null && (bufferOffset < 0 || bufferOffset >= buffer.Length)) + if (buffer != null && (bufferOffset < 0 || bufferOffset > buffer.Length)) { throw new IndexOutOfRangeException($"bufferOffset must be between 0 and {buffer.Length}"); } @@ -165,20 +180,19 @@ private static void CheckOffsets(long dataOffset, T[]? buffer, int bufferOffs public override string GetDataTypeName(int ordinal) { - return ReaderMetadata.Columns[ordinal].Type.TypeId.ToString(); + return ReaderMetadata.GetColumn(ordinal).Type.TypeId.ToString(); } public override DateTime GetDateTime(int ordinal) { var ydbValue = GetFieldYdbValue(ordinal); - // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault return ydbValue.TypeId switch { YdbTypeId.Timestamp => ydbValue.GetTimestamp(), YdbTypeId.Datetime => ydbValue.GetDatetime(), YdbTypeId.Date => ydbValue.GetDate(), - _ => throw new InvalidCastException($"Field type {ydbValue.TypeId} can't be cast to DateTime type") + _ => ThrowHelper.ThrowInvalidCast(ydbValue) }; } @@ -197,9 +211,24 @@ public override double GetDouble(int ordinal) return GetFieldYdbValue(ordinal).GetDouble(); } + public override T GetFieldValue(int ordinal) + { + if (typeof(T) == typeof(TextReader)) + { + return (T)(object)GetTextReader(ordinal); + } + + if (typeof(T) == typeof(Stream)) + { + return (T)(object)GetStream(ordinal); + } + + return base.GetFieldValue(ordinal); + } + public override System.Type GetFieldType(int ordinal) { - var type = ReaderMetadata.Columns[ordinal].Type; + var type = ReaderMetadata.GetColumn(ordinal).Type; if (type.TypeCase == Type.TypeOneofCase.OptionalType) { @@ -239,7 +268,7 @@ public override float GetFloat(int ordinal) public override Guid GetGuid(int ordinal) { - throw new YdbException("Ydb does not supported Guid"); + return GetFieldYdbValue(ordinal).GetUuid(); } public override short GetInt16(int ordinal) @@ -264,7 +293,16 @@ public uint GetUint32(int ordinal) public override long GetInt64(int ordinal) { - return GetFieldYdbValue(ordinal).GetInt64(); + var ydbValue = GetFieldYdbValue(ordinal); + + return ydbValue.TypeId switch + { + YdbTypeId.Int64 => ydbValue.GetInt64(), + YdbTypeId.Int32 => ydbValue.GetInt32(), + YdbTypeId.Int8 => ydbValue.GetInt8(), + YdbTypeId.Int16 => ydbValue.GetInt16(), + _ => ThrowHelper.ThrowInvalidCast(ydbValue) + }; } public ulong GetUint64(int ordinal) @@ -274,7 +312,7 @@ public ulong GetUint64(int ordinal) public override string GetName(int ordinal) { - return ReaderMetadata.Columns[ordinal].Name; + return ReaderMetadata.GetColumn(ordinal).Name; } public override int GetOrdinal(string name) @@ -292,6 +330,11 @@ public override string GetString(int ordinal) return GetFieldYdbValue(ordinal).GetUtf8(); } + public override TextReader GetTextReader(int ordinal) + { + return new StringReader(GetString(ordinal)); + } + public string GetJson(int ordinal) { return GetFieldYdbValue(ordinal).GetJson(); @@ -304,10 +347,14 @@ public string GetJsonDocument(int ordinal) public override object GetValue(int ordinal) { - EnsureOrdinal(ordinal); - var ydbValue = CurrentRow[ordinal]; + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (ydbValue.TypeId == YdbTypeId.Null) + { + return DBNull.Value; + } + // ReSharper disable once InvertIf if (ydbValue.TypeId == YdbTypeId.OptionalType) { @@ -347,6 +394,12 @@ public override object GetValue(int ordinal) public override int GetValues(object[] values) { + ArgumentNullException.ThrowIfNull(values); + if (FieldCount == 0) + { + throw new InvalidOperationException(" No resultset is currently being traversed"); + } + var count = Math.Min(FieldCount, values.Length); for (var i = 0; i < count; i++) values[i] = GetValue(i); @@ -355,16 +408,16 @@ public override int GetValues(object[] values) public override bool IsDBNull(int ordinal) { - return CurrentRow[ordinal].TypeId == YdbTypeId.Unknown || + return CurrentRow[ordinal].TypeId == YdbTypeId.Null || (CurrentRow[ordinal].TypeId == YdbTypeId.OptionalType && CurrentRow[ordinal].GetOptional() == null); } public override int FieldCount => ReaderMetadata.FieldCount; public override object this[int ordinal] => GetValue(ordinal); public override object this[string name] => GetValue(GetOrdinal(name)); - public override int RecordsAffected => 0; + public override int RecordsAffected => -1; public override bool HasRows => ReaderMetadata.RowsCount > 0; - public override bool IsClosed => ReaderState == State.Closed; + public override bool IsClosed => ReaderState == State.Close; public override bool NextResult() { @@ -378,30 +431,33 @@ public override bool Read() public override async Task NextResultAsync(CancellationToken cancellationToken) { + ThrowIfClosed(); + ReaderState = ReaderState switch { - State.Closed => State.Closed, - State.Initialized or State.NewResultState => State.ReadResultState, - State.ReadResultState => await new Func>(async () => + State.IsConsumed => State.IsConsumed, + State.NewResultSet => State.ReadResultSet, + State.ReadResultSet => await new Func>(async () => { State state; - while ((state = await NextExecPart()) == State.ReadResultState) + while ((state = await NextExecPart()) == State.ReadResultSet) { } - return state == State.NewResultState ? State.ReadResultState : state; + return state == State.NewResultSet ? State.ReadResultSet : state; })(), + State.Close => State.Close, // not invoke _ => throw new ArgumentOutOfRangeException(ReaderState.ToString()) }; - return ReaderState != State.Closed; + return ReaderState != State.IsConsumed; } public override async Task ReadAsync(CancellationToken cancellationToken) { - var nextResult = ReaderState != State.Initialized || await NextResultAsync(cancellationToken); + ThrowIfClosed(); - if (!nextResult || ReaderState == State.Closed) + if (ReaderState == State.IsConsumed) { return false; } @@ -411,7 +467,7 @@ public override async Task ReadAsync(CancellationToken cancellationToken) return true; } - while ((ReaderState = await NextExecPart()) == State.ReadResultState) // reset _currentRowIndex + while ((ReaderState = await NextExecPart()) == State.ReadResultSet) // reset _currentRowIndex { if (++_currentRowIndex < RowsCount) { @@ -422,6 +478,14 @@ public override async Task ReadAsync(CancellationToken cancellationToken) return false; } + private void ThrowIfClosed() + { + if (ReaderState == State.Close) + { + throw new InvalidOperationException("The reader is closed"); + } + } + public override int Depth => 0; public override IEnumerator GetEnumerator() @@ -434,14 +498,21 @@ public override IEnumerator GetEnumerator() public override async Task CloseAsync() { - if (ReaderState == State.Closed) + if (ReaderState == State.Close) { return; } - ReaderState = State.Closed; - _onNotSuccessStatus(new Status(StatusCode.SessionBusy)); + ReaderMetadata = CloseMetadata.Instance; + var isConsumed = ReaderState == State.IsConsumed; + ReaderState = State.Close; + + if (isConsumed) + { + return; + } + _onNotSuccessStatus(new Status(StatusCode.SessionBusy)); await _stream.DisposeAsync(); if (_ydbTransaction != null) @@ -459,8 +530,6 @@ public override void Close() private YdbValue GetFieldYdbValue(int ordinal) { - EnsureOrdinal(ordinal); - var ydbValue = CurrentRow[ordinal]; return ydbValue.TypeId == YdbTypeId.OptionalType @@ -468,14 +537,6 @@ private YdbValue GetFieldYdbValue(int ordinal) : ydbValue; } - private void EnsureOrdinal(int ordinal) - { - if (ordinal >= FieldCount || 0 > ordinal) // get FieldCount throw InvalidOperationException if State == Closed - { - throw new IndexOutOfRangeException("Ordinal must be between 0 and " + (FieldCount - 1)); - } - } - private async Task NextExecPart() { try @@ -484,7 +545,7 @@ private async Task NextExecPart() if (!await _stream.MoveNextAsync()) { - return State.Closed; + return State.IsConsumed; } var part = _stream.Current; @@ -517,12 +578,12 @@ private async Task NextExecPart() if (part.ResultSetIndex <= _resultSetIndex) { - return State.ReadResultState; + return State.ReadResultSet; } _resultSetIndex = part.ResultSetIndex; - return State.NewResultState; + return State.NewResultSet; } catch (Driver.TransportException e) { @@ -536,7 +597,7 @@ private async Task NextExecPart() private void OnFailReadStream() { - ReaderState = State.Closed; + ReaderState = State.IsConsumed; if (_ydbTransaction != null) { _ydbTransaction.Failed = true; @@ -567,17 +628,40 @@ private EmptyMetadata() public IReadOnlyDictionary ColumnNameToOrdinal => throw new InvalidOperationException("No resultset is currently being traversed"); - public IReadOnlyList Columns => + public int FieldCount => 0; + public int RowsCount => 0; + + public Value.ResultSet.Column GetColumn(int ordinal) + { throw new InvalidOperationException("No resultset is currently being traversed"); + } + } - public int FieldCount => 0; + private class CloseMetadata : IMetadata + { + public static readonly IMetadata Instance = new CloseMetadata(); + + private CloseMetadata() + { + } + + public IReadOnlyDictionary ColumnNameToOrdinal => + throw new InvalidOperationException("The reader is closed"); + + public int FieldCount => throw new InvalidOperationException("The reader is closed"); public int RowsCount => 0; + + public Value.ResultSet.Column GetColumn(int ordinal) + { + throw new InvalidOperationException("The reader is closed"); + } } private class Metadata : IMetadata { + private IReadOnlyList Columns { get; } + public IReadOnlyDictionary ColumnNameToOrdinal { get; } - public IReadOnlyList Columns { get; } public int FieldCount { get; } public int RowsCount { get; } @@ -588,5 +672,15 @@ public Metadata(Value.ResultSet resultSet) RowsCount = resultSet.Rows.Count; FieldCount = resultSet.Columns.Count; } + + public Value.ResultSet.Column GetColumn(int ordinal) + { + if (ordinal < 0 || ordinal >= FieldCount) + { + ThrowHelper.ThrowIndexOutOfRangeException(FieldCount); + } + + return Columns[ordinal]; + } } } diff --git a/src/Ydb.Sdk/src/Value/ResultSet.cs b/src/Ydb.Sdk/src/Value/ResultSet.cs index bfaef0f4..226d52ba 100644 --- a/src/Ydb.Sdk/src/Value/ResultSet.cs +++ b/src/Ydb.Sdk/src/Value/ResultSet.cs @@ -1,5 +1,6 @@ using System.Collections; using Google.Protobuf.Collections; +using Ydb.Sdk.Ado; namespace Ydb.Sdk.Value; @@ -20,7 +21,7 @@ public class ResultSet internal ResultSet(Ydb.ResultSet resultSetProto) { - Columns = resultSetProto.Columns.Select(c => new Column(c.Type, c.Name)).ToList(); + Columns = resultSetProto.Columns.Select(c => new Column(c.Type, c.Name)).ToArray(); ColumnNameToOrdinal = Columns .Select((c, idx) => (c.Name, Index: idx)) @@ -130,10 +131,21 @@ internal Row(Ydb.Value row, IReadOnlyList columns, IReadOnlyDictionary new(_columns[columnIndex].Type, _row.Items[columnIndex]); + public YdbValue this[int columnIndex] + { + get + { + if (columnIndex < 0 || columnIndex >= ColumnCount) + { + ThrowHelper.ThrowIndexOutOfRangeException(ColumnCount); + } + + return new YdbValue(_columns[columnIndex].Type, _row.Items[columnIndex]); + } + } public YdbValue this[string columnName] => this[_columnsMap[columnName]]; - internal int ColumnCount => _columns.Count; + private int ColumnCount => _columns.Count; } } diff --git a/src/Ydb.Sdk/src/Value/YdbValue.cs b/src/Ydb.Sdk/src/Value/YdbValue.cs index aa6d26a2..de63ed36 100644 --- a/src/Ydb.Sdk/src/Value/YdbValue.cs +++ b/src/Ydb.Sdk/src/Value/YdbValue.cs @@ -39,7 +39,8 @@ public enum YdbTypeId : uint DictType = YdbTypeIdRanges.ComplexTypesFirst + 105, VariantType = YdbTypeIdRanges.ComplexTypesFirst + 106, - VoidType = YdbTypeIdRanges.ComplexTypesFirst + 201 + VoidType = YdbTypeIdRanges.ComplexTypesFirst + 201, + Null = YdbTypeIdRanges.ComplexTypesFirst + 202 } internal static class YdbTypeIdRanges @@ -107,6 +108,7 @@ internal static YdbTypeId GetYdbTypeId(Type protoType) Type.TypeOneofCase.DictType => YdbTypeId.DictType, Type.TypeOneofCase.VariantType => YdbTypeId.VariantType, Type.TypeOneofCase.VoidType => YdbTypeId.VoidType, + Type.TypeOneofCase.NullType => YdbTypeId.Null, _ => YdbTypeId.Unknown }; } diff --git a/src/Ydb.Sdk/tests/Ado/Specification/YdbDataReaderTests.cs b/src/Ydb.Sdk/tests/Ado/Specification/YdbDataReaderTests.cs new file mode 100644 index 00000000..6de9e1d9 --- /dev/null +++ b/src/Ydb.Sdk/tests/Ado/Specification/YdbDataReaderTests.cs @@ -0,0 +1,400 @@ +using System.Data.Common; +using AdoNet.Specification.Tests; +using Xunit; +using Ydb.Sdk.Ado; + +namespace Ydb.Sdk.Tests.Ado.Specification; + +public class YdbDataReaderTests : DataReaderTestBase +{ + public YdbDataReaderTests(YdbSelectValueFixture fixture) : base(fixture) + { + } + + // UTF8 constant must have the suffix ''u!!! :((( + public override void Dispose_command_before_reader() + { + using var connection = CreateOpenConnection(); + DbDataReader reader; + using (var command = connection.CreateCommand()) + { + command.CommandText = "SELECT 'test'u;"; + reader = command.ExecuteReader(); + } + + Assert.True(reader.Read()); + Assert.Equal("test", reader.GetString(0)); + Assert.False(reader.Read()); + } + + public override void GetChars_reads_nothing_at_end_of_buffer() + { + TestGetChars(reader => { Assert.Equal(0, reader.GetChars(0, 0, new char[4], 4, 0)); }); + } + + public override void GetChars_reads_nothing_when_dataOffset_is_too_large() + { + TestGetChars(reader => { Assert.Equal(0, reader.GetChars(0, 6, new char[4], 0, 4)); }); + } + + public override void GetChars_reads_part_of_string() + { + TestGetChars(reader => + { + var buffer = new char[5]; + Assert.Equal(2, reader.GetChars(0, 1, buffer, 2, 2)); + Assert.Equal(new[] { '\0', '\0', 'b', '¢', '\0' }, buffer); + }); + } + + public override void GetChars_returns_length_when_buffer_is_null() + { + TestGetChars(reader => { Assert.Equal(4, reader.GetChars(0, 0, null, 0, 0)); }); + } + + public override void GetChars_returns_length_when_buffer_is_null_and_dataOffset_is_specified() + { + TestGetChars(reader => { Assert.Equal(4, reader.GetChars(0, 1, null, 0, 0)); }); + } + + public override void GetChars_throws_when_bufferOffset_is_negative() + { + TestGetChars(reader => + { + AssertThrowsAny(() => + reader.GetChars(0, 0, new char[4], -1, 4)); + }); + } + + public override void GetChars_throws_when_bufferOffset_is_too_large() + { + TestGetChars(reader => + { + AssertThrowsAny(() => + reader.GetChars(0, 0, new char[4], 5, 0)); + }); + } + + public override void GetChars_throws_when_bufferOffset_plus_length_is_too_long() + { + TestGetChars(reader => + { + AssertThrowsAny(() => + reader.GetChars(0, 0, new char[4], 2, 3)); + }); + } + + public override void GetChars_throws_when_dataOffset_is_negative() + { + TestGetChars(reader => + { + AssertThrowsAny(() => + reader.GetChars(0, -1, new char[4], 0, 4)); + }); + } + + public override void GetChars_works() + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 'test'u;"; + using var reader = command.ExecuteReader(); + var hasData = reader.Read(); + Assert.True(hasData); + + var buffer = new char[4]; + Assert.Equal(4, reader.GetChars(0, 0, buffer, 0, buffer.Length)); + Assert.Equal(new[] { 't', 'e', 's', 't' }, buffer); + } + + public override void GetChars_works_when_buffer_is_large() + { + TestGetChars(reader => + { + var buffer = new char[6]; + Assert.Equal(4, reader.GetChars(0, 0, buffer, 0, 6)); + Assert.Equal(new[] { 'a', 'b', '¢', 'd', '\0', '\0' }, buffer); + }); + } + + public override void GetFieldType_works() + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 'test'u;"; + using var reader = command.ExecuteReader(); + Assert.Equal(typeof(string), reader.GetFieldType(0)); + } + + public override void Item_by_ordinal_works() + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 'test'u;"; + using var reader = command.ExecuteReader(); + var hasData = reader.Read(); + Assert.True(hasData); + + Assert.Equal("test", reader[0]); + } + + public override void Item_by_name_works() + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 'test'u AS Id;"; + using var reader = command.ExecuteReader(); + var hasData = reader.Read(); + Assert.True(hasData); + + Assert.Equal("test", reader["Id"]); + } + + + public override void GetValue_to_string_works_utf8_two_bytes() + { + GetX_works("SELECT 'Ä'u;", r => r.GetValue(0) as string, "Ä"); + } + + + public override void GetValue_to_string_works_utf8_three_bytes() + { + GetX_works("SELECT 'Ḁ'u;", r => r.GetValue(0) as string, "Ḁ"); + } + + + public override void GetValue_to_string_works_utf8_four_bytes() + { + GetX_works("SELECT '😀'u;", r => r.GetValue(0) as string, "😀"); + } + + public override void GetValues_works() + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 'a'u, NULL;"; + using var reader = command.ExecuteReader(); + var hasData = reader.Read(); + Assert.True(hasData); + + // Array may be wider than row + var values = new object[3]; + var result = reader.GetValues(values); + + Assert.Equal(2, result); + Assert.Equal("a", values[0]); + Assert.Same(DBNull.Value, values[1]); + } + + public override void GetString_works() + { + GetX_works("SELECT 'test'u;", r => r.GetString(0), "test"); + } + + public override void GetString_works_utf8_two_bytes() + { + GetX_works("SELECT 'Ä'u;", r => r.GetString(0), "Ä"); + } + + public override void GetString_works_utf8_three_bytes() + { + GetX_works("SELECT 'Ḁ'u;", r => r.GetString(0), "Ḁ"); + } + + public override void GetString_works_utf8_four_bytes() + { + GetX_works("SELECT '😀'u;", r => r.GetString(0), "😀"); + } + + // UNION does not guarantee order + public override void Read_works() + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1 as id UNION SELECT 2 as id ORDER BY id;"; + using var reader = command.ExecuteReader(); + var hasData = reader.Read(); + Assert.True(hasData); + Assert.Equal(1L, reader.GetInt64(0)); + + hasData = reader.Read(); + Assert.True(hasData); + Assert.Equal(2L, reader.GetInt64(0)); + + hasData = reader.Read(); + Assert.False(hasData); + } + + public override void GetFieldValue_works_utf8_two_bytes() + { + GetX_works("SELECT 'Ä'u;", r => r.GetFieldValue(0), "Ä"); + } + + public override void GetFieldValue_works_utf8_three_bytes() + { + GetX_works("SELECT 'Ḁ'u;", r => r.GetFieldValue(0), "Ḁ"); + } + + public override void GetFieldValue_works_utf8_four_bytes() + { + GetX_works("SELECT '😀'u;", r => r.GetFieldValue(0), "😀"); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Don't supported CommandBehavior")] +#pragma warning restore xUnit1004 + public override void SingleResult_returns_one_result_set() + { + base.SingleResult_returns_one_result_set(); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Don't supported CommandBehavior")] +#pragma warning restore xUnit1004 + public override void SingleRow_returns_one_result_set() + { + base.SingleRow_returns_one_result_set(); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Don't supported CommandBehavior")] +#pragma warning restore xUnit1004 + public override void SingleRow_returns_one_row() + { + base.SingleRow_returns_one_row(); + } + + +#pragma warning disable xUnit1004 + [Fact(Skip = "Mutually exclusive test with GetTextReader_throws_for_null_String")] +#pragma warning restore xUnit1004 + public override void GetTextReader_returns_empty_for_null_String() + { + base.GetTextReader_returns_empty_for_null_String(); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Not supported GetSchemaTable")] +#pragma warning restore xUnit1004 + public override void GetColumnSchema_is_empty_after_Delete() + { + base.GetColumnSchema_is_empty_after_Delete(); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Not supported GetSchemaTable")] +#pragma warning restore xUnit1004 + public override void GetColumnSchema_ColumnName() + { + base.GetColumnSchema_ColumnName(); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Not supported GetSchemaTable")] +#pragma warning restore xUnit1004 + public override void GetColumnSchema_DataType() + { + base.GetColumnSchema_DataType(); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Not supported GetSchemaTable")] +#pragma warning restore xUnit1004 + public override void GetColumnSchema_DataTypeName() + { + base.GetColumnSchema_DataTypeName(); + } + +#pragma warning disable xUnit1004 + [Fact(Skip = "Not supported GetSchemaTable")] +#pragma warning restore xUnit1004 + public override void GetSchemaTable_is_null_after_Delete() + { + base.GetSchemaTable_is_null_after_Delete(); + } + + protected override async Task OnInitializeAsync() + { + await using var connection = CreateConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + + var ydbCommand = new YdbCommand + { + Connection = connection, + CommandText = $@" + CREATE TABLE `select_value_{Utils.Net}` + ( + `Id` Int32 NOT NULL, + `Binary` Bytes, + `Boolean` Bool, + `Byte` Uint8, + `SByte` Int8, + `Int16` Int16, + `UInt16` UInt16, + `Int32` Int32, + `UInt32` UInt32, + `Int64` Int64, + `UInt64` UInt64, + `Single` Float, + `Double` Double, + `Decimal` Decimal(22, 9), + `String` Text, + `Guid` Uuid, + `Date` Date, + `DateTime` Datetime, + `DateTime2` Timestamp, + + PRIMARY KEY (`Id`) + ); + " + }; + + await ydbCommand.ExecuteNonQueryAsync(); + ydbCommand.CommandText = $@" + INSERT INTO `select_value_{Utils.Net}`(`Id`, `Binary`, `Boolean`, `Byte`, `SByte`, `Int16`, `UInt16`,`Int32`, + `UInt32`, `Int64`, `UInt64`, `Single`, `Double`, `Decimal`, `String`, `Guid`, `Date`, `DateTime`, `DateTime2`) VALUES + (0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL), + (1, '', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, '', NULL, NULL, NULL, NULL), + (2, String::HexDecode('00'), FALSE, 0, 0, 0, 0, 0, 0, 0, 0, CAST(0 AS Float), 0, CAST(0 AS Decimal(22, 9)), '0', Uuid('00000000-0000-0000-0000-000000000000'), NULL, NULL, CurrentUtcTimestamp()), + (3, String::HexDecode('11'), TRUE, 1, 1, 1, 1, 1, 1, 1, 1, CAST(1 AS Float), 1, CAST(1 AS Decimal(22, 9)), '1', Uuid('11111111-1111-1111-1111-111111111111'), Date('2105-01-01'), Datetime('2105-01-01T11:11:11Z'), Timestamp('2105-01-01T11:11:11.111Z')), + (4, NULL, FALSE, 0, -128, -32768, 0, -2147483648, 0, -9223372036854775808, 0, CAST(1.18e-38 AS Float), 2.23e-308, CAST('0.000000000000001' AS Decimal(22, 9)), NULL, Uuid('33221100-5544-7766-9988-aabbccddeeff'), Date('2000-01-01'), Datetime('2000-01-01T00:00:00Z'), Timestamp('2000-01-01T00:00:00.000Z')), + (5, NULL, TRUE, 255, 127, 32767, 65535, 2147483647, 4294967295, 9223372036854775807, 18446744073709551615, CAST(3.40e38 AS Float), 1.79e308, CAST('99999999999999999999.999999999' AS Decimal(22, 9)), NULL, Uuid('ccddeeff-aabb-8899-7766-554433221100'), Date('1999-12-31'), Datetime('1999-12-31T23:59:59Z'), Timestamp('1999-12-31T23:59:59.999Z')); + "; + await ydbCommand.ExecuteNonQueryAsync(); + } + + protected override async Task OnDisposeAsync() + { + await using var connection = CreateConnection(); + connection.ConnectionString = ConnectionString; + await connection.OpenAsync(); + + await new YdbCommand { Connection = connection, CommandText = $"DROP TABLE `select_value_{Utils.Net}`" } + .ExecuteNonQueryAsync(); + } + + // Copy and paste private method from base class + private void TestGetChars(Action action) + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + // NB: Intentionally using a multi-byte UTF-8 character + command.CommandText = "SELECT 'ab¢d'u;"; + using var reader = command.ExecuteReader(); + reader.Read(); + action(reader); + } + + private void GetX_works(string sql, Func action, T expected) + { + using var connection = CreateOpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = sql; + using var reader = command.ExecuteReader(); + var hasData = reader.Read(); + + Assert.True(hasData); + Assert.Equal(expected, action(reader)); + } +} diff --git a/src/Ydb.Sdk/tests/Ado/Specification/YdbSelectValueFixture.cs b/src/Ydb.Sdk/tests/Ado/Specification/YdbSelectValueFixture.cs new file mode 100644 index 00000000..4a36856f --- /dev/null +++ b/src/Ydb.Sdk/tests/Ado/Specification/YdbSelectValueFixture.cs @@ -0,0 +1,47 @@ +using System.Collections.ObjectModel; +using System.Data; +using AdoNet.Specification.Tests; + +namespace Ydb.Sdk.Tests.Ado.Specification; + +public class YdbSelectValueFixture : YdbFactoryFixture, ISelectValueFixture, IDeleteFixture +{ + public string CreateSelectSql(DbType dbType, ValueKind kind) + { + return $"SELECT `{dbType}` FROM `select_value_{Utils.Net}` WHERE Id = {(int)kind}"; + } + + public string CreateSelectSql(byte[] value) + { + return $"SELECT String::HexDecode('{BitConverter.ToString(value).Replace("-", string.Empty)}');"; + } + + public IReadOnlyCollection SupportedDbTypes => new ReadOnlyCollection(new[] + { + DbType.Binary, + DbType.Boolean, + DbType.Byte, + DbType.Date, + DbType.DateTime, + DbType.Decimal, + DbType.Double, + DbType.Guid, + DbType.Int16, + DbType.Int32, + DbType.Int64, + DbType.SByte, + DbType.Single, + DbType.String, + DbType.DateTime2, + DbType.UInt16, + DbType.UInt32, + DbType.UInt64 + }); + + + public string SelectNoRows => $"SELECT 1 FROM `select_value_{Utils.Net}` WHERE 0 = 1;"; + + public System.Type NullValueExceptionType => typeof(InvalidCastException); + + public string DeleteNoRows => $"DELETE FROM `select_value_{Utils.Net}` WHERE 0 = 1;"; +} diff --git a/src/Ydb.Sdk/tests/Ado/YdbCommandTests.cs b/src/Ydb.Sdk/tests/Ado/YdbCommandTests.cs index 06e2d1b0..061cdb60 100644 --- a/src/Ydb.Sdk/tests/Ado/YdbCommandTests.cs +++ b/src/Ydb.Sdk/tests/Ado/YdbCommandTests.cs @@ -32,7 +32,7 @@ public async Task ExecuteScalarAsync_WhenSetYdbParameter_ReturnThisValue(YdbP dbCommand.Parameters.Add(dbParameter); - Assert.Equal(data.Expected, await dbCommand.ExecuteScalarAsync()); + Assert.Equal(data.Expected == null ? DBNull.Value : data.Expected, await dbCommand.ExecuteScalarAsync()); var ydbDataReader = await dbCommand.ExecuteReaderAsync(); Assert.Equal(1, ydbDataReader.FieldCount); Assert.Equal("var", ydbDataReader.GetName(0)); @@ -64,7 +64,7 @@ public async Task ExecuteScalarAsync_WhenSetYdbParameterThenPrepare_ReturnThisVa }; dbCommand.Parameters.Add(dbParameter); - Assert.Equal(data.Expected, await dbCommand.ExecuteScalarAsync()); + Assert.Equal(data.Expected == null ? DBNull.Value : data.Expected, await dbCommand.ExecuteScalarAsync()); } [Theory] @@ -133,11 +133,10 @@ public async Task CloseAsync_WhenDoubleInvoke_Idempotent() ydbCommand.CommandText = "SELECT 1;"; var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); - Assert.True(await ydbDataReader.NextResultAsync()); Assert.False(await ydbDataReader.NextResultAsync()); await ydbDataReader.CloseAsync(); await ydbDataReader.CloseAsync(); - Assert.False(await ydbDataReader.NextResultAsync()); + await Assert.ThrowsAsync(() => ydbDataReader.NextResultAsync()); } [Fact] @@ -231,7 +230,7 @@ public async Task ExecuteDbDataReader_WhenSelectManyResultSet_ReturnYdbDataReade Assert.Equal(timestamp, ydbDataReader.GetDateTime(1)); Assert.False(await ydbDataReader.ReadAsync()); Assert.False(await ydbDataReader.NextResultAsync()); - Assert.True(ydbDataReader.IsClosed); + Assert.False(ydbDataReader.IsClosed); // For IsClosed, invoke Close on YdbConnection or YdbDataReader. } [Fact] @@ -254,7 +253,6 @@ public void ExecuteDbDataReader_WhenPreviousIsNotClosed_ThrowException() Assert.Equal("A command is already in progress: SELECT 1; SELECT 1;", Assert.Throws(() => dbCommand.ExecuteReader()).Message); Assert.True(ydbDataReader.NextResult()); - Assert.True(ydbDataReader.NextResult()); Assert.False(ydbDataReader.NextResult()); ydbDataReader.Close(); @@ -271,7 +269,7 @@ public void GetChars_WhenSelectText_MoveCharsToBuffer() var bufferChars = new char[10]; var checkBuffer = new char[10]; - Assert.Equal(0, ydbDataReader.GetChars(0, 4, null, 0, 6)); + Assert.Equal(7, ydbDataReader.GetChars(0, 4, null, 0, 6)); Assert.Equal($"dataOffset must be between 0 and {int.MaxValue}", Assert.Throws(() => ydbDataReader.GetChars(0, -1, null, 0, 6)).Message); Assert.Equal($"dataOffset must be between 0 and {int.MaxValue}", @@ -319,7 +317,7 @@ public void GetBytes_WhenSelectBytes_MoveBytesToBuffer() var bufferChars = new byte[10]; var checkBuffer = new byte[10]; - Assert.Equal(0, ydbDataReader.GetBytes(0, 4, null, 0, 6)); + Assert.Equal(7, ydbDataReader.GetBytes(0, 4, null, 0, 6)); Assert.Equal($"dataOffset must be between 0 and {int.MaxValue}", Assert.Throws(() => ydbDataReader.GetBytes(0, -1, null, 0, 6)).Message); Assert.Equal($"dataOffset must be between 0 and {int.MaxValue}", @@ -399,10 +397,29 @@ public async Task GetEnumerator_WhenReadMultiSelect_ReadFirstResultSet() } [Fact] - public async Task ExecuteScalar_WhenSelectNull_ReturnNull() + public async Task ExecuteScalar_WhenSelectNull_ReturnDbNull() + { + await using var ydbConnection = await CreateOpenConnectionAsync(); + Assert.Equal(DBNull.Value, + await new YdbCommand(ydbConnection) { CommandText = "SELECT NULL" }.ExecuteScalarAsync()); + } + + [Fact] + public async Task GetValue_WhenSelectNull_ReturnDbNull() + { + await using var ydbConnection = await CreateOpenConnectionAsync(); + var reader = await new YdbCommand(ydbConnection) { CommandText = "SELECT NULL" }.ExecuteReaderAsync(); + Assert.True(await reader.ReadAsync()); + Assert.True(reader.IsDBNull(0)); + Assert.Equal(DBNull.Value, reader.GetValue(0)); + } + + [Fact] + public async Task ExecuteScalar_WhenSelectNoRows_ReturnNull() { await using var ydbConnection = await CreateOpenConnectionAsync(); - Assert.Null(await new YdbCommand(ydbConnection) { CommandText = "SELECT NULL" }.ExecuteScalarAsync()); + Assert.Null(await new YdbCommand(ydbConnection) { CommandText = "SELECT * FROM (select 1) AS T WHERE FALSE" } + .ExecuteScalarAsync()); } [Theory] diff --git a/src/Ydb.Sdk/tests/Ado/YdbConnectionTests.cs b/src/Ydb.Sdk/tests/Ado/YdbConnectionTests.cs index c087ccb0..534c8f71 100644 --- a/src/Ydb.Sdk/tests/Ado/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/tests/Ado/YdbConnectionTests.cs @@ -116,7 +116,8 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() Assert.Equal(1, reader.GetInt32(0)); await ydbConnection.CloseAsync(); - Assert.False(await reader.ReadAsync()); + Assert.Equal("The reader is closed", + (await Assert.ThrowsAsync(() => reader.ReadAsync())).Message); } [Fact] diff --git a/src/Ydb.Sdk/tests/Ado/YdbDataReaderTests.cs b/src/Ydb.Sdk/tests/Ado/YdbDataReaderTests.cs index 586ebe2a..0e04cf9b 100644 --- a/src/Ydb.Sdk/tests/Ado/YdbDataReaderTests.cs +++ b/src/Ydb.Sdk/tests/Ado/YdbDataReaderTests.cs @@ -20,8 +20,6 @@ public async Task BasedIteration_WhenNotCallMethodRead_ThrowException() Assert.Equal("No row is available", Assert.Throws(() => reader.GetValue(0)).Message); - Assert.True(reader.NextResult()); - Assert.Equal("No row is available", Assert.Throws(() => reader.GetValue(0)).Message); // Need Read() @@ -32,11 +30,18 @@ public async Task BasedIteration_WhenNotCallMethodRead_ThrowException() Assert.Throws(() => reader.GetBoolean(1)).Message); Assert.False(reader.Read()); - Assert.True(reader.IsClosed); + Assert.False(reader.IsClosed); - Assert.Equal("The reader is closed", + Assert.Equal("No row is available", Assert.Throws(() => reader.GetValue(0)).Message); Assert.Empty(statuses); + + await reader.CloseAsync(); + Assert.True(reader.IsClosed); + Assert.Equal("The reader is closed", + Assert.Throws(() => reader.GetValue(0)).Message); + Assert.Equal("The reader is closed", + Assert.Throws(() => reader.Read()).Message); } [Fact] @@ -56,7 +61,6 @@ public async Task NextResult_WhenNextResultSkipResultSet_ReturnNextResultSet() var statuses = new List(); var reader = await YdbDataReader.CreateYdbDataReader(EnumeratorSuccess(2), statuses.Add); - Assert.True(reader.NextResult()); Assert.True(reader.NextResult()); Assert.True(reader.Read()); Assert.True((bool)reader.GetValue(0)); diff --git a/src/Ydb.Sdk/tests/Ado/YdbTransactionTests.cs b/src/Ydb.Sdk/tests/Ado/YdbTransactionTests.cs index aa3b9031..4f30a261 100644 --- a/src/Ydb.Sdk/tests/Ado/YdbTransactionTests.cs +++ b/src/Ydb.Sdk/tests/Ado/YdbTransactionTests.cs @@ -182,7 +182,6 @@ public void CommitAndRollback_WhenStreamIsOpened_ThrowException() Assert.Equal("A command is already in progress: SELECT 1; SELECT 2; SELECT 3", Assert.Throws(() => ydbTransaction.Rollback()).Message); - Assert.True(dbDataReader.NextResult()); Assert.True(dbDataReader.NextResult()); Assert.True(dbDataReader.NextResult()); Assert.False(dbDataReader.NextResult());