From 4c6d452865d8d25af8d9e2b8a8d3068763f012da Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 18 Aug 2025 14:50:52 +0300 Subject: [PATCH 01/16] feat: YdbList + BulkUpsertImporter.AddListAsync --- .../src/Ado/BulkUpsert/BulkUpsertImporter.cs | 59 +++ .../src/Ado/BulkUpsert/IBulkUpsertImporter.cs | 13 + src/Ydb.Sdk/src/Ado/YdbParameter.cs | 5 +- src/Ydb.Sdk/src/Value/YdbList.cs | 35 ++ .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 363 ++++++++++++++++++ .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 88 +++++ 6 files changed, 560 insertions(+), 3 deletions(-) create mode 100644 src/Ydb.Sdk/src/Value/YdbList.cs create mode 100644 src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs index dc563257..5b0d2a48 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs @@ -31,6 +31,14 @@ internal BulkUpsertImporter( _cancellationToken = cancellationToken; } + /// Adds one line to the current BulkUpsert batch. + /// Column values are in array order columns. + /// Types: / / — as is; others are displayed. + /// // columns: ["Id","Name"] + /// await importer.AddRowAsync(1, "Alice"); + /// + /// The number of values is not equal to the number of columns. + /// The value cannot be compared with the YDB type. public async ValueTask AddRowAsync(params object[] values) { if (values.Length != _columns.Count) @@ -40,6 +48,7 @@ public async ValueTask AddRowAsync(params object[] values) { YdbValue ydbValue => ydbValue.GetProto(), YdbParameter param => param.TypedValue, + YdbList list => list.ToTypedValue(), _ => new YdbParameter { Value = v }.TypedValue } ).ToArray(); @@ -60,6 +69,56 @@ public async ValueTask AddRowAsync(params object[] values) _structType ??= new StructType { Members = { _columns.Select((col, i) => new StructMember { Name = col, Type = ydbValues[i].Type }) } }; } + + /// + /// Adds a set of strings in the form . + /// + /// + /// The expected value is of the type List<Struct<...>>. Names and order of fields Struct + /// they must exactly match the array columns passed when creating the importer.. + /// Example: columns=["Id","Name"]List<Struct<Id:Int64, Name:Utf8>>. + /// + public async ValueTask AddListAsync(YdbList list) + { + var tv = list.ToTypedValue(); + + if (tv.Type.TypeCase != Type.TypeOneofCase.ListType || + tv.Type.ListType.Item.TypeCase != Type.TypeOneofCase.StructType) + { + throw new ArgumentException( + "BulkUpsertImporter.AddListAsync expects a YdbList with a value like List>", + nameof(list)); + } + + var incomingStruct = tv.Type.ListType.Item.StructType; + + if (incomingStruct.Members.Count != _columns.Count) + throw new ArgumentException( + $"The number of columns in the List ({incomingStruct.Members.Count}) " + + $"does not match the expected ({_columns.Count})."); + + for (var i = 0; i < _columns.Count; i++) + { + var expected = _columns[i]; + var actual = incomingStruct.Members[i].Name; + if (!string.Equals(expected, actual, StringComparison.Ordinal)) + throw new ArgumentException( + $"Column name mismatch at position {i}: expected '{expected}', received '{actual}'."); + } + + _structType ??= incomingStruct; + + foreach (var rowValue in tv.Value.Items) + { + var rowSize = rowValue.CalculateSize(); + + if (_currentBytes + rowSize > _maxBatchByteSize && _rows.Count > 0) + await FlushAsync().ConfigureAwait(false); + + _rows.Add(rowValue); + _currentBytes += rowSize; + } + } public async ValueTask FlushAsync() { diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs index fa687609..166810e4 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs @@ -1,8 +1,21 @@ +using Ydb.Sdk.Value; + namespace Ydb.Sdk.Ado.BulkUpsert; public interface IBulkUpsertImporter { + /// Adds one line to the batch. + /// The column values are in order columns. ValueTask AddRowAsync(params object[] row); + + /// + /// Adds multiple lines with a single parameter . + /// + /// + /// Expected List<Struct<...>>, where the names and order of the fields are the same as columns. + /// Form Example: List<Struct<Id:Int64, Name:Utf8>>. + /// + ValueTask AddListAsync(YdbList list); ValueTask FlushAsync(); } diff --git a/src/Ydb.Sdk/src/Ado/YdbParameter.cs b/src/Ydb.Sdk/src/Ado/YdbParameter.cs index 78ffbec9..7857507d 100644 --- a/src/Ydb.Sdk/src/Ado/YdbParameter.cs +++ b/src/Ydb.Sdk/src/Ado/YdbParameter.cs @@ -37,9 +37,7 @@ public sealed class YdbParameter : DbParameter private string _parameterName = string.Empty; - public YdbParameter() - { - } + public YdbParameter() { } public YdbParameter(string parameterName, object value) { @@ -246,6 +244,7 @@ internal TypedValue TypedValue private TypedValue Cast(object value) => value switch { + YdbList ydbList => ydbList.ToTypedValue(), string stringValue => stringValue.Text(), bool boolValue => boolValue.Bool(), sbyte sbyteValue => sbyteValue.Int8(), diff --git a/src/Ydb.Sdk/src/Value/YdbList.cs b/src/Ydb.Sdk/src/Value/YdbList.cs new file mode 100644 index 00000000..6bf5b897 --- /dev/null +++ b/src/Ydb.Sdk/src/Value/YdbList.cs @@ -0,0 +1,35 @@ +using Ydb.Sdk.Ado; +using Ydb.Sdk.Ado.YdbType; + +namespace Ydb.Sdk.Value; + +/// +/// A wrapper above the list of YDB values. For bulk operations, it is used as +/// List<Struct<...>> with the fields in the same order as columns. +/// +public sealed class YdbList +{ + private readonly IReadOnlyList _items; + + public YdbList(IEnumerable items) + { + _items = items as IReadOnlyList ?? items.ToList(); + } + + internal TypedValue ToTypedValue() + { + var typed = new List(_items.Count); + foreach (var item in _items) + { + var tv = item switch + { + YdbValue yv => yv.GetProto(), + YdbParameter p => p.TypedValue, + _ => new YdbParameter { Value = item }.TypedValue + }; + typed.Add(tv); + } + + return typed.List(); + } +} diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs new file mode 100644 index 00000000..1372abc7 --- /dev/null +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -0,0 +1,363 @@ +using Xunit; +using Ydb.Sdk.Ado.YdbType; +using Ydb.Sdk.Value; + +namespace Ydb.Sdk.Ado.Tests.Value; + +public class YdbListTests : TestBase +{ + [Fact] + public void ListOfInt64_FromPlainObjects_IsInferred() + { + var param = new YdbParameter("$ids", new YdbList([1L, 2L, 3L])); + var tv = param.TypedValue; + + Assert.Equal(3, tv.Value.Items.Count); + Assert.All(tv.Value.Items, v => Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, v.ValueCase)); + + Assert.Equal(1L, tv.Value.Items[0].Int64Value); + Assert.Equal(2L, tv.Value.Items[1].Int64Value); + Assert.Equal(3L, tv.Value.Items[2].Int64Value); + } + + [Fact] + public void ListOfUtf8_FromPlainStrings_IsInferred() + { + var param = new YdbParameter("$tags", new YdbList(["a", "b"])); + var tv = param.TypedValue; + + Assert.Equal(2, tv.Value.Items.Count); + Assert.All(tv.Value.Items, v => Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, v.ValueCase)); + + Assert.Equal("a", tv.Value.Items[0].TextValue); + Assert.Equal("b", tv.Value.Items[1].TextValue); + } + + [Fact] + public void ListOfStruct_WithNestedList_HasExpectedShape() + { + // List>> + var rows = new YdbList(new object[] + { + YdbValue.MakeStruct(new Dictionary + { + ["Key"] = YdbValue.MakeInt64(1), + ["SubKey"] = YdbValue.MakeInt64(2), + ["Value"] = YdbValue.MakeUtf8("v"), + ["Tags"] = YdbValue.MakeList([YdbValue.MakeUtf8("a"), YdbValue.MakeUtf8("b")]) + }), + YdbValue.MakeStruct(new Dictionary + { + ["Key"] = YdbValue.MakeInt64(10), + ["SubKey"] = YdbValue.MakeInt64(20), + ["Value"] = YdbValue.MakeUtf8("vv"), + ["Tags"] = YdbValue.MakeList([YdbValue.MakeUtf8("x")]) + }) + }); + + var p = new YdbParameter("$rows", rows); + var tv = p.TypedValue; + + Assert.Equal(2, tv.Value.Items.Count); + + var row1 = tv.Value.Items[0].Items; + Assert.Equal(4, row1.Count); + + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row1[0].ValueCase); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row1[1].ValueCase); + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, row1[2].ValueCase); + + Assert.Equal(Ydb.Value.ValueOneofCase.None, row1[3].ValueCase); + Assert.True(row1[3].Items.Count > 0); + + Assert.Equal(1L, row1[0].Int64Value); + Assert.Equal(2L, row1[1].Int64Value); + Assert.Equal("v", row1[2].TextValue); + + var tags1 = row1[3].Items; + Assert.Equal(2, tags1.Count); + Assert.Equal("a", tags1[0].TextValue); + Assert.Equal("b", tags1[1].TextValue); + + var row2 = tv.Value.Items[1].Items; + Assert.Equal(4, row2.Count); + Assert.Equal(10L, row2[0].Int64Value); + Assert.Equal(20L, row2[1].Int64Value); + Assert.Equal("vv", row2[2].TextValue); + + var tags2 = row2[3].Items; + Assert.Single(tags2); + Assert.Equal("x", tags2[0].TextValue); + } + + [Fact] + public void List_MixedItems_YdbValue_YdbParameter_Primitives_AreUnified() + { + var mixed = new YdbList(new object[] + { + YdbValue.MakeInt64(1), + new YdbParameter { YdbDbType = YdbDbType.Int64, Value = 2L }, + 3L + }); + + var p = new YdbParameter("$mixed", mixed); + var tv = p.TypedValue; + + Assert.Equal(3, tv.Value.Items.Count); + Assert.All(tv.Value.Items, v => Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, v.ValueCase)); + + Assert.Equal(1L, tv.Value.Items[0].Int64Value); + Assert.Equal(2L, tv.Value.Items[1].Int64Value); + Assert.Equal(3L, tv.Value.Items[2].Int64Value); + } + + [Fact] + public void UPDATE_ON_SELECT_YdbList_Shape_And_SampleYql() + { + // $to_update: List> + var toUpdate = new YdbList(new object[] + { + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(1), + ["Value"] = YdbValue.MakeUtf8("new-1") + }), + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(2), + ["Value"] = YdbValue.MakeUtf8("new-2") + }), + }); + + var p = new YdbParameter("$to_update", toUpdate); + var tv = p.TypedValue; + + Assert.Equal(2, tv.Value.Items.Count); + foreach (var row in tv.Value.Items) + { + Assert.Equal(2, row.Items.Count); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row.Items[0].ValueCase); // Id + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, row.Items[1].ValueCase); // Value + } + + const string yql = """ + UPDATE my_table ON + SELECT * FROM $to_update; + """; + Assert.Contains("UPDATE my_table ON", yql); + Assert.Contains("SELECT * FROM $to_update;", yql); + } + + [Fact] + public void DELETE_ON_SELECT_YdbListPk_Shape_And_SampleYql() + { + // $to_delete: List> + var toDelete = new YdbList(new object[] + { + YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(1) }), + YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(2) }), + }); + + var p = new YdbParameter("$to_delete", toDelete); + var tv = p.TypedValue; + + Assert.Equal(2, tv.Value.Items.Count); + foreach (var row in tv.Value.Items) + { + Assert.Single(row.Items); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row.Items[0].ValueCase); + } + + const string yql = """ + DELETE my_table ON + SELECT * FROM $to_delete; + """; + Assert.Contains("DELETE my_table ON", yql); + Assert.Contains("SELECT * FROM $to_delete;", yql); + } + + [Fact] + public void INSERT_INTO_SELECT_YdbList_Shape_And_SampleYql() + { + // $rows: List> + var rows = new YdbList(new object[] + { + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(10), + ["Value"] = YdbValue.MakeUtf8("v") + }) + }); + + var p = new YdbParameter("$rows", rows); + var tv = p.TypedValue; + + Assert.Single(tv.Value.Items); + Assert.Equal(2, tv.Value.Items[0].Items.Count); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, tv.Value.Items[0].Items[0].ValueCase); // Id + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, tv.Value.Items[0].Items[1].ValueCase); // Value + + const string yql = """ + INSERT INTO my_table + SELECT * FROM $rows; + """; + Assert.Contains("INSERT INTO my_table", yql); + Assert.DoesNotContain(" ON", yql); + } + + [Fact] + public void UPSERT_INTO_SELECT_YdbList_Shape_And_SampleYql() + { + // $rows: List> + var rows = new YdbList(new object[] + { + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(10), + ["Value"] = YdbValue.MakeUtf8("vv") + }) + }); + + var p = new YdbParameter("$rows", rows); + var tv = p.TypedValue; + + Assert.Single(tv.Value.Items); + Assert.Equal(2, tv.Value.Items[0].Items.Count); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, tv.Value.Items[0].Items[0].ValueCase); // Id + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, tv.Value.Items[0].Items[1].ValueCase); // Value + + const string yql = """ + UPSERT INTO my_table + SELECT * FROM $rows; + """; + Assert.Contains("UPSERT INTO my_table", yql); + Assert.DoesNotContain(" ON", yql); + } + + [Fact] + public async Task Update_On_ListStruct_UpdatesRows() + { + var table = $"UpdOn_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $@" + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + Other Utf8, + PRIMARY KEY (Id) + );"; + await cmd.ExecuteNonQueryAsync(); + + cmd.CommandText = $@"UPSERT INTO {table} (Id, Value, Other) VALUES + (1, 'old-1', 'keep'), + (2, 'old-2', 'keep'), + (3, 'old-3', 'keep');"; + await cmd.ExecuteNonQueryAsync(); + } + + // $to_update : List> + var toUpdate = new YdbList(new object[] + { + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(1), + ["Value"] = YdbValue.MakeUtf8("new-1") + }), + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(3), + ["Value"] = YdbValue.MakeUtf8("new-3") + }) + }); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $@" + DECLARE $to_update AS List>; + UPDATE {table} ON + SELECT * FROM AS_TABLE($to_update);"; + cmd.Parameters.Add(new YdbParameter("$to_update", toUpdate)); + await cmd.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Id, Value, Other FROM {table} ORDER BY Id;"; + var rows = new List<(long id, string val, string oth)>(); + await using var r = await check.ExecuteReaderAsync(); + while (await r.ReadAsync()) + rows.Add((r.GetInt64(0), r.GetString(1), r.GetString(2))); + + Assert.Equal((1L, "new-1", "keep"), rows[0]); + Assert.Equal((2L, "old-2", "keep"), rows[1]); + Assert.Equal((3L, "new-3", "keep"), rows[2]); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } + + [Fact] + public async Task Delete_On_ListStruct_DeletesByPk() + { + var table = $"DelOn_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $@" + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + );"; + await cmd.ExecuteNonQueryAsync(); + + cmd.CommandText = $@"UPSERT INTO {table} (Id, Value) VALUES + (1, 'a'), (2, 'b'), (3, 'c');"; + await cmd.ExecuteNonQueryAsync(); + } + + // $to_delete : List> + var toDelete = new YdbList(new object[] + { + YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(1) }), + YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(3) }) + }); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $@" + DECLARE $to_delete AS List>; + DELETE FROM {table} ON + SELECT Id FROM AS_TABLE($to_delete);"; + cmd.Parameters.Add(new YdbParameter("$to_delete", toDelete)); + await cmd.ExecuteNonQueryAsync(); + } + + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT ARRAY_LENGTH(AsList()) FROM (SELECT * FROM {table});"; + check.CommandText = $"SELECT COUNT(*) FROM {table};"; + var left = Convert.ToInt32(await check.ExecuteScalarAsync()); + Assert.Equal(1, left); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } +} diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index 89413315..46cc85e1 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -2,6 +2,7 @@ using Xunit; using Ydb.Sdk.Ado.Tests.Utils; using Ydb.Sdk.Ado.YdbType; +using Ydb.Sdk.Value; namespace Ydb.Sdk.Ado.Tests; @@ -477,4 +478,91 @@ public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() await Assert.ThrowsAsync(async () => { await importer.FlushAsync(); }); } + + [Fact] + public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() + { + var table = $"BulkImporter_List_{Guid.NewGuid():N}"; + + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Name Utf8, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + + // $rows: List> + var rows = new YdbList([ + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(1), + ["Name"] = YdbValue.MakeUtf8("A") + }), + YdbValue.MakeStruct(new Dictionary + { + ["Id"] = YdbValue.MakeInt64(2), + ["Name"] = YdbValue.MakeUtf8("B") + }) + ]); + + await importer.AddListAsync(rows); + await importer.FlushAsync(); + + await using var check = conn.CreateCommand(); + check.CommandText = $"SELECT COUNT(*) FROM {table}"; + var count = Convert.ToInt32(await check.ExecuteScalarAsync()); + Assert.Equal(2, count); + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } + + [Fact] + public async Task BulkUpsertImporter_AddListAsync_NotListOfStruct_ThrowsArgumentException() + { + var table = $"BulkImporter_List_{Guid.NewGuid():N}"; + + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Name Utf8, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + + var wrong = new YdbList([1L, 2L, 3L]); + + var ex = await Assert.ThrowsAsync(() => importer.AddListAsync(wrong).AsTask()); + Assert.Contains("expects a YdbList with a value like List>", ex.Message); + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } } From 2573547242649990ba3b68dec63afea0216f85e1 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 18 Aug 2025 16:13:01 +0300 Subject: [PATCH 02/16] feat: YdbList struct-mode (List) --- src/Ydb.Sdk/src/Value/YdbList.cs | 148 +++++- .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 421 ++++++------------ 2 files changed, 269 insertions(+), 300 deletions(-) diff --git a/src/Ydb.Sdk/src/Value/YdbList.cs b/src/Ydb.Sdk/src/Value/YdbList.cs index 6bf5b897..591c42ee 100644 --- a/src/Ydb.Sdk/src/Value/YdbList.cs +++ b/src/Ydb.Sdk/src/Value/YdbList.cs @@ -4,21 +4,78 @@ namespace Ydb.Sdk.Value; /// -/// A wrapper above the list of YDB values. For bulk operations, it is used as -/// List<Struct<...>> with the fields in the same order as columns. +/// Universal wrapper for YDB lists. +/// +/// - Plain mode (back-compat): wraps any and produces List<T>. +/// +/// +/// - Struct mode: builds List<Struct<...>> without using YdbValue.MakeStruct on the outside. +/// You define column names (and optional types) and push rows positionally. +/// /// public sealed class YdbList { - private readonly IReadOnlyList _items; + // -------- Plain mode -------- + private readonly IReadOnlyList? _items; + // -------- Struct mode -------- + private readonly string[]? _columns; + private readonly YdbDbType[]? _types; + private readonly List? _rows; + + /// + /// Plain mode constructor (kept for backward compatibility). + /// Produces List<T> by inferring element types. + /// public YdbList(IEnumerable items) { _items = items as IReadOnlyList ?? items.ToList(); } + /// + /// Start Struct mode with column names (types will be inferred from the first non-null row). + /// + public static YdbList Struct(params string[] columns) => new(columns, null); + + /// + /// Start Struct mode with column names and explicit YDB types (same length as ). + /// Use explicit types if you plan to pass null values and want typed NULLs. + /// + public static YdbList Struct(string[] columns, YdbDbType[]? types) => new(columns, types); + + private YdbList(string[] columns, YdbDbType[]? types) + { + if (types is not null && types.Length != columns.Length) + throw new ArgumentException("Length of 'types' must match length of 'columns'.", nameof(types)); + + _columns = columns; + _types = types; + _rows = new List(); + } + + /// + /// Add one positional row (Struct mode). Values must match the number of columns. + /// + public YdbList AddRow(params object?[] values) + { + EnsureStruct(); + if (values.Length != _columns!.Length) + throw new ArgumentException($"Expected {_columns.Length} values, got {values.Length}."); + _rows!.Add(values); + return this; + } + + /// + /// Converts this wrapper to a YDB . + /// In plain mode returns List<T>; in struct mode returns List<Struct<...>>. + /// internal TypedValue ToTypedValue() + => _columns is null ? ToTypedValuePlain() : ToTypedValueStruct(); + + // -------- Implementation: plain mode -------- + private TypedValue ToTypedValuePlain() { - var typed = new List(_items.Count); + var typed = new List(_items!.Count); foreach (var item in _items) { var tv = item switch @@ -32,4 +89,87 @@ internal TypedValue ToTypedValue() return typed.List(); } + + // -------- Implementation: struct mode -------- + private TypedValue ToTypedValueStruct() + { + if (_rows!.Count == 0 && (_types is null || _types.All(t => t == YdbDbType.Unspecified))) + throw new InvalidOperationException( + "Cannot infer Struct schema from an empty list without explicit YdbDbType hints."); + + var memberTypes = new List(_columns!.Length); + for (var i = 0; i < _columns.Length; i++) + { + if (_types is not null && _types[i] != YdbDbType.Unspecified) + { + var tv = new YdbParameter { YdbDbType = _types[i] }.TypedValue; + memberTypes.Add(tv.Type); + continue; + } + + var sample = (from r in _rows where r[i] is not null and not DBNull select r[i]).FirstOrDefault(); + if (sample is null) + throw new InvalidOperationException( + $"Column '{_columns[i]}' has only nulls and no explicit YdbDbType. Provide a type hint."); + + var inferred = new YdbParameter { Value = sample }.TypedValue; + memberTypes.Add(inferred.Type); + } + + var structType = new StructType + { + Members = + { + _columns.Select((name, idx) => new StructMember + { + Name = name, + Type = memberTypes[idx] + }) + } + }; + + var ydbRows = new List(_rows.Count); + foreach (var r in _rows) + { + var fields = new List(_columns.Length); + for (var i = 0; i < _columns.Length; i++) + { + var v = r[i]; + + if (_types is not null && _types[i] != YdbDbType.Unspecified) + { + var tv = new YdbParameter { YdbDbType = _types[i], Value = v }.TypedValue; + fields.Add(tv.Value); + } + else + { + if (v is null || v == DBNull.Value) + throw new InvalidOperationException( + $"Column '{_columns[i]}' has null value but no explicit YdbDbType. Provide a type hint."); + + var tv = v switch + { + YdbValue yv => yv.GetProto(), + YdbParameter p => p.TypedValue, + _ => new YdbParameter { Value = v }.TypedValue + }; + fields.Add(tv.Value); + } + } + ydbRows.Add(new Ydb.Value { Items = { fields } }); + } + + return new TypedValue + { + Type = new Type { ListType = new ListType { Item = new Type { StructType = structType } } }, + Value = new Ydb.Value { Items = { ydbRows } } + }; + } + + private void EnsureStruct() + { + if (_columns is null) + throw new InvalidOperationException( + "This YdbList was created in plain mode. Use YdbList.Struct(...) to build List>."); + } } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs index 1372abc7..1e82787c 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -7,101 +7,130 @@ namespace Ydb.Sdk.Ado.Tests.Value; public class YdbListTests : TestBase { [Fact] - public void ListOfInt64_FromPlainObjects_IsInferred() + public void Struct_BasicShape_ProducesListOfStruct() { - var param = new YdbParameter("$ids", new YdbList([1L, 2L, 3L])); - var tv = param.TypedValue; + // $rows: List> + var rows = YdbList + .Struct("Id", "Value") + .AddRow(1L, "a") + .AddRow(2L, "b"); - Assert.Equal(3, tv.Value.Items.Count); - Assert.All(tv.Value.Items, v => Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, v.ValueCase)); + var tv = new YdbParameter("$rows", rows).TypedValue; - Assert.Equal(1L, tv.Value.Items[0].Int64Value); - Assert.Equal(2L, tv.Value.Items[1].Int64Value); - Assert.Equal(3L, tv.Value.Items[2].Int64Value); + Assert.Equal(2, tv.Value.Items.Count); + + var r1 = tv.Value.Items[0].Items; + Assert.Equal(2, r1.Count); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r1[0].ValueCase); // Id + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r1[1].ValueCase); // Value + Assert.Equal(1L, r1[0].Int64Value); + Assert.Equal("a", r1[1].TextValue); + + var r2 = tv.Value.Items[1].Items; + Assert.Equal(2, r2.Count); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r2[1].ValueCase); + Assert.Equal(2L, r2[0].Int64Value); + Assert.Equal("b", r2[1].TextValue); } [Fact] - public void ListOfUtf8_FromPlainStrings_IsInferred() + public void Struct_AllNonNullThenNull_UsesNullFlagValue() { - var param = new YdbParameter("$tags", new YdbList(["a", "b"])); - var tv = param.TypedValue; + var rows = YdbList + .Struct(["Id", "Name"], [YdbDbType.Int64, YdbDbType.Text]) + .AddRow(1L, "A") + .AddRow(2L, null); + + var tv = new YdbParameter("$rows", rows).TypedValue; Assert.Equal(2, tv.Value.Items.Count); - Assert.All(tv.Value.Items, v => Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, v.ValueCase)); - Assert.Equal("a", tv.Value.Items[0].TextValue); - Assert.Equal("b", tv.Value.Items[1].TextValue); + var r2 = tv.Value.Items[1].Items; + Assert.Equal(2, r2.Count); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); + Assert.Equal(2L, r2[0].Int64Value); + + Assert.Equal(Ydb.Value.ValueOneofCase.NullFlagValue, r2[1].ValueCase); } [Fact] - public void ListOfStruct_WithNestedList_HasExpectedShape() + public void Struct_NullBeforeInference_Throws() { - // List>> - var rows = new YdbList(new object[] - { - YdbValue.MakeStruct(new Dictionary - { - ["Key"] = YdbValue.MakeInt64(1), - ["SubKey"] = YdbValue.MakeInt64(2), - ["Value"] = YdbValue.MakeUtf8("v"), - ["Tags"] = YdbValue.MakeList([YdbValue.MakeUtf8("a"), YdbValue.MakeUtf8("b")]) - }), - YdbValue.MakeStruct(new Dictionary - { - ["Key"] = YdbValue.MakeInt64(10), - ["SubKey"] = YdbValue.MakeInt64(20), - ["Value"] = YdbValue.MakeUtf8("vv"), - ["Tags"] = YdbValue.MakeList([YdbValue.MakeUtf8("x")]) - }) - }); + var rows = YdbList + .Struct("Id", "Value") + .AddRow(1L, null) + .AddRow(2L, "B"); var p = new YdbParameter("$rows", rows); - var tv = p.TypedValue; + var ex = Assert.Throws(() => { var _ = p.TypedValue; }); + + Assert.Contains("null", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("explicit", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Struct_WithTypeHints_AllowsTypedNulls() + { + var rows = YdbList.Struct( + ["Id", "Name"], + [YdbDbType.Int64, YdbDbType.Text]) + .AddRow(1L, "A") + .AddRow(2L, null); + + var tv = new YdbParameter("$rows", rows).TypedValue; Assert.Equal(2, tv.Value.Items.Count); - var row1 = tv.Value.Items[0].Items; - Assert.Equal(4, row1.Count); + var r2 = tv.Value.Items[1].Items; + Assert.Equal(2, r2.Count); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); + Assert.Equal(2L, r2[0].Int64Value); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row1[0].ValueCase); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row1[1].ValueCase); - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, row1[2].ValueCase); + Assert.Equal(Ydb.Value.ValueOneofCase.NullFlagValue, r2[1].ValueCase); + } - Assert.Equal(Ydb.Value.ValueOneofCase.None, row1[3].ValueCase); - Assert.True(row1[3].Items.Count > 0); + [Fact] + public void Struct_EmptyWithoutTypeHints_Throws() + { + var rows = YdbList.Struct("Id", "Value"); + var p = new YdbParameter("$rows", rows); - Assert.Equal(1L, row1[0].Int64Value); - Assert.Equal(2L, row1[1].Int64Value); - Assert.Equal("v", row1[2].TextValue); + var ex = Assert.Throws(() => { var _ = p.TypedValue; }); + Assert.Contains("infer", ex.Message, StringComparison.OrdinalIgnoreCase); + } - var tags1 = row1[3].Items; - Assert.Equal(2, tags1.Count); - Assert.Equal("a", tags1[0].TextValue); - Assert.Equal("b", tags1[1].TextValue); + [Fact] + public void Struct_NullWithoutAnyNonNull_InColumn_Throws() + { + var rows = YdbList + .Struct("Id", "Value") + .AddRow(1L, null); - var row2 = tv.Value.Items[1].Items; - Assert.Equal(4, row2.Count); - Assert.Equal(10L, row2[0].Int64Value); - Assert.Equal(20L, row2[1].Int64Value); - Assert.Equal("vv", row2[2].TextValue); + var p = new YdbParameter("$rows", rows); + var ex = Assert.Throws(() => { var _ = p.TypedValue; }); - var tags2 = row2[3].Items; - Assert.Single(tags2); - Assert.Equal("x", tags2[0].TextValue); + Assert.Contains("only null", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("explicit", ex.Message, StringComparison.OrdinalIgnoreCase); } [Fact] - public void List_MixedItems_YdbValue_YdbParameter_Primitives_AreUnified() + public void Struct_AddRow_WrongArity_Throws() { - var mixed = new YdbList(new object[] - { - YdbValue.MakeInt64(1), - new YdbParameter { YdbDbType = YdbDbType.Int64, Value = 2L }, - 3L - }); + var rows = YdbList.Struct("Id", "Value"); - var p = new YdbParameter("$mixed", mixed); - var tv = p.TypedValue; + var ex1 = Assert.Throws(() => rows.AddRow(1L)); + Assert.Contains("Expected 2 values", ex1.Message, StringComparison.OrdinalIgnoreCase); + + var ex2 = Assert.Throws(() => rows.AddRow(1L, "a", 123)); + Assert.Contains("Expected 2 values", ex2.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void PlainMode_BackCompat_ListOfPrimitives() + { + var plain = new YdbList([1L, 2L, 3L]); + var tv = new YdbParameter("$ids", plain).TypedValue; Assert.Equal(3, tv.Value.Items.Count); Assert.All(tv.Value.Items, v => Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, v.ValueCase)); @@ -112,252 +141,52 @@ public void List_MixedItems_YdbValue_YdbParameter_Primitives_AreUnified() } [Fact] - public void UPDATE_ON_SELECT_YdbList_Shape_And_SampleYql() + public void Shape_For_Update_Delete_Insert_Upsert_Samples() { - // $to_update: List> - var toUpdate = new YdbList(new object[] - { - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(1), - ["Value"] = YdbValue.MakeUtf8("new-1") - }), - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(2), - ["Value"] = YdbValue.MakeUtf8("new-2") - }), - }); - - var p = new YdbParameter("$to_update", toUpdate); - var tv = p.TypedValue; - - Assert.Equal(2, tv.Value.Items.Count); - foreach (var row in tv.Value.Items) - { - Assert.Equal(2, row.Items.Count); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row.Items[0].ValueCase); // Id - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, row.Items[1].ValueCase); // Value - } - - const string yql = """ + var toUpdate = YdbList.Struct("Id", "Value") + .AddRow(1L, "new-1") + .AddRow(2L, "new-2"); + var pUpdate = new YdbParameter("$to_update", toUpdate).TypedValue; + Assert.Equal(2, pUpdate.Value.Items.Count); + Assert.True(pUpdate.Value.Items.All(r => r.Items.Count == 2)); + + var toDelete = YdbList.Struct("Id") + .AddRow(1L) + .AddRow(3L); + var pDelete = new YdbParameter("$to_delete", toDelete).TypedValue; + Assert.Equal(2, pDelete.Value.Items.Count); + Assert.True(pDelete.Value.Items.All(r => r.Items.Count == 1)); + + const string yqlUpdate = """ UPDATE my_table ON SELECT * FROM $to_update; """; - Assert.Contains("UPDATE my_table ON", yql); - Assert.Contains("SELECT * FROM $to_update;", yql); - } - - [Fact] - public void DELETE_ON_SELECT_YdbListPk_Shape_And_SampleYql() - { - // $to_delete: List> - var toDelete = new YdbList(new object[] - { - YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(1) }), - YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(2) }), - }); + Assert.Contains("UPDATE my_table ON", yqlUpdate); - var p = new YdbParameter("$to_delete", toDelete); - var tv = p.TypedValue; - - Assert.Equal(2, tv.Value.Items.Count); - foreach (var row in tv.Value.Items) - { - Assert.Single(row.Items); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row.Items[0].ValueCase); - } - - const string yql = """ + const string yqlDelete = """ DELETE my_table ON SELECT * FROM $to_delete; """; - Assert.Contains("DELETE my_table ON", yql); - Assert.Contains("SELECT * FROM $to_delete;", yql); - } - - [Fact] - public void INSERT_INTO_SELECT_YdbList_Shape_And_SampleYql() - { - // $rows: List> - var rows = new YdbList(new object[] - { - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(10), - ["Value"] = YdbValue.MakeUtf8("v") - }) - }); - - var p = new YdbParameter("$rows", rows); - var tv = p.TypedValue; - - Assert.Single(tv.Value.Items); - Assert.Equal(2, tv.Value.Items[0].Items.Count); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, tv.Value.Items[0].Items[0].ValueCase); // Id - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, tv.Value.Items[0].Items[1].ValueCase); // Value + Assert.Contains("DELETE my_table ON", yqlDelete); - const string yql = """ + var insertRows = YdbList.Struct("Id", "Value").AddRow(10L, "v"); + var pInsert = new YdbParameter("$rows", insertRows).TypedValue; + Assert.Single(pInsert.Value.Items); + const string yqlInsert = """ INSERT INTO my_table SELECT * FROM $rows; """; - Assert.Contains("INSERT INTO my_table", yql); - Assert.DoesNotContain(" ON", yql); - } - - [Fact] - public void UPSERT_INTO_SELECT_YdbList_Shape_And_SampleYql() - { - // $rows: List> - var rows = new YdbList(new object[] - { - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(10), - ["Value"] = YdbValue.MakeUtf8("vv") - }) - }); - - var p = new YdbParameter("$rows", rows); - var tv = p.TypedValue; - - Assert.Single(tv.Value.Items); - Assert.Equal(2, tv.Value.Items[0].Items.Count); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, tv.Value.Items[0].Items[0].ValueCase); // Id - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, tv.Value.Items[0].Items[1].ValueCase); // Value + Assert.Contains("INSERT INTO my_table", yqlInsert); + Assert.DoesNotContain(" ON", yqlInsert); - const string yql = """ + var upsertRows = YdbList.Struct("Id", "Value").AddRow(10L, "vv"); + var pUpsert = new YdbParameter("$rows", upsertRows).TypedValue; + Assert.Single(pUpsert.Value.Items); + const string yqlUpsert = """ UPSERT INTO my_table SELECT * FROM $rows; """; - Assert.Contains("UPSERT INTO my_table", yql); - Assert.DoesNotContain(" ON", yql); - } - - [Fact] - public async Task Update_On_ListStruct_UpdatesRows() - { - var table = $"UpdOn_{Guid.NewGuid():N}"; - await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $@" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - Other Utf8, - PRIMARY KEY (Id) - );"; - await cmd.ExecuteNonQueryAsync(); - - cmd.CommandText = $@"UPSERT INTO {table} (Id, Value, Other) VALUES - (1, 'old-1', 'keep'), - (2, 'old-2', 'keep'), - (3, 'old-3', 'keep');"; - await cmd.ExecuteNonQueryAsync(); - } - - // $to_update : List> - var toUpdate = new YdbList(new object[] - { - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(1), - ["Value"] = YdbValue.MakeUtf8("new-1") - }), - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(3), - ["Value"] = YdbValue.MakeUtf8("new-3") - }) - }); - - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $@" - DECLARE $to_update AS List>; - UPDATE {table} ON - SELECT * FROM AS_TABLE($to_update);"; - cmd.Parameters.Add(new YdbParameter("$to_update", toUpdate)); - await cmd.ExecuteNonQueryAsync(); - } - - await using (var check = conn.CreateCommand()) - { - check.CommandText = $"SELECT Id, Value, Other FROM {table} ORDER BY Id;"; - var rows = new List<(long id, string val, string oth)>(); - await using var r = await check.ExecuteReaderAsync(); - while (await r.ReadAsync()) - rows.Add((r.GetInt64(0), r.GetString(1), r.GetString(2))); - - Assert.Equal((1L, "new-1", "keep"), rows[0]); - Assert.Equal((2L, "old-2", "keep"), rows[1]); - Assert.Equal((3L, "new-3", "keep"), rows[2]); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } - } - - [Fact] - public async Task Delete_On_ListStruct_DeletesByPk() - { - var table = $"DelOn_{Guid.NewGuid():N}"; - await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $@" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - );"; - await cmd.ExecuteNonQueryAsync(); - - cmd.CommandText = $@"UPSERT INTO {table} (Id, Value) VALUES - (1, 'a'), (2, 'b'), (3, 'c');"; - await cmd.ExecuteNonQueryAsync(); - } - - // $to_delete : List> - var toDelete = new YdbList(new object[] - { - YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(1) }), - YdbValue.MakeStruct(new Dictionary { ["Id"] = YdbValue.MakeInt64(3) }) - }); - - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $@" - DECLARE $to_delete AS List>; - DELETE FROM {table} ON - SELECT Id FROM AS_TABLE($to_delete);"; - cmd.Parameters.Add(new YdbParameter("$to_delete", toDelete)); - await cmd.ExecuteNonQueryAsync(); - } - - - await using (var check = conn.CreateCommand()) - { - check.CommandText = $"SELECT ARRAY_LENGTH(AsList()) FROM (SELECT * FROM {table});"; - check.CommandText = $"SELECT COUNT(*) FROM {table};"; - var left = Convert.ToInt32(await check.ExecuteScalarAsync()); - Assert.Equal(1, left); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } + Assert.Contains("UPSERT INTO my_table", yqlUpsert); + Assert.DoesNotContain(" ON", yqlUpsert); } } From 726c00b6f67bfb2cfedb2d79838d22a687d98fa5 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 18 Aug 2025 17:32:08 +0300 Subject: [PATCH 03/16] feat: make YdbList struct-only builder for List>; remove plain mode --- .../src/Ado/BulkUpsert/BulkUpsertImporter.cs | 60 ++++--- .../src/Ado/BulkUpsert/IBulkUpsertImporter.cs | 14 +- src/Ydb.Sdk/src/Ado/YdbParameter.cs | 21 +-- src/Ydb.Sdk/src/Value/YdbList.cs | 150 +++++------------- .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 82 ++++++---- .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 58 +++---- 6 files changed, 170 insertions(+), 215 deletions(-) diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs index 5b0d2a48..88e0f10b 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs @@ -31,51 +31,58 @@ internal BulkUpsertImporter( _cancellationToken = cancellationToken; } - /// Adds one line to the current BulkUpsert batch. - /// Column values are in array order columns. - /// Types: / / — as is; others are displayed. - /// // columns: ["Id","Name"] + /// + /// Add a single row to the current BulkUpsert batch. + /// + /// Column values in the same order as the configured columns. + /// + /// Supported element types: , , (as-is); + /// other CLR values are converted via . + /// + /// + /// + /// // columns: ["Id", "Name"] /// await importer.AddRowAsync(1, "Alice"); - /// - /// The number of values is not equal to the number of columns. - /// The value cannot be compared with the YDB type. + /// + /// + /// When the number of values doesn't equal the number of columns. + /// When a value cannot be mapped to a YDB type. public async ValueTask AddRowAsync(params object[] values) { if (values.Length != _columns.Count) - throw new ArgumentException("Values count must match columns count", nameof(values)); + throw new ArgumentException("Values count must match columns count.", nameof(values)); var ydbValues = values.Select(v => v switch - { - YdbValue ydbValue => ydbValue.GetProto(), - YdbParameter param => param.TypedValue, - YdbList list => list.ToTypedValue(), - _ => new YdbParameter { Value = v }.TypedValue - } - ).ToArray(); + { + YdbValue ydbValue => ydbValue.GetProto(), + YdbParameter param => param.TypedValue, + YdbList list => list.ToTypedValue(), + _ => new YdbParameter { Value = v }.TypedValue + }).ToArray(); var protoStruct = new Ydb.Value(); - foreach (var value in ydbValues) protoStruct.Items.Add(value.Value); + foreach (var value in ydbValues) + protoStruct.Items.Add(value.Value); var rowSize = protoStruct.CalculateSize(); if (_currentBytes + rowSize > _maxBatchByteSize && _rows.Count > 0) - { await FlushAsync(); - } _rows.Add(protoStruct); _currentBytes += rowSize; _structType ??= new StructType - { Members = { _columns.Select((col, i) => new StructMember { Name = col, Type = ydbValues[i].Type }) } }; + { + Members = { _columns.Select((col, i) => new StructMember { Name = col, Type = ydbValues[i].Type }) } + }; } - + /// - /// Adds a set of strings in the form . + /// Add multiple rows from a single parameter. /// /// - /// The expected value is of the type List<Struct<...>>. Names and order of fields Struct - /// they must exactly match the array columns passed when creating the importer.. + /// Expects List<Struct<...>>; struct member names and order must exactly match the configured columns. /// Example: columns=["Id","Name"]List<Struct<Id:Int64, Name:Utf8>>. /// public async ValueTask AddListAsync(YdbList list) @@ -120,9 +127,14 @@ public async ValueTask AddListAsync(YdbList list) } } + /// + /// Flush the current batch via BulkUpsert. No-op if the batch is empty. + /// public async ValueTask FlushAsync() { - if (_rows.Count == 0) return; + if (_rows.Count == 0) + return; + if (_structType == null) throw new InvalidOperationException("structType is undefined"); diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs index 166810e4..a55e77d5 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs @@ -4,18 +4,16 @@ namespace Ydb.Sdk.Ado.BulkUpsert; public interface IBulkUpsertImporter { - /// Adds one line to the batch. - /// The column values are in order columns. + /// Add a single row to the batch. Values must match the importer column order. + /// Column values in the same order as the configured columns. ValueTask AddRowAsync(params object[] row); - + /// - /// Adds multiple lines with a single parameter . + /// Add many rows from (shape: List<Struct<...>>). + /// Struct member names and order must exactly match the configured columns. /// - /// - /// Expected List<Struct<...>>, where the names and order of the fields are the same as columns. - /// Form Example: List<Struct<Id:Int64, Name:Utf8>>. - /// ValueTask AddListAsync(YdbList list); + /// Flush the current batch via BulkUpsert (no-op if empty). ValueTask FlushAsync(); } diff --git a/src/Ydb.Sdk/src/Ado/YdbParameter.cs b/src/Ydb.Sdk/src/Ado/YdbParameter.cs index 7857507d..fcb8db6c 100644 --- a/src/Ydb.Sdk/src/Ado/YdbParameter.cs +++ b/src/Ydb.Sdk/src/Ado/YdbParameter.cs @@ -37,7 +37,9 @@ public sealed class YdbParameter : DbParameter private string _parameterName = string.Empty; - public YdbParameter() { } + public YdbParameter() + { + } public YdbParameter(string parameterName, object value) { @@ -122,14 +124,10 @@ internal TypedValue TypedValue var value = Value; if (value is YdbValue ydbValue) - { return ydbValue.GetProto(); - } if (value == null || value == DBNull.Value) - { return NullTypedValue(); - } return YdbDbType switch { @@ -147,13 +145,13 @@ internal TypedValue TypedValue YdbDbType.Double => MakeDouble(value), YdbDbType.Decimal when value is decimal decimalValue => Decimal(decimalValue), YdbDbType.Bytes => MakeBytes(value), - YdbDbType.Json when value is string stringValue => stringValue.Json(), - YdbDbType.JsonDocument when value is string stringValue => stringValue.JsonDocument(), + YdbDbType.Json when value is string sJson => sJson.Json(), + YdbDbType.JsonDocument when value is string sJsonDoc => sJsonDoc.JsonDocument(), YdbDbType.Uuid when value is Guid guidValue => guidValue.Uuid(), YdbDbType.Date => MakeDate(value), - YdbDbType.DateTime when value is DateTime dateTimeValue => dateTimeValue.Datetime(), + YdbDbType.DateTime when value is DateTime dt => dt.Datetime(), YdbDbType.Timestamp => MakeTimestamp(value), - YdbDbType.Interval when value is TimeSpan timeSpanValue => timeSpanValue.Interval(), + YdbDbType.Interval when value is TimeSpan ts => ts.Interval(), YdbDbType.Unspecified => Cast(value), _ => throw ValueTypeNotSupportedException }; @@ -277,9 +275,7 @@ private TypedValue Decimal(decimal value) => private TypedValue NullTypedValue() { if (YdbNullByDbType.TryGetValue(YdbDbType, out var value)) - { return value; - } if (YdbDbType == YdbDbType.Decimal) { @@ -289,8 +285,7 @@ private TypedValue NullTypedValue() } throw new InvalidOperationException( - "Writing value of 'null' is not supported without explicit mapping to the YdbDbType" - ); + "Writing value of 'null' is not supported without explicit mapping to the YdbDbType"); } private InvalidOperationException ValueTypeNotSupportedException => diff --git a/src/Ydb.Sdk/src/Value/YdbList.cs b/src/Ydb.Sdk/src/Value/YdbList.cs index 591c42ee..70d79880 100644 --- a/src/Ydb.Sdk/src/Value/YdbList.cs +++ b/src/Ydb.Sdk/src/Value/YdbList.cs @@ -1,160 +1,99 @@ +using Google.Protobuf.WellKnownTypes; using Ydb.Sdk.Ado; using Ydb.Sdk.Ado.YdbType; namespace Ydb.Sdk.Value; /// -/// Universal wrapper for YDB lists. -/// -/// - Plain mode (back-compat): wraps any and produces List<T>. -/// -/// -/// - Struct mode: builds List<Struct<...>> without using YdbValue.MakeStruct on the outside. -/// You define column names (and optional types) and push rows positionally. -/// +/// Struct-only builder for YDB List<Struct<...>>. +/// Define columns (optionally YDB types) and add positional rows; no external MakeStruct is needed. /// public sealed class YdbList { - // -------- Plain mode -------- - private readonly IReadOnlyList? _items; - - // -------- Struct mode -------- - private readonly string[]? _columns; + private readonly string[] _columns; private readonly YdbDbType[]? _types; - private readonly List? _rows; - - /// - /// Plain mode constructor (kept for backward compatibility). - /// Produces List<T> by inferring element types. - /// - public YdbList(IEnumerable items) - { - _items = items as IReadOnlyList ?? items.ToList(); - } + private readonly List _rows = new(); - /// - /// Start Struct mode with column names (types will be inferred from the first non-null row). - /// - public static YdbList Struct(params string[] columns) => new(columns, null); + /// Create Struct-mode list with column names; types will be inferred from the first non-null per column. + public static YdbList Struct(params string[] columns) => new(columns); - /// - /// Start Struct mode with column names and explicit YDB types (same length as ). - /// Use explicit types if you plan to pass null values and want typed NULLs. - /// - public static YdbList Struct(string[] columns, YdbDbType[]? types) => new(columns, types); + /// Create Struct-mode list with column names and explicit YDB types (same length as columns). + public static YdbList Struct(string[] columns, YdbDbType[] types) => new(columns, types); - private YdbList(string[] columns, YdbDbType[]? types) + /// Constructs Struct-mode list. If is null, types are inferred per column. + public YdbList(string[] columns, YdbDbType[]? types = null) { + if (columns is null || columns.Length == 0) + throw new ArgumentException("Columns must be non-empty.", nameof(columns)); if (types is not null && types.Length != columns.Length) throw new ArgumentException("Length of 'types' must match length of 'columns'.", nameof(types)); _columns = columns; _types = types; - _rows = new List(); } - /// - /// Add one positional row (Struct mode). Values must match the number of columns. - /// + /// Add one positional row. Value count must match the number of columns. public YdbList AddRow(params object?[] values) { - EnsureStruct(); - if (values.Length != _columns!.Length) + if (values.Length != _columns.Length) throw new ArgumentException($"Expected {_columns.Length} values, got {values.Length}."); - _rows!.Add(values); + _rows.Add(values); return this; } - /// - /// Converts this wrapper to a YDB . - /// In plain mode returns List<T>; in struct mode returns List<Struct<...>>. - /// + /// Convert to YDB with shape List<Struct<...>>. internal TypedValue ToTypedValue() - => _columns is null ? ToTypedValuePlain() : ToTypedValueStruct(); - - // -------- Implementation: plain mode -------- - private TypedValue ToTypedValuePlain() { - var typed = new List(_items!.Count); - foreach (var item in _items) - { - var tv = item switch - { - YdbValue yv => yv.GetProto(), - YdbParameter p => p.TypedValue, - _ => new YdbParameter { Value = item }.TypedValue - }; - typed.Add(tv); - } - - return typed.List(); - } + if (_rows.Count == 0 && (_types is null || _types.All(t => t == YdbDbType.Unspecified))) + throw new InvalidOperationException("Cannot infer Struct schema from an empty list without explicit YdbDbType hints."); - // -------- Implementation: struct mode -------- - private TypedValue ToTypedValueStruct() - { - if (_rows!.Count == 0 && (_types is null || _types.All(t => t == YdbDbType.Unspecified))) - throw new InvalidOperationException( - "Cannot infer Struct schema from an empty list without explicit YdbDbType hints."); - - var memberTypes = new List(_columns!.Length); - for (var i = 0; i < _columns.Length; i++) + var memberTypes = new Type[_columns.Length]; + for (int i = 0; i < _columns.Length; i++) { if (_types is not null && _types[i] != YdbDbType.Unspecified) { - var tv = new YdbParameter { YdbDbType = _types[i] }.TypedValue; - memberTypes.Add(tv.Type); + memberTypes[i] = new YdbParameter { YdbDbType = _types[i] }.TypedValue.Type; continue; } - var sample = (from r in _rows where r[i] is not null and not DBNull select r[i]).FirstOrDefault(); + object? sample = null; + foreach (var r in _rows) + { + var v = r[i]; + if (v is not null && v != DBNull.Value) { sample = v; break; } + } if (sample is null) throw new InvalidOperationException( $"Column '{_columns[i]}' has only nulls and no explicit YdbDbType. Provide a type hint."); - var inferred = new YdbParameter { Value = sample }.TypedValue; - memberTypes.Add(inferred.Type); + memberTypes[i] = new YdbParameter { Value = sample }.TypedValue.Type; } var structType = new StructType { - Members = - { - _columns.Select((name, idx) => new StructMember - { - Name = name, - Type = memberTypes[idx] - }) - } + Members = { _columns.Select((name, idx) => new StructMember { Name = name, Type = memberTypes[idx] }) } }; var ydbRows = new List(_rows.Count); foreach (var r in _rows) { var fields = new List(_columns.Length); - for (var i = 0; i < _columns.Length; i++) + for (int i = 0; i < _columns.Length; i++) { var v = r[i]; - - if (_types is not null && _types[i] != YdbDbType.Unspecified) + if (v is null || v == DBNull.Value) { - var tv = new YdbParameter { YdbDbType = _types[i], Value = v }.TypedValue; - fields.Add(tv.Value); + fields.Add(new Ydb.Value { NullFlagValue = NullValue.NullValue }); + continue; } - else - { - if (v is null || v == DBNull.Value) - throw new InvalidOperationException( - $"Column '{_columns[i]}' has null value but no explicit YdbDbType. Provide a type hint."); - var tv = v switch - { - YdbValue yv => yv.GetProto(), - YdbParameter p => p.TypedValue, - _ => new YdbParameter { Value = v }.TypedValue - }; - fields.Add(tv.Value); - } + var tv = v switch + { + YdbValue yv => yv.GetProto(), + YdbParameter p => p.TypedValue, + _ => new YdbParameter { Value = v }.TypedValue + }; + fields.Add(tv.Value); } ydbRows.Add(new Ydb.Value { Items = { fields } }); } @@ -165,11 +104,4 @@ private TypedValue ToTypedValueStruct() Value = new Ydb.Value { Items = { ydbRows } } }; } - - private void EnsureStruct() - { - if (_columns is null) - throw new InvalidOperationException( - "This YdbList was created in plain mode. Use YdbList.Struct(...) to build List>."); - } } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs index 1e82787c..49746221 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -22,14 +22,14 @@ public void Struct_BasicShape_ProducesListOfStruct() var r1 = tv.Value.Items[0].Items; Assert.Equal(2, r1.Count); Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r1[0].ValueCase); // Id - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r1[1].ValueCase); // Value + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r1[1].ValueCase); // Value Assert.Equal(1L, r1[0].Int64Value); Assert.Equal("a", r1[1].TextValue); var r2 = tv.Value.Items[1].Items; Assert.Equal(2, r2.Count); Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r2[1].ValueCase); + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r2[1].ValueCase); Assert.Equal(2L, r2[0].Int64Value); Assert.Equal("b", r2[1].TextValue); } @@ -55,18 +55,27 @@ public void Struct_AllNonNullThenNull_UsesNullFlagValue() } [Fact] - public void Struct_NullBeforeInference_Throws() + public void Struct_NullBeforeInference_InfersFromNextRow_UsesNullFlagValue() { var rows = YdbList .Struct("Id", "Value") .AddRow(1L, null) .AddRow(2L, "B"); - var p = new YdbParameter("$rows", rows); - var ex = Assert.Throws(() => { var _ = p.TypedValue; }); + var tv = new YdbParameter("$rows", rows).TypedValue; - Assert.Contains("null", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("explicit", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, tv.Value.Items.Count); + + var r1 = tv.Value.Items[0].Items; + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r1[0].ValueCase); + Assert.Equal(1L, r1[0].Int64Value); + Assert.Equal(Ydb.Value.ValueOneofCase.NullFlagValue, r1[1].ValueCase); + + var r2 = tv.Value.Items[1].Items; + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); + Assert.Equal(2L, r2[0].Int64Value); + Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r2[1].ValueCase); + Assert.Equal("B", r2[1].TextValue); } [Fact] @@ -96,7 +105,10 @@ public void Struct_EmptyWithoutTypeHints_Throws() var rows = YdbList.Struct("Id", "Value"); var p = new YdbParameter("$rows", rows); - var ex = Assert.Throws(() => { var _ = p.TypedValue; }); + var ex = Assert.Throws(() => + { + var _ = p.TypedValue; + }); Assert.Contains("infer", ex.Message, StringComparison.OrdinalIgnoreCase); } @@ -108,7 +120,10 @@ public void Struct_NullWithoutAnyNonNull_InColumn_Throws() .AddRow(1L, null); var p = new YdbParameter("$rows", rows); - var ex = Assert.Throws(() => { var _ = p.TypedValue; }); + var ex = Assert.Throws(() => + { + var _ = p.TypedValue; + }); Assert.Contains("only null", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("explicit", ex.Message, StringComparison.OrdinalIgnoreCase); @@ -127,17 +142,28 @@ public void Struct_AddRow_WrongArity_Throws() } [Fact] - public void PlainMode_BackCompat_ListOfPrimitives() + public void Struct_SingleColumn_ListOfPrimitives() { - var plain = new YdbList([1L, 2L, 3L]); - var tv = new YdbParameter("$ids", plain).TypedValue; + // $ids: List> + var ids = YdbList + .Struct("Id") + .AddRow(1L) + .AddRow(2L) + .AddRow(3L); + + var tv = new YdbParameter("$ids", ids).TypedValue; Assert.Equal(3, tv.Value.Items.Count); - Assert.All(tv.Value.Items, v => Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, v.ValueCase)); - Assert.Equal(1L, tv.Value.Items[0].Int64Value); - Assert.Equal(2L, tv.Value.Items[1].Int64Value); - Assert.Equal(3L, tv.Value.Items[2].Int64Value); + Assert.All(tv.Value.Items, row => + { + Assert.Single(row.Items); + Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row.Items[0].ValueCase); + }); + + Assert.Equal(1L, tv.Value.Items[0].Items[0].Int64Value); + Assert.Equal(2L, tv.Value.Items[1].Items[0].Int64Value); + Assert.Equal(3L, tv.Value.Items[2].Items[0].Int64Value); } [Fact] @@ -158,24 +184,24 @@ public void Shape_For_Update_Delete_Insert_Upsert_Samples() Assert.True(pDelete.Value.Items.All(r => r.Items.Count == 1)); const string yqlUpdate = """ - UPDATE my_table ON - SELECT * FROM $to_update; - """; + UPDATE my_table ON + SELECT * FROM $to_update; + """; Assert.Contains("UPDATE my_table ON", yqlUpdate); const string yqlDelete = """ - DELETE my_table ON - SELECT * FROM $to_delete; - """; + DELETE my_table ON + SELECT * FROM $to_delete; + """; Assert.Contains("DELETE my_table ON", yqlDelete); var insertRows = YdbList.Struct("Id", "Value").AddRow(10L, "v"); var pInsert = new YdbParameter("$rows", insertRows).TypedValue; Assert.Single(pInsert.Value.Items); const string yqlInsert = """ - INSERT INTO my_table - SELECT * FROM $rows; - """; + INSERT INTO my_table + SELECT * FROM $rows; + """; Assert.Contains("INSERT INTO my_table", yqlInsert); Assert.DoesNotContain(" ON", yqlInsert); @@ -183,9 +209,9 @@ INSERT INTO my_table var pUpsert = new YdbParameter("$rows", upsertRows).TypedValue; Assert.Single(pUpsert.Value.Items); const string yqlUpsert = """ - UPSERT INTO my_table - SELECT * FROM $rows; - """; + UPSERT INTO my_table + SELECT * FROM $rows; + """; Assert.Contains("UPSERT INTO my_table", yqlUpsert); Assert.DoesNotContain(" ON", yqlUpsert); } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index 46cc85e1..f86f7ad4 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -173,10 +173,7 @@ INSERT INTO {tableName} ydbCommand.CommandText = $"SELECT NULL, t.* FROM {tableName} t"; var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); Assert.True(await ydbDataReader.ReadAsync()); - for (var i = 0; i < 21; i++) - { - Assert.True(ydbDataReader.IsDBNull(i)); - } + for (var i = 0; i < 21; i++) Assert.True(ydbDataReader.IsDBNull(i)); Assert.False(await ydbDataReader.ReadAsync()); @@ -478,8 +475,8 @@ public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() await Assert.ThrowsAsync(async () => { await importer.FlushAsync(); }); } - - [Fact] + + [Fact] public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() { var table = $"BulkImporter_List_{Guid.NewGuid():N}"; @@ -490,30 +487,22 @@ public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Utf8, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Name Utf8, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); // $rows: List> - var rows = new YdbList([ - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(1), - ["Name"] = YdbValue.MakeUtf8("A") - }), - YdbValue.MakeStruct(new Dictionary - { - ["Id"] = YdbValue.MakeInt64(2), - ["Name"] = YdbValue.MakeUtf8("B") - }) - ]); + var rows = YdbList + .Struct("Id", "Name") + .AddRow(1L, "A") + .AddRow(2L, "B"); await importer.AddListAsync(rows); await importer.FlushAsync(); @@ -532,7 +521,7 @@ PRIMARY KEY (Id) } [Fact] - public async Task BulkUpsertImporter_AddListAsync_NotListOfStruct_ThrowsArgumentException() + public async Task BulkUpsertImporter_AddListAsync_WrongStructColumns_ThrowsArgumentException() { var table = $"BulkImporter_List_{Guid.NewGuid():N}"; @@ -542,21 +531,24 @@ public async Task BulkUpsertImporter_AddListAsync_NotListOfStruct_ThrowsArgument await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Utf8, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Name Utf8, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); - var wrong = new YdbList([1L, 2L, 3L]); + var wrong = YdbList + .Struct("Id", "Wrong") + .AddRow(1L, "A"); var ex = await Assert.ThrowsAsync(() => importer.AddListAsync(wrong).AsTask()); - Assert.Contains("expects a YdbList with a value like List>", ex.Message); + Assert.Contains("mismatch", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("expected 'Name'", ex.Message, StringComparison.OrdinalIgnoreCase); } finally { From 6c0c5ff6e77970894f69389c64dc38d787737a79 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 18 Aug 2025 17:46:33 +0300 Subject: [PATCH 04/16] fix lint --- .../src/Ado/BulkUpsert/BulkUpsertImporter.cs | 6 +++--- src/Ydb.Sdk/src/Value/YdbList.cs | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs index 88e0f10b..94a3cfdb 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs @@ -54,10 +54,10 @@ public async ValueTask AddRowAsync(params object[] values) var ydbValues = values.Select(v => v switch { - YdbValue ydbValue => ydbValue.GetProto(), + YdbValue ydbValue => ydbValue.GetProto(), YdbParameter param => param.TypedValue, - YdbList list => list.ToTypedValue(), - _ => new YdbParameter { Value = v }.TypedValue + YdbList list => list.ToTypedValue(), + _ => new YdbParameter { Value = v }.TypedValue }).ToArray(); var protoStruct = new Ydb.Value(); diff --git a/src/Ydb.Sdk/src/Value/YdbList.cs b/src/Ydb.Sdk/src/Value/YdbList.cs index 70d79880..ecbd692e 100644 --- a/src/Ydb.Sdk/src/Value/YdbList.cs +++ b/src/Ydb.Sdk/src/Value/YdbList.cs @@ -45,10 +45,11 @@ public YdbList AddRow(params object?[] values) internal TypedValue ToTypedValue() { if (_rows.Count == 0 && (_types is null || _types.All(t => t == YdbDbType.Unspecified))) - throw new InvalidOperationException("Cannot infer Struct schema from an empty list without explicit YdbDbType hints."); + throw new InvalidOperationException( + "Cannot infer Struct schema from an empty list without explicit YdbDbType hints."); var memberTypes = new Type[_columns.Length]; - for (int i = 0; i < _columns.Length; i++) + for (var i = 0; i < _columns.Length; i++) { if (_types is not null && _types[i] != YdbDbType.Unspecified) { @@ -60,8 +61,13 @@ internal TypedValue ToTypedValue() foreach (var r in _rows) { var v = r[i]; - if (v is not null && v != DBNull.Value) { sample = v; break; } + if (v is not null && v != DBNull.Value) + { + sample = v; + break; + } } + if (sample is null) throw new InvalidOperationException( $"Column '{_columns[i]}' has only nulls and no explicit YdbDbType. Provide a type hint."); @@ -78,7 +84,7 @@ internal TypedValue ToTypedValue() foreach (var r in _rows) { var fields = new List(_columns.Length); - for (int i = 0; i < _columns.Length; i++) + for (var i = 0; i < _columns.Length; i++) { var v = r[i]; if (v is null || v == DBNull.Value) @@ -89,18 +95,19 @@ internal TypedValue ToTypedValue() var tv = v switch { - YdbValue yv => yv.GetProto(), + YdbValue yv => yv.GetProto(), YdbParameter p => p.TypedValue, - _ => new YdbParameter { Value = v }.TypedValue + _ => new YdbParameter { Value = v }.TypedValue }; fields.Add(tv.Value); } + ydbRows.Add(new Ydb.Value { Items = { fields } }); } return new TypedValue { - Type = new Type { ListType = new ListType { Item = new Type { StructType = structType } } }, + Type = new Type { ListType = new ListType { Item = new Type { StructType = structType } } }, Value = new Ydb.Value { Items = { ydbRows } } }; } From efbd3db1ce5ea1d63acd28065129c871f37c7912 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Tue, 19 Aug 2025 13:30:50 +0300 Subject: [PATCH 05/16] refactor: switch YdbList.Struct to proto-first and update integration tests --- src/Ydb.Sdk/src/Ado/YdbParameter.cs | 12 +- src/Ydb.Sdk/src/Value/YdbList.cs | 150 ++++-- .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 495 +++++++++++------- .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 7 +- 4 files changed, 418 insertions(+), 246 deletions(-) diff --git a/src/Ydb.Sdk/src/Ado/YdbParameter.cs b/src/Ydb.Sdk/src/Ado/YdbParameter.cs index fcb8db6c..8f6cbb80 100644 --- a/src/Ydb.Sdk/src/Ado/YdbParameter.cs +++ b/src/Ydb.Sdk/src/Ado/YdbParameter.cs @@ -124,10 +124,14 @@ internal TypedValue TypedValue var value = Value; if (value is YdbValue ydbValue) + { return ydbValue.GetProto(); + } if (value == null || value == DBNull.Value) + { return NullTypedValue(); + } return YdbDbType switch { @@ -145,13 +149,13 @@ internal TypedValue TypedValue YdbDbType.Double => MakeDouble(value), YdbDbType.Decimal when value is decimal decimalValue => Decimal(decimalValue), YdbDbType.Bytes => MakeBytes(value), - YdbDbType.Json when value is string sJson => sJson.Json(), - YdbDbType.JsonDocument when value is string sJsonDoc => sJsonDoc.JsonDocument(), + YdbDbType.Json when value is string stringJsonValue => stringJsonValue.Json(), + YdbDbType.JsonDocument when value is string stringJsonDocumentValue => stringJsonDocumentValue.JsonDocument(), YdbDbType.Uuid when value is Guid guidValue => guidValue.Uuid(), YdbDbType.Date => MakeDate(value), - YdbDbType.DateTime when value is DateTime dt => dt.Datetime(), + YdbDbType.DateTime when value is DateTime dateTimeValue => dateTimeValue.Datetime(), YdbDbType.Timestamp => MakeTimestamp(value), - YdbDbType.Interval when value is TimeSpan ts => ts.Interval(), + YdbDbType.Interval when value is TimeSpan timeSpanValue => timeSpanValue.Interval(), YdbDbType.Unspecified => Cast(value), _ => throw ValueTypeNotSupportedException }; diff --git a/src/Ydb.Sdk/src/Value/YdbList.cs b/src/Ydb.Sdk/src/Value/YdbList.cs index ecbd692e..a8a3ec06 100644 --- a/src/Ydb.Sdk/src/Value/YdbList.cs +++ b/src/Ydb.Sdk/src/Value/YdbList.cs @@ -6,22 +6,38 @@ namespace Ydb.Sdk.Value; /// /// Struct-only builder for YDB List<Struct<...>>. -/// Define columns (optionally YDB types) and add positional rows; no external MakeStruct is needed. +/// Works directly with protobuf: +/// - Each call to converts values into protobuf cells () and stores a row immediately. +/// - The struct schema () is derived from column type hints or from the first non-null sample per column. +/// - If a column has at least one null, its type becomes Optional<T>; individual null cells are encoded via . /// public sealed class YdbList { private readonly string[] _columns; - private readonly YdbDbType[]? _types; - private readonly List _rows = new(); + private readonly YdbDbType[]? _typeHints; - /// Create Struct-mode list with column names; types will be inferred from the first non-null per column. + private readonly List _rows = new(); + + private readonly Type?[] _observedBaseTypes; + private readonly bool[] _sawNull; + + /// + /// Create a struct-mode list with column names; types will be inferred from the + /// first non-null value per column (columns with any nulls become Optional<T>). + /// public static YdbList Struct(params string[] columns) => new(columns); - /// Create Struct-mode list with column names and explicit YDB types (same length as columns). + /// + /// Create a struct-mode list with column names and explicit YDB type hints + /// (array length must match ). Columns with any nulls + /// will be emitted as Optional<hintedType>. + /// public static YdbList Struct(string[] columns, YdbDbType[] types) => new(columns, types); - /// Constructs Struct-mode list. If is null, types are inferred per column. - public YdbList(string[] columns, YdbDbType[]? types = null) + /// + /// Construct a struct-mode list. If is null, schema is inferred from values. + /// + private YdbList(string[] columns, YdbDbType[]? types = null) { if (columns is null || columns.Length == 0) throw new ArgumentException("Columns must be non-empty.", nameof(columns)); @@ -29,86 +45,106 @@ public YdbList(string[] columns, YdbDbType[]? types = null) throw new ArgumentException("Length of 'types' must match length of 'columns'.", nameof(types)); _columns = columns; - _types = types; + _typeHints = types; + _observedBaseTypes = new Type[_columns.Length]; + _sawNull = new bool[_columns.Length]; } - /// Add one positional row. Value count must match the number of columns. + /// + /// Add a single positional row. The number of values must match the number of columns. + /// Values are converted to protobuf cells and the row is stored immediately. + /// public YdbList AddRow(params object?[] values) { if (values.Length != _columns.Length) throw new ArgumentException($"Expected {_columns.Length} values, got {values.Length}."); - _rows.Add(values); + + var cells = new List(_columns.Length); + + for (var i = 0; i < _columns.Length; i++) + { + var v = values[i]; + + if (v is null || v == DBNull.Value) + { + _sawNull[i] = true; + cells.Add(new Ydb.Value { NullFlagValue = NullValue.NullValue }); + continue; + } + + var tv = v switch + { + YdbValue yv => yv.GetProto(), + YdbParameter p => p.TypedValue, + _ => new YdbParameter { Value = v }.TypedValue + }; + + var t = tv.Type; + if (t.TypeCase == Type.TypeOneofCase.OptionalType && t.OptionalType is not null) + t = t.OptionalType.Item; + + _observedBaseTypes[i] ??= t; + cells.Add(tv.Value); + } + + _rows.Add(new Ydb.Value { Items = { cells } }); return this; } - /// Convert to YDB with shape List<Struct<...>>. + /// + /// Convert to a YDB shaped as List<Struct<...>>. + /// Columns that observed null values are emitted as Optional<T>; + /// individual null cells are encoded via . + /// internal TypedValue ToTypedValue() { - if (_rows.Count == 0 && (_types is null || _types.All(t => t == YdbDbType.Unspecified))) + if (_rows.Count == 0 && (_typeHints is null || _typeHints.All(t => t == YdbDbType.Unspecified))) throw new InvalidOperationException( "Cannot infer Struct schema from an empty list without explicit YdbDbType hints."); - var memberTypes = new Type[_columns.Length]; - for (var i = 0; i < _columns.Length; i++) + var n = _columns.Length; + var memberTypes = new Type[n]; + + for (var i = 0; i < n; i++) { - if (_types is not null && _types[i] != YdbDbType.Unspecified) + Type? baseType; + + if (_typeHints is not null && _typeHints[i] != YdbDbType.Unspecified) { - memberTypes[i] = new YdbParameter { YdbDbType = _types[i] }.TypedValue.Type; - continue; - } + baseType = new YdbParameter { YdbDbType = _typeHints[i], Value = DBNull.Value }.TypedValue.Type; - object? sample = null; - foreach (var r in _rows) + if (baseType.TypeCase == Type.TypeOneofCase.OptionalType && baseType.OptionalType is not null) + baseType = baseType.OptionalType.Item; + } + else { - var v = r[i]; - if (v is not null && v != DBNull.Value) - { - sample = v; - break; - } + baseType = _observedBaseTypes[i]; + if (baseType is null) + throw new InvalidOperationException( + $"Column '{_columns[i]}' has only nulls and no explicit YdbDbType. Provide a type hint."); } - if (sample is null) - throw new InvalidOperationException( - $"Column '{_columns[i]}' has only nulls and no explicit YdbDbType. Provide a type hint."); - - memberTypes[i] = new YdbParameter { Value = sample }.TypedValue.Type; + memberTypes[i] = _sawNull[i] && baseType.TypeCase != Type.TypeOneofCase.OptionalType + ? new Type { OptionalType = new OptionalType { Item = baseType } } + : baseType; } var structType = new StructType { - Members = { _columns.Select((name, idx) => new StructMember { Name = name, Type = memberTypes[idx] }) } - }; - - var ydbRows = new List(_rows.Count); - foreach (var r in _rows) - { - var fields = new List(_columns.Length); - for (var i = 0; i < _columns.Length; i++) + Members = { - var v = r[i]; - if (v is null || v == DBNull.Value) - { - fields.Add(new Ydb.Value { NullFlagValue = NullValue.NullValue }); - continue; - } - - var tv = v switch + _columns.Select((name, idx) => new StructMember { - YdbValue yv => yv.GetProto(), - YdbParameter p => p.TypedValue, - _ => new YdbParameter { Value = v }.TypedValue - }; - fields.Add(tv.Value); + Name = name, + Type = memberTypes[idx] + }) } - - ydbRows.Add(new Ydb.Value { Items = { fields } }); - } + }; return new TypedValue { - Type = new Type { ListType = new ListType { Item = new Type { StructType = structType } } }, - Value = new Ydb.Value { Items = { ydbRows } } + Type = new Type { ListType = new ListType { Item = new Type { StructType = structType } } }, + Value = new Ydb.Value { Items = { _rows } } }; } } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs index 49746221..82ba1b1d 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -4,215 +4,344 @@ namespace Ydb.Sdk.Ado.Tests.Value; -public class YdbListTests : TestBase +public class YdbListIntegrationTests : TestBase { [Fact] - public void Struct_BasicShape_ProducesListOfStruct() + public async Task Insert_With_YdbList_Works() { - // $rows: List> - var rows = YdbList - .Struct("Id", "Value") - .AddRow(1L, "a") - .AddRow(2L, "b"); - - var tv = new YdbParameter("$rows", rows).TypedValue; - - Assert.Equal(2, tv.Value.Items.Count); - - var r1 = tv.Value.Items[0].Items; - Assert.Equal(2, r1.Count); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r1[0].ValueCase); // Id - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r1[1].ValueCase); // Value - Assert.Equal(1L, r1[0].Int64Value); - Assert.Equal("a", r1[1].TextValue); - - var r2 = tv.Value.Items[1].Items; - Assert.Equal(2, r2.Count); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r2[1].ValueCase); - Assert.Equal(2L, r2[0].Int64Value); - Assert.Equal("b", r2[1].TextValue); - } - - [Fact] - public void Struct_AllNonNullThenNull_UsesNullFlagValue() - { - var rows = YdbList - .Struct(["Id", "Name"], [YdbDbType.Int64, YdbDbType.Text]) - .AddRow(1L, "A") - .AddRow(2L, null); - - var tv = new YdbParameter("$rows", rows).TypedValue; - - Assert.Equal(2, tv.Value.Items.Count); - - var r2 = tv.Value.Items[1].Items; - Assert.Equal(2, r2.Count); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); - Assert.Equal(2L, r2[0].Int64Value); - - Assert.Equal(Ydb.Value.ValueOneofCase.NullFlagValue, r2[1].ValueCase); - } - - [Fact] - public void Struct_NullBeforeInference_InfersFromNextRow_UsesNullFlagValue() - { - var rows = YdbList - .Struct("Id", "Value") - .AddRow(1L, null) - .AddRow(2L, "B"); - - var tv = new YdbParameter("$rows", rows).TypedValue; - - Assert.Equal(2, tv.Value.Items.Count); - - var r1 = tv.Value.Items[0].Items; - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r1[0].ValueCase); - Assert.Equal(1L, r1[0].Int64Value); - Assert.Equal(Ydb.Value.ValueOneofCase.NullFlagValue, r1[1].ValueCase); - - var r2 = tv.Value.Items[1].Items; - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); - Assert.Equal(2L, r2[0].Int64Value); - Assert.Equal(Ydb.Value.ValueOneofCase.TextValue, r2[1].ValueCase); - Assert.Equal("B", r2[1].TextValue); + var table = $"ydb_list_insert_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var rows = YdbList.Struct("Id", "Value") + .AddRow(1L, "a") + .AddRow(2L, "b"); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + INSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; + cmd.Parameters.Add(new YdbParameter("$rows", rows)); + await cmd.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT COUNT(*) FROM {table}"; + var count = Convert.ToInt32(await check.ExecuteScalarAsync()); + Assert.Equal(2, count); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } } [Fact] - public void Struct_WithTypeHints_AllowsTypedNulls() + public async Task Upsert_With_YdbList_Inserts_And_Updates() { - var rows = YdbList.Struct( - ["Id", "Name"], - [YdbDbType.Int64, YdbDbType.Text]) - .AddRow(1L, "A") - .AddRow(2L, null); - - var tv = new YdbParameter("$rows", rows).TypedValue; - - Assert.Equal(2, tv.Value.Items.Count); - - var r2 = tv.Value.Items[1].Items; - Assert.Equal(2, r2.Count); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, r2[0].ValueCase); - Assert.Equal(2L, r2[0].Int64Value); - - Assert.Equal(Ydb.Value.ValueOneofCase.NullFlagValue, r2[1].ValueCase); + var table = $"ydb_list_upsert_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + await using (var seed = conn.CreateCommand()) + { + seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1, 'old')"; + await seed.ExecuteNonQueryAsync(); + } + + var rows = YdbList.Struct("Id", "Value") + .AddRow(1L, "new") + .AddRow(2L, "two"); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + UPSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; + cmd.Parameters.Add(new YdbParameter("$rows", rows)); + await cmd.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Value FROM {table} WHERE Id=1"; + Assert.Equal("new", (string)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; + Assert.Equal("two", (string)(await check.ExecuteScalarAsync())!); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } } [Fact] - public void Struct_EmptyWithoutTypeHints_Throws() + public async Task UpdateOn_With_YdbList_ChangesValues() { - var rows = YdbList.Struct("Id", "Value"); - var p = new YdbParameter("$rows", rows); - - var ex = Assert.Throws(() => + var table = $"ydb_list_update_on_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + await using (var seed = conn.CreateCommand()) + { + seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1,'a'),(2,'b')"; + await seed.ExecuteNonQueryAsync(); + } + + var toUpdate = YdbList.Struct("Id", "Value") + .AddRow(1L, "x") + .AddRow(2L, "y"); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + UPDATE {table} ON + SELECT * FROM AS_TABLE($to_update); + """; + cmd.Parameters.Add(new YdbParameter("$to_update", toUpdate)); + await cmd.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Value FROM {table} WHERE Id=1"; + Assert.Equal("x", (string)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; + Assert.Equal("y", (string)(await check.ExecuteScalarAsync())!); + } + } + finally { - var _ = p.TypedValue; - }); - Assert.Contains("infer", ex.Message, StringComparison.OrdinalIgnoreCase); + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } } [Fact] - public void Struct_NullWithoutAnyNonNull_InColumn_Throws() + public async Task DeleteOn_With_YdbList_RemovesRows() { - var rows = YdbList - .Struct("Id", "Value") - .AddRow(1L, null); - - var p = new YdbParameter("$rows", rows); - var ex = Assert.Throws(() => + var table = $"ydb_list_delete_on_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try { - var _ = p.TypedValue; - }); - - Assert.Contains("only null", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("explicit", ex.Message, StringComparison.OrdinalIgnoreCase); + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + await using (var seed = conn.CreateCommand()) + { + seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1,'a'),(2,'b'),(3,'c')"; + await seed.ExecuteNonQueryAsync(); + } + + var toDelete = YdbList.Struct("Id") + .AddRow(1L) + .AddRow(3L); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + DELETE FROM {table} ON + SELECT * FROM AS_TABLE($to_delete); + """; + cmd.Parameters.Add(new YdbParameter("$to_delete", toDelete)); + await cmd.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT COUNT(*) FROM {table}"; + var count = Convert.ToInt32(await check.ExecuteScalarAsync()); + Assert.Equal(1, count); + + check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; + Assert.Equal("b", (string)(await check.ExecuteScalarAsync())!); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } } [Fact] - public void Struct_AddRow_WrongArity_Throws() + public async Task Insert_With_OptionalUtf8_And_Inference_NullThenNonNull() { - var rows = YdbList.Struct("Id", "Value"); - - var ex1 = Assert.Throws(() => rows.AddRow(1L)); - Assert.Contains("Expected 2 values", ex1.Message, StringComparison.OrdinalIgnoreCase); - - var ex2 = Assert.Throws(() => rows.AddRow(1L, "a", 123)); - Assert.Contains("Expected 2 values", ex2.Message, StringComparison.OrdinalIgnoreCase); + var table = $"ydb_list_nulls_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Name Utf8?, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var rows1 = YdbList.Struct( + ["Id", "Name"], + [YdbDbType.Int64, YdbDbType.Text]) + .AddRow(1L, "A") + .AddRow(2L, null); + + await using (var insert1 = conn.CreateCommand()) + { + insert1.CommandText = $""" + INSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; + insert1.Parameters.Add(new YdbParameter("$rows", rows1)); + await insert1.ExecuteNonQueryAsync(); + } + + var rows2 = YdbList.Struct( + ["Id", "Name"], + [YdbDbType.Int64, YdbDbType.Text]) + .AddRow(3L, null) + .AddRow(4L, "B"); + + await using (var insert2 = conn.CreateCommand()) + { + insert2.CommandText = $""" + INSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; + insert2.Parameters.Add(new YdbParameter("$rows", rows2)); + await insert2.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=3"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Name FROM {table} WHERE Id=4"; + Assert.Equal("B", (string)(await check.ExecuteScalarAsync())!); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } } [Fact] - public void Struct_SingleColumn_ListOfPrimitives() + public async Task Bulk_Load_With_List_Mode_Sanity() { - // $ids: List> - var ids = YdbList - .Struct("Id") - .AddRow(1L) - .AddRow(2L) - .AddRow(3L); - - var tv = new YdbParameter("$ids", ids).TypedValue; + var table = $"ydb_list_load_{Guid.NewGuid():N}"; + const int n = 5_000; - Assert.Equal(3, tv.Value.Items.Count); - - Assert.All(tv.Value.Items, row => + await using var conn = await CreateOpenConnectionAsync(); + try { - Assert.Single(row.Items); - Assert.Equal(Ydb.Value.ValueOneofCase.Int64Value, row.Items[0].ValueCase); - }); - - Assert.Equal(1L, tv.Value.Items[0].Items[0].Int64Value); - Assert.Equal(2L, tv.Value.Items[1].Items[0].Int64Value); - Assert.Equal(3L, tv.Value.Items[2].Items[0].Int64Value); - } - - [Fact] - public void Shape_For_Update_Delete_Insert_Upsert_Samples() - { - var toUpdate = YdbList.Struct("Id", "Value") - .AddRow(1L, "new-1") - .AddRow(2L, "new-2"); - var pUpdate = new YdbParameter("$to_update", toUpdate).TypedValue; - Assert.Equal(2, pUpdate.Value.Items.Count); - Assert.True(pUpdate.Value.Items.All(r => r.Items.Count == 2)); - - var toDelete = YdbList.Struct("Id") - .AddRow(1L) - .AddRow(3L); - var pDelete = new YdbParameter("$to_delete", toDelete).TypedValue; - Assert.Equal(2, pDelete.Value.Items.Count); - Assert.True(pDelete.Value.Items.All(r => r.Items.Count == 1)); - - const string yqlUpdate = """ - UPDATE my_table ON - SELECT * FROM $to_update; - """; - Assert.Contains("UPDATE my_table ON", yqlUpdate); - - const string yqlDelete = """ - DELETE my_table ON - SELECT * FROM $to_delete; - """; - Assert.Contains("DELETE my_table ON", yqlDelete); - - var insertRows = YdbList.Struct("Id", "Value").AddRow(10L, "v"); - var pInsert = new YdbParameter("$rows", insertRows).TypedValue; - Assert.Single(pInsert.Value.Items); - const string yqlInsert = """ - INSERT INTO my_table - SELECT * FROM $rows; - """; - Assert.Contains("INSERT INTO my_table", yqlInsert); - Assert.DoesNotContain(" ON", yqlInsert); - - var upsertRows = YdbList.Struct("Id", "Value").AddRow(10L, "vv"); - var pUpsert = new YdbParameter("$rows", upsertRows).TypedValue; - Assert.Single(pUpsert.Value.Items); - const string yqlUpsert = """ - UPSERT INTO my_table - SELECT * FROM $rows; - """; - Assert.Contains("UPSERT INTO my_table", yqlUpsert); - Assert.DoesNotContain(" ON", yqlUpsert); + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Name Utf8, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + for (var offset = 0; offset < n; offset += 1000) + { + var rows = YdbList.Struct("Id", "Name"); + for (var i = offset; i < Math.Min(n, offset + 1000); i++) + rows.AddRow((long)i, $"v{i}"); + + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + UPSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; + cmd.Parameters.Add(new YdbParameter("$rows", rows)); + await cmd.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT COUNT(*) FROM {table}"; + var count = Convert.ToInt32(await check.ExecuteScalarAsync()); + Assert.Equal(n, count); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } } } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index f86f7ad4..61222a28 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -173,7 +173,10 @@ INSERT INTO {tableName} ydbCommand.CommandText = $"SELECT NULL, t.* FROM {tableName} t"; var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); Assert.True(await ydbDataReader.ReadAsync()); - for (var i = 0; i < 21; i++) Assert.True(ydbDataReader.IsDBNull(i)); + for (var i = 0; i < 21; i++) + { + Assert.True(ydbDataReader.IsDBNull(i)); + } Assert.False(await ydbDataReader.ReadAsync()); @@ -311,7 +314,7 @@ public async Task BulkUpsertImporter_HappyPath_Add_Flush() createCmd.CommandText = $""" CREATE TABLE {tableName} ( Id Int32, - Name Text, + Name Utf8, PRIMARY KEY (Id) ) """; From 35118f61cf2423381dcec6a0629d43569e654015 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Tue, 19 Aug 2025 14:00:16 +0300 Subject: [PATCH 06/16] fix autoformat --- src/Ydb.Sdk/src/Ado/YdbParameter.cs | 3 +- src/Ydb.Sdk/src/Value/YdbList.cs | 6 +- .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 114 +++++++++--------- 3 files changed, 62 insertions(+), 61 deletions(-) diff --git a/src/Ydb.Sdk/src/Ado/YdbParameter.cs b/src/Ydb.Sdk/src/Ado/YdbParameter.cs index 8f6cbb80..62aab817 100644 --- a/src/Ydb.Sdk/src/Ado/YdbParameter.cs +++ b/src/Ydb.Sdk/src/Ado/YdbParameter.cs @@ -150,7 +150,8 @@ internal TypedValue TypedValue YdbDbType.Decimal when value is decimal decimalValue => Decimal(decimalValue), YdbDbType.Bytes => MakeBytes(value), YdbDbType.Json when value is string stringJsonValue => stringJsonValue.Json(), - YdbDbType.JsonDocument when value is string stringJsonDocumentValue => stringJsonDocumentValue.JsonDocument(), + YdbDbType.JsonDocument when value is string stringJsonDocumentValue => stringJsonDocumentValue + .JsonDocument(), YdbDbType.Uuid when value is Guid guidValue => guidValue.Uuid(), YdbDbType.Date => MakeDate(value), YdbDbType.DateTime when value is DateTime dateTimeValue => dateTimeValue.Datetime(), diff --git a/src/Ydb.Sdk/src/Value/YdbList.cs b/src/Ydb.Sdk/src/Value/YdbList.cs index a8a3ec06..ff814b24 100644 --- a/src/Ydb.Sdk/src/Value/YdbList.cs +++ b/src/Ydb.Sdk/src/Value/YdbList.cs @@ -74,9 +74,9 @@ public YdbList AddRow(params object?[] values) var tv = v switch { - YdbValue yv => yv.GetProto(), + YdbValue yv => yv.GetProto(), YdbParameter p => p.TypedValue, - _ => new YdbParameter { Value = v }.TypedValue + _ => new YdbParameter { Value = v }.TypedValue }; var t = tv.Type; @@ -143,7 +143,7 @@ internal TypedValue ToTypedValue() return new TypedValue { - Type = new Type { ListType = new ListType { Item = new Type { StructType = structType } } }, + Type = new Type { ListType = new ListType { Item = new Type { StructType = structType } } }, Value = new Ydb.Value { Items = { _rows } } }; } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs index 82ba1b1d..7ae0f551 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -16,12 +16,12 @@ public async Task Insert_With_YdbList_Works() await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } @@ -32,9 +32,9 @@ PRIMARY KEY (Id) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = $""" - INSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; + INSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; cmd.Parameters.Add(new YdbParameter("$rows", rows)); await cmd.ExecuteNonQueryAsync(); } @@ -64,12 +64,12 @@ public async Task Upsert_With_YdbList_Inserts_And_Updates() await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } @@ -86,9 +86,9 @@ PRIMARY KEY (Id) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = $""" - UPSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; + UPSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; cmd.Parameters.Add(new YdbParameter("$rows", rows)); await cmd.ExecuteNonQueryAsync(); } @@ -120,12 +120,12 @@ public async Task UpdateOn_With_YdbList_ChangesValues() await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } @@ -142,9 +142,9 @@ PRIMARY KEY (Id) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = $""" - UPDATE {table} ON - SELECT * FROM AS_TABLE($to_update); - """; + UPDATE {table} ON + SELECT * FROM AS_TABLE($to_update); + """; cmd.Parameters.Add(new YdbParameter("$to_update", toUpdate)); await cmd.ExecuteNonQueryAsync(); } @@ -176,12 +176,12 @@ public async Task DeleteOn_With_YdbList_RemovesRows() await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Value Utf8, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } @@ -198,9 +198,9 @@ PRIMARY KEY (Id) await using (var cmd = conn.CreateCommand()) { cmd.CommandText = $""" - DELETE FROM {table} ON - SELECT * FROM AS_TABLE($to_delete); - """; + DELETE FROM {table} ON + SELECT * FROM AS_TABLE($to_delete); + """; cmd.Parameters.Add(new YdbParameter("$to_delete", toDelete)); await cmd.ExecuteNonQueryAsync(); } @@ -233,12 +233,12 @@ public async Task Insert_With_OptionalUtf8_And_Inference_NullThenNonNull() await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Utf8?, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Name Utf8?, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } @@ -251,9 +251,9 @@ PRIMARY KEY (Id) await using (var insert1 = conn.CreateCommand()) { insert1.CommandText = $""" - INSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; + INSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; insert1.Parameters.Add(new YdbParameter("$rows", rows1)); await insert1.ExecuteNonQueryAsync(); } @@ -267,9 +267,9 @@ INSERT INTO {table} await using (var insert2 = conn.CreateCommand()) { insert2.CommandText = $""" - INSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; + INSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; insert2.Parameters.Add(new YdbParameter("$rows", rows2)); await insert2.ExecuteNonQueryAsync(); } @@ -306,12 +306,12 @@ public async Task Bulk_Load_With_List_Mode_Sanity() await using (var create = conn.CreateCommand()) { create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Utf8, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id Int64, + Name Utf8, + PRIMARY KEY (Id) + ) + """; await create.ExecuteNonQueryAsync(); } @@ -323,9 +323,9 @@ PRIMARY KEY (Id) await using var cmd = conn.CreateCommand(); cmd.CommandText = $""" - UPSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; + UPSERT INTO {table} + SELECT * FROM AS_TABLE($rows); + """; cmd.Parameters.Add(new YdbParameter("$rows", rows)); await cmd.ExecuteNonQueryAsync(); } From aab00359b9972dec131d744d3cf48b53cefa6d4d Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 25 Aug 2025 15:20:04 +0300 Subject: [PATCH 07/16] made an issue --- .../src/Ado/BulkUpsert/BulkUpsertImporter.cs | 46 +- .../src/Ado/BulkUpsert/IBulkUpsertImporter.cs | 20 +- src/Ydb.Sdk/src/Ado/YdbParameter.cs | 8 +- src/Ydb.Sdk/src/Value/YdbList.cs | 41 +- .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 528 ++++++++---------- .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 56 +- 6 files changed, 356 insertions(+), 343 deletions(-) diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs index 94a3cfdb..093fb7de 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs @@ -34,19 +34,22 @@ internal BulkUpsertImporter( /// /// Add a single row to the current BulkUpsert batch. /// - /// Column values in the same order as the configured columns. + /// Values in the same order as the configured columns. /// - /// Supported element types: , , (as-is); - /// other CLR values are converted via . + /// Supported per-cell types: , . + /// Other CLR values are converted via . + /// Passing as a column value is not supported (tables do not accept list-typed columns). + /// Use AddListAsync(YdbList) to append many rows from a list parameter. /// + /// Thrown when the number of values differs from the number of columns. + /// Thrown when a value cannot be mapped to a YDB type. /// /// /// // columns: ["Id", "Name"] /// await importer.AddRowAsync(1, "Alice"); + /// await importer.AddRowAsync(2, "Bob"); /// /// - /// When the number of values doesn't equal the number of columns. - /// When a value cannot be mapped to a YDB type. public async ValueTask AddRowAsync(params object[] values) { if (values.Length != _columns.Count) @@ -54,15 +57,17 @@ public async ValueTask AddRowAsync(params object[] values) var ydbValues = values.Select(v => v switch { - YdbValue ydbValue => ydbValue.GetProto(), + YdbValue ydbValue => ydbValue.GetProto(), YdbParameter param => param.TypedValue, - YdbList list => list.ToTypedValue(), + YdbList => throw new ArgumentException( + "YdbList cannot be used as a column value. Use AddListAsync(YdbList) to append multiple rows.", + nameof(values)), _ => new YdbParameter { Value = v }.TypedValue }).ToArray(); var protoStruct = new Ydb.Value(); - foreach (var value in ydbValues) - protoStruct.Items.Add(value.Value); + foreach (var tv in ydbValues) + protoStruct.Items.Add(tv.Value); var rowSize = protoStruct.CalculateSize(); @@ -79,24 +84,17 @@ public async ValueTask AddRowAsync(params object[] values) } /// - /// Add multiple rows from a single parameter. + /// Add multiple rows from a shaped as List<Struct<...>>. + /// Struct member names and order must exactly match the configured columns. /// - /// - /// Expects List<Struct<...>>; struct member names and order must exactly match the configured columns. - /// Example: columns=["Id","Name"]List<Struct<Id:Int64, Name:Utf8>>. - /// + /// Rows as List<Struct<...>> with the exact column names and order. + /// + /// Thrown when the struct column set, order, or count does not match the importer’s columns. + /// public async ValueTask AddListAsync(YdbList list) { var tv = list.ToTypedValue(); - if (tv.Type.TypeCase != Type.TypeOneofCase.ListType || - tv.Type.ListType.Item.TypeCase != Type.TypeOneofCase.StructType) - { - throw new ArgumentException( - "BulkUpsertImporter.AddListAsync expects a YdbList with a value like List>", - nameof(list)); - } - var incomingStruct = tv.Type.ListType.Item.StructType; if (incomingStruct.Members.Count != _columns.Count) @@ -130,6 +128,10 @@ public async ValueTask AddListAsync(YdbList list) /// /// Flush the current batch via BulkUpsert. No-op if the batch is empty. /// + /// + /// Uses the collected struct schema from the first added row (or the provided list) and sends + /// the accumulated rows in a single BulkUpsert request. + /// public async ValueTask FlushAsync() { if (_rows.Count == 0) diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs index a55e77d5..9e66ddb0 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/IBulkUpsertImporter.cs @@ -2,18 +2,30 @@ namespace Ydb.Sdk.Ado.BulkUpsert; +/// +/// Bulk upsert importer API: add rows and flush them to YDB in batches. +/// public interface IBulkUpsertImporter { - /// Add a single row to the batch. Values must match the importer column order. - /// Column values in the same order as the configured columns. + /// + /// Add a single row to the batch. Values must match the importer’s column order. + /// + /// Values in the same order as the configured columns. + /// Thrown when the number of values differs from the number of columns. ValueTask AddRowAsync(params object[] row); /// - /// Add many rows from (shape: List<Struct<...>>). + /// Add multiple rows from a shaped as List<Struct<...>>. /// Struct member names and order must exactly match the configured columns. /// + /// Rows as List<Struct<...>> with the exact column names and order. + /// + /// Thrown when the struct column set, order, or count does not match the importer’s columns. + /// ValueTask AddListAsync(YdbList list); - /// Flush the current batch via BulkUpsert (no-op if empty). + /// + /// Flush the current batch via BulkUpsert. No-op if the batch is empty. + /// ValueTask FlushAsync(); } diff --git a/src/Ydb.Sdk/src/Ado/YdbParameter.cs b/src/Ydb.Sdk/src/Ado/YdbParameter.cs index 62aab817..501c954f 100644 --- a/src/Ydb.Sdk/src/Ado/YdbParameter.cs +++ b/src/Ydb.Sdk/src/Ado/YdbParameter.cs @@ -149,9 +149,8 @@ internal TypedValue TypedValue YdbDbType.Double => MakeDouble(value), YdbDbType.Decimal when value is decimal decimalValue => Decimal(decimalValue), YdbDbType.Bytes => MakeBytes(value), - YdbDbType.Json when value is string stringJsonValue => stringJsonValue.Json(), - YdbDbType.JsonDocument when value is string stringJsonDocumentValue => stringJsonDocumentValue - .JsonDocument(), + YdbDbType.Json when value is string stringValue => stringValue.Json(), + YdbDbType.JsonDocument when value is string stringValue => stringValue.JsonDocument(), YdbDbType.Uuid when value is Guid guidValue => guidValue.Uuid(), YdbDbType.Date => MakeDate(value), YdbDbType.DateTime when value is DateTime dateTimeValue => dateTimeValue.Datetime(), @@ -290,7 +289,8 @@ private TypedValue NullTypedValue() } throw new InvalidOperationException( - "Writing value of 'null' is not supported without explicit mapping to the YdbDbType"); + "Writing value of 'null' is not supported without explicit mapping to the YdbDbType" + ); } private InvalidOperationException ValueTypeNotSupportedException => diff --git a/src/Ydb.Sdk/src/Value/YdbList.cs b/src/Ydb.Sdk/src/Value/YdbList.cs index ff814b24..2d93ae3f 100644 --- a/src/Ydb.Sdk/src/Value/YdbList.cs +++ b/src/Ydb.Sdk/src/Value/YdbList.cs @@ -5,16 +5,15 @@ namespace Ydb.Sdk.Value; /// -/// Struct-only builder for YDB List<Struct<...>>. -/// Works directly with protobuf: -/// - Each call to converts values into protobuf cells () and stores a row immediately. -/// - The struct schema () is derived from column type hints or from the first non-null sample per column. -/// - If a column has at least one null, its type becomes Optional<T>; individual null cells are encoded via . +/// Builder for YDB values shaped as List<Struct<...>>, working directly with protobuf. +/// Each call to converts input values into protobuf cells and stores the row. +/// The struct schema is derived from explicit type hints or from the first non-null sample per column. +/// If a column contains at least one null, its member type becomes Optional<T>. /// public sealed class YdbList { private readonly string[] _columns; - private readonly YdbDbType[]? _typeHints; + private readonly YdbDbType[] _typeHints; private readonly List _rows = new(); @@ -22,16 +21,18 @@ public sealed class YdbList private readonly bool[] _sawNull; /// - /// Create a struct-mode list with column names; types will be inferred from the - /// first non-null value per column (columns with any nulls become Optional<T>). + /// Create a struct-mode list with column names. Types will be inferred from the + /// first non-null value per column (columns with any nulls become Optional<T>). /// + /// Struct member names, in order. public static YdbList Struct(params string[] columns) => new(columns); /// - /// Create a struct-mode list with column names and explicit YDB type hints - /// (array length must match ). Columns with any nulls - /// will be emitted as Optional<hintedType>. + /// Create a struct-mode list with column names and explicit YDB type hints. The array length must match . + /// Columns that contain null are emitted as Optional<hintedType>. /// + /// Struct member names, in order. + /// YDB type hints for each column (same length as ). public static YdbList Struct(string[] columns, YdbDbType[] types) => new(columns, types); /// @@ -45,7 +46,7 @@ private YdbList(string[] columns, YdbDbType[]? types = null) throw new ArgumentException("Length of 'types' must match length of 'columns'.", nameof(types)); _columns = columns; - _typeHints = types; + _typeHints = types ?? Enumerable.Repeat(YdbDbType.Unspecified, columns.Length).ToArray(); _observedBaseTypes = new Type[_columns.Length]; _sawNull = new bool[_columns.Length]; } @@ -54,6 +55,8 @@ private YdbList(string[] columns, YdbDbType[]? types = null) /// Add a single positional row. The number of values must match the number of columns. /// Values are converted to protobuf cells and the row is stored immediately. /// + /// Row values in the same order as the declared columns. + /// Thrown when the number of values differs from the number of columns. public YdbList AddRow(params object?[] values) { if (values.Length != _columns.Length) @@ -92,13 +95,17 @@ public YdbList AddRow(params object?[] values) } /// - /// Convert to a YDB shaped as List<Struct<...>>. - /// Columns that observed null values are emitted as Optional<T>; - /// individual null cells are encoded via . + /// Convert the accumulated rows into a YDB with shape List<Struct<...>>. + /// Columns that observed null values are emitted as Optional<T>. /// + /// TypedValue representing List<Struct<...>> and the collected data rows. + /// + /// Thrown when the schema cannot be inferred (e.g., empty list without type hints, or a column has only nulls + /// and no explicit type hint). + /// internal TypedValue ToTypedValue() { - if (_rows.Count == 0 && (_typeHints is null || _typeHints.All(t => t == YdbDbType.Unspecified))) + if (_rows.Count == 0) throw new InvalidOperationException( "Cannot infer Struct schema from an empty list without explicit YdbDbType hints."); @@ -109,7 +116,7 @@ internal TypedValue ToTypedValue() { Type? baseType; - if (_typeHints is not null && _typeHints[i] != YdbDbType.Unspecified) + if (_typeHints[i] != YdbDbType.Unspecified) { baseType = new YdbParameter { YdbDbType = _typeHints[i], Value = DBNull.Value }.TypedValue.Type; diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs index 7ae0f551..d4a4c0b5 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -4,344 +4,292 @@ namespace Ydb.Sdk.Ado.Tests.Value; -public class YdbListIntegrationTests : TestBase +public class YdbListTests : TestBase { - [Fact] - public async Task Insert_With_YdbList_Works() + private static async Task WithTempTableAsync( + YdbConnection conn, + string namePrefix, + string columns, + Func body) { - var table = $"ydb_list_insert_{Guid.NewGuid():N}"; - await using var conn = await CreateOpenConnectionAsync(); + var table = $"{namePrefix}_{Guid.NewGuid():N}"; + var createSql = $"CREATE TABLE {table} (\n{columns}\n)"; + var dropSql = $"DROP TABLE {table}"; + + await using (var create = conn.CreateCommand()) + { + create.CommandText = createSql; + await create.ExecuteNonQueryAsync(); + } + try { - await using (var create = conn.CreateCommand()) - { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - var rows = YdbList.Struct("Id", "Value") - .AddRow(1L, "a") - .AddRow(2L, "b"); - - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $""" - INSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; - cmd.Parameters.Add(new YdbParameter("$rows", rows)); - await cmd.ExecuteNonQueryAsync(); - } - - await using (var check = conn.CreateCommand()) - { - check.CommandText = $"SELECT COUNT(*) FROM {table}"; - var count = Convert.ToInt32(await check.ExecuteScalarAsync()); - Assert.Equal(2, count); - } + await body(table); } finally { await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; + drop.CommandText = dropSql; await drop.ExecuteNonQueryAsync(); } } - [Fact] - public async Task Upsert_With_YdbList_Inserts_And_Updates() + private static async Task ExecAsTableAsync( + YdbConnection conn, + string verb, + string table, + string paramName, + YdbList rows) { - var table = $"ydb_list_upsert_{Guid.NewGuid():N}"; - await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var create = conn.CreateCommand()) - { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - await using (var seed = conn.CreateCommand()) - { - seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1, 'old')"; - await seed.ExecuteNonQueryAsync(); - } - - var rows = YdbList.Struct("Id", "Value") - .AddRow(1L, "new") - .AddRow(2L, "two"); - - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $""" - UPSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; - cmd.Parameters.Add(new YdbParameter("$rows", rows)); - await cmd.ExecuteNonQueryAsync(); - } - - await using (var check = conn.CreateCommand()) - { - check.CommandText = $"SELECT Value FROM {table} WHERE Id=1"; - Assert.Equal("new", (string)(await check.ExecuteScalarAsync())!); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"{verb} INTO {table}\nSELECT * FROM AS_TABLE({paramName});"; + cmd.Parameters.Add(new YdbParameter(paramName, rows)); + await cmd.ExecuteNonQueryAsync(); + } - check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; - Assert.Equal("two", (string)(await check.ExecuteScalarAsync())!); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } + private static async Task CountAsync(YdbConnection conn, string table) + { + await using var check = conn.CreateCommand(); + check.CommandText = $"SELECT COUNT(*) FROM {table}"; + return Convert.ToInt32(await check.ExecuteScalarAsync()); } [Fact] - public async Task UpdateOn_With_YdbList_ChangesValues() + public async Task Insert_With_YdbList_Works() { - var table = $"ydb_list_update_on_{Guid.NewGuid():N}"; await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var create = conn.CreateCommand()) + await WithTempTableAsync(conn, "ydb_list_insert", + """ + Id Int64, + Value Text, + PRIMARY KEY (Id) + """, + async table => { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - await using (var seed = conn.CreateCommand()) - { - seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1,'a'),(2,'b')"; - await seed.ExecuteNonQueryAsync(); - } - - var toUpdate = YdbList.Struct("Id", "Value") - .AddRow(1L, "x") - .AddRow(2L, "y"); + var rows = YdbList.Struct("Id", "Value") + .AddRow(1L, "a") + .AddRow(2L, "b"); - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $""" - UPDATE {table} ON - SELECT * FROM AS_TABLE($to_update); - """; - cmd.Parameters.Add(new YdbParameter("$to_update", toUpdate)); - await cmd.ExecuteNonQueryAsync(); - } - - await using (var check = conn.CreateCommand()) - { - check.CommandText = $"SELECT Value FROM {table} WHERE Id=1"; - Assert.Equal("x", (string)(await check.ExecuteScalarAsync())!); + await ExecAsTableAsync(conn, "INSERT", table, "$rows", rows); - check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; - Assert.Equal("y", (string)(await check.ExecuteScalarAsync())!); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } + Assert.Equal(2, await CountAsync(conn, table)); + }); } [Fact] - public async Task DeleteOn_With_YdbList_RemovesRows() + public async Task YdbList_WhenUpsertOperation_InsertAndUpdate() { - var table = $"ydb_list_delete_on_{Guid.NewGuid():N}"; await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var create = conn.CreateCommand()) - { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Value Utf8, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - await using (var seed = conn.CreateCommand()) - { - seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1,'a'),(2,'b'),(3,'c')"; - await seed.ExecuteNonQueryAsync(); - } - - var toDelete = YdbList.Struct("Id") - .AddRow(1L) - .AddRow(3L); - - await using (var cmd = conn.CreateCommand()) - { - cmd.CommandText = $""" - DELETE FROM {table} ON - SELECT * FROM AS_TABLE($to_delete); - """; - cmd.Parameters.Add(new YdbParameter("$to_delete", toDelete)); - await cmd.ExecuteNonQueryAsync(); - } - - await using (var check = conn.CreateCommand()) + await WithTempTableAsync(conn, "ydb_list_upsert", + """ + Id Int64, + Value Text, + PRIMARY KEY (Id) + """, + async table => { - check.CommandText = $"SELECT COUNT(*) FROM {table}"; - var count = Convert.ToInt32(await check.ExecuteScalarAsync()); - Assert.Equal(1, count); - - check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; - Assert.Equal("b", (string)(await check.ExecuteScalarAsync())!); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } + await using (var seed = conn.CreateCommand()) + { + seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1, 'old')"; + await seed.ExecuteNonQueryAsync(); + } + + var rows = YdbList.Struct("Id", "Value") + .AddRow(1L, "new") + .AddRow(2L, "two"); + + await ExecAsTableAsync(conn, "UPSERT", table, "$rows", rows); + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Value FROM {table} WHERE Id=1"; + Assert.Equal("new", (string)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; + Assert.Equal("two", (string)(await check.ExecuteScalarAsync())!); + } + }); } [Fact] - public async Task Insert_With_OptionalUtf8_And_Inference_NullThenNonNull() + public async Task UpdateOn_With_YdbList_ChangesValues() { - var table = $"ydb_list_nulls_{Guid.NewGuid():N}"; await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var create = conn.CreateCommand()) + await WithTempTableAsync(conn, "ydb_list_update_on", + """ + Id Int64, + Value Text, + PRIMARY KEY (Id) + """, + async table => { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Utf8?, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - var rows1 = YdbList.Struct( - ["Id", "Name"], - [YdbDbType.Int64, YdbDbType.Text]) - .AddRow(1L, "A") - .AddRow(2L, null); - - await using (var insert1 = conn.CreateCommand()) - { - insert1.CommandText = $""" - INSERT INTO {table} - SELECT * FROM AS_TABLE($rows); + await using (var seed = conn.CreateCommand()) + { + seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1,'a'),(2,'b')"; + await seed.ExecuteNonQueryAsync(); + } + + var toUpdate = YdbList.Struct("Id", "Value") + .AddRow(1L, "x") + .AddRow(2L, "y"); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + UPDATE {table} ON + SELECT * FROM AS_TABLE($to_update); """; - insert1.Parameters.Add(new YdbParameter("$rows", rows1)); - await insert1.ExecuteNonQueryAsync(); - } - - var rows2 = YdbList.Struct( - ["Id", "Name"], - [YdbDbType.Int64, YdbDbType.Text]) - .AddRow(3L, null) - .AddRow(4L, "B"); + cmd.Parameters.Add(new YdbParameter("$to_update", toUpdate)); + await cmd.ExecuteNonQueryAsync(); + } + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Value FROM {table} WHERE Id=1"; + Assert.Equal("x", (string)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; + Assert.Equal("y", (string)(await check.ExecuteScalarAsync())!); + } + }); + } - await using (var insert2 = conn.CreateCommand()) + [Fact] + public async Task DeleteOn_With_YdbList_RemovesRows() + { + await using var conn = await CreateOpenConnectionAsync(); + await WithTempTableAsync(conn, "ydb_list_delete_on", + """ + Id Int64, + Value Text, + PRIMARY KEY (Id) + """, + async table => { - insert2.CommandText = $""" - INSERT INTO {table} - SELECT * FROM AS_TABLE($rows); + await using (var seed = conn.CreateCommand()) + { + seed.CommandText = $"INSERT INTO {table} (Id, Value) VALUES (1,'a'),(2,'b'),(3,'c')"; + await seed.ExecuteNonQueryAsync(); + } + + var toDelete = YdbList.Struct("Id") + .AddRow(1L) + .AddRow(3L); + + await using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = $""" + DELETE FROM {table} ON + SELECT * FROM AS_TABLE($to_delete); """; - insert2.Parameters.Add(new YdbParameter("$rows", rows2)); - await insert2.ExecuteNonQueryAsync(); - } + cmd.Parameters.Add(new YdbParameter("$to_delete", toDelete)); + await cmd.ExecuteNonQueryAsync(); + } + + Assert.Equal(1, await CountAsync(conn, table)); + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Value FROM {table} WHERE Id=2"; + Assert.Equal("b", (string)(await check.ExecuteScalarAsync())!); + } + }); + } - await using (var check = conn.CreateCommand()) + [Fact] + public async Task Insert_With_OptionalText_And_Inference_NullThenNonNull() + { + await using var conn = await CreateOpenConnectionAsync(); + await WithTempTableAsync(conn, "ydb_list_nulls", + """ + Id Int64, + Name Text?, + PRIMARY KEY (Id) + """, + async table => { - check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; - Assert.True((bool)(await check.ExecuteScalarAsync())!); - - check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=3"; - Assert.True((bool)(await check.ExecuteScalarAsync())!); - - check.CommandText = $"SELECT Name FROM {table} WHERE Id=4"; - Assert.Equal("B", (string)(await check.ExecuteScalarAsync())!); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } + var rows1 = YdbList.Struct( + ["Id", "Name"], + [YdbDbType.Int64, YdbDbType.Text]) + .AddRow(1L, "A") + .AddRow(2L, null); + + await ExecAsTableAsync(conn, "INSERT", table, "$rows", rows1); + + var rows2 = YdbList.Struct( + ["Id", "Name"], + [YdbDbType.Int64, YdbDbType.Text]) + .AddRow(3L, null) + .AddRow(4L, "B"); + + await ExecAsTableAsync(conn, "INSERT", table, "$rows", rows2); + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=3"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Name FROM {table} WHERE Id=4"; + Assert.Equal("B", (string)(await check.ExecuteScalarAsync())!); + } + }); } [Fact] public async Task Bulk_Load_With_List_Mode_Sanity() { - var table = $"ydb_list_load_{Guid.NewGuid():N}"; const int n = 5_000; - await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var create = conn.CreateCommand()) - { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Utf8, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - for (var offset = 0; offset < n; offset += 1000) + await WithTempTableAsync(conn, "ydb_list_load", + """ + Id Int64, + Name Text, + PRIMARY KEY (Id) + """, + async table => { - var rows = YdbList.Struct("Id", "Name"); - for (var i = offset; i < Math.Min(n, offset + 1000); i++) - rows.AddRow((long)i, $"v{i}"); - - await using var cmd = conn.CreateCommand(); - cmd.CommandText = $""" - UPSERT INTO {table} - SELECT * FROM AS_TABLE($rows); - """; - cmd.Parameters.Add(new YdbParameter("$rows", rows)); - await cmd.ExecuteNonQueryAsync(); - } - - await using (var check = conn.CreateCommand()) + for (var offset = 0; offset < n; offset += 1000) + { + var rows = YdbList.Struct("Id", "Name"); + for (var i = offset; i < Math.Min(n, offset + 1000); i++) + rows.AddRow((long)i, $"v{i}"); + + await ExecAsTableAsync(conn, "UPSERT", table, "$rows", rows); + } + + Assert.Equal(n, await CountAsync(conn, table)); + }); + } + + [Fact] + public async Task YdbList_WhenAnyRowHasNull_InsertsIntoOptionalColumn() + { + await using var conn = await CreateOpenConnectionAsync(); + await WithTempTableAsync(conn, "ydb_list_optional", + """ + Id Int64, + Name Text?, + PRIMARY KEY (Id) + """, + async table => { - check.CommandText = $"SELECT COUNT(*) FROM {table}"; - var count = Convert.ToInt32(await check.ExecuteScalarAsync()); - Assert.Equal(n, count); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } + var rows = YdbList.Struct("Id", "Name") + .AddRow(1L, "X") + .AddRow(2L, null); + + await ExecAsTableAsync(conn, "UPSERT", table, "$rows", rows); + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Name FROM {table} WHERE Id=1"; + Assert.Equal("X", (string)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + } + }); } } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index 61222a28..cac2412f 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -314,7 +314,7 @@ public async Task BulkUpsertImporter_HappyPath_Add_Flush() createCmd.CommandText = $""" CREATE TABLE {tableName} ( Id Int32, - Name Utf8, + Name Text, PRIMARY KEY (Id) ) """; @@ -374,7 +374,7 @@ public async Task BulkUpsertImporter_ThrowsOnInvalidRowCount() createCmd.CommandText = $""" CREATE TABLE {tableName} ( Id Int32, - Name Utf8, + Name Text, PRIMARY KEY (Id) ) """; @@ -411,7 +411,7 @@ public async Task BulkUpsertImporter_MultipleImporters_Parallel() createCmd.CommandText = $""" CREATE TABLE {table} ( Id Int32, - Name Utf8, + Name Text, PRIMARY KEY (Id) ) """; @@ -492,7 +492,7 @@ public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() create.CommandText = $""" CREATE TABLE {table} ( Id Int64, - Name Utf8, + Name Text, PRIMARY KEY (Id) ) """; @@ -501,7 +501,7 @@ PRIMARY KEY (Id) var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); - // $rows: List> + // $rows: List> var rows = YdbList .Struct("Id", "Name") .AddRow(1L, "A") @@ -536,7 +536,7 @@ public async Task BulkUpsertImporter_AddListAsync_WrongStructColumns_ThrowsArgum create.CommandText = $""" CREATE TABLE {table} ( Id Int64, - Name Utf8, + Name Text, PRIMARY KEY (Id) ) """; @@ -560,4 +560,48 @@ PRIMARY KEY (Id) await drop.ExecuteNonQueryAsync(); } } + + [Fact] + public async Task BulkUpsertImporter_AddRowAsync_WhenLaterRowHasNull_AllowsNullValue() + { + var table = $"bulk_null_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int32, + Name Text?, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + + await importer.AddRowAsync(1, "A"); + + await importer.AddRowAsync(2, new YdbParameter { YdbDbType = YdbDbType.Text, Value = null }); + + await importer.FlushAsync(); + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Name FROM {table} WHERE Id=1"; + Assert.Equal("A", (string)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } } From b77d5be367f8bad680a462f990ecf4099a98ff62 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Fri, 29 Aug 2025 04:06:53 +0300 Subject: [PATCH 08/16] Simplify tests: move temp-table lifecycle to TestBase; --- src/Ydb.Sdk/CHANGELOG.md | 2 + .../test/Ydb.Sdk.Ado.Tests/TestBase.cs | 198 +++++++++ .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 388 +++--------------- 3 files changed, 266 insertions(+), 322 deletions(-) diff --git a/src/Ydb.Sdk/CHANGELOG.md b/src/Ydb.Sdk/CHANGELOG.md index 0cad865f..c8c02cc1 100644 --- a/src/Ydb.Sdk/CHANGELOG.md +++ b/src/Ydb.Sdk/CHANGELOG.md @@ -1,3 +1,5 @@ +- Test: Refactor TestBase: add UsingTempTableAsync / UsingTempTablesAsync to remove DDL/cleanup boilerplate; test names and logic unchanged. +- Feat Value: Add YdbList builder for List> (protobuf-based); works with IBulkUpsertImporter.AddListAsync. - Dev: LogLevel `Warning` -> `Debug` on DeleteSession has been `RpcException`. ## v0.22.0 diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs index 09eaad17..bcfe32b4 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs @@ -1,5 +1,7 @@ +using System.Data; using Xunit; using Ydb.Sdk.Ado.Tests.Utils; +using Ydb.Sdk.Ado.YdbType; namespace Ydb.Sdk.Ado.Tests; @@ -7,6 +9,8 @@ public abstract class TestBase : IAsyncLifetime { protected static string ConnectionString => TestUtils.ConnectionString; + protected static readonly string[] IdNameColumns = ["Id", "Name"]; + protected static YdbConnection CreateConnection() => new( new YdbConnectionStringBuilder(ConnectionString) { LoggerFactory = TestUtils.LoggerFactory } ); @@ -25,6 +29,200 @@ protected static async Task CreateOpenConnectionAsync() return connection; } + private static string CreateIdNameTableSql(string table, string idType = "Int32", bool nameNullable = false) => $""" + CREATE TABLE {table} ( + Id {idType}, + Name Text{(nameNullable ? "?" : "")}, + PRIMARY KEY (Id) + ) + """; + + private static string CreateAllTypesTableSql(string table) => @$" +CREATE TABLE {table} ( + id INT32, + bool_column BOOL, + bigint_column INT64, + smallint_column INT16, + tinyint_column INT8, + float_column FLOAT, + double_column DOUBLE, + decimal_column DECIMAL(22,9), + uint8_column UINT8, + uint16_column UINT16, + uint32_column UINT32, + uint64_column UINT64, + text_column TEXT, + binary_column BYTES, + json_column JSON, + jsondocument_column JSONDOCUMENT, + date_column DATE, + datetime_column DATETIME, + timestamp_column TIMESTAMP, + interval_column INTERVAL, + PRIMARY KEY (id) +) +"; + + private static async Task UsingTempTableCoreAsync( + YdbConnection conn, + Func createSqlFactory, + Func body, + Func? dropSqlFactory = null) + { + var table = $"tmp_{Guid.NewGuid():N}"; + + await using (var create = conn.CreateCommand()) + { + create.CommandText = createSqlFactory(table); + await create.ExecuteNonQueryAsync(); + } + + try + { + await body(conn, table); + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = (dropSqlFactory ?? (t => $"DROP TABLE {t}"))(table); + try { await drop.ExecuteNonQueryAsync(); } catch { /* ignore */ } + } + } + + private static async Task UsingTempTableAsync( + Func createSqlFactory, + Func body, + Func? dropSqlFactory = null) + { + await using var conn = await CreateOpenConnectionAsync(); + await UsingTempTableCoreAsync(conn, createSqlFactory, body, dropSqlFactory); + } + + protected static Task WithIdNameTableAsync( + Func body, + string idType = "Int32", + bool nameNullable = false) => + UsingTempTableAsync(t => CreateIdNameTableSql(t, idType, nameNullable), body); + + protected static Task WithAllTypesTableAsync(Func body) => + UsingTempTableAsync(CreateAllTypesTableSql, body); + + private static async Task UsingTempTablesCoreAsync( + YdbConnection conn, + int count, + Func createSqlsFactory, + Func body, + Func? dropSqlsFactory = null) + { + var names = Enumerable.Range(0, count).Select(_ => $"tmp_{Guid.NewGuid():N}").ToArray(); + + foreach (var sql in createSqlsFactory(names)) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + await cmd.ExecuteNonQueryAsync(); + } + + try + { + await body(conn, names); + } + finally + { + var drops = (dropSqlsFactory ?? (ts => ts.Select(t => $"DROP TABLE {t}").ToArray()))(names); + foreach (var sql in drops) + { + await using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + try { await cmd.ExecuteNonQueryAsync(); } catch { /* ignore */ } + } + } + } + + private static async Task UsingTempTablesAsync( + int count, + Func createSqlsFactory, + Func body, + Func? dropSqlsFactory = null) + { + await using var conn = await CreateOpenConnectionAsync(); + await UsingTempTablesCoreAsync(conn, count, createSqlsFactory, body, dropSqlsFactory); + } + + protected static Task WithTwoIdNameTablesAsync( + Func body, + string idType = "Int32", + bool nameNullable = false) => + UsingTempTablesAsync( + 2, + tables => tables.Select(t => CreateIdNameTableSql(t, idType, nameNullable)).ToArray(), + body + ); + + protected static async Task CountAsync(YdbConnection c, string table) + { + await using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT COUNT(*) FROM {table}"; + return Convert.ToInt32(await cmd.ExecuteScalarAsync()); + } + + protected static async Task> ReadNamesAsync(YdbConnection c, string table) + { + var names = new List(); + await using var cmd = c.CreateCommand(); + cmd.CommandText = $"SELECT Name FROM {table} ORDER BY Id"; + await using var r = await cmd.ExecuteReaderAsync(); + while (await r.ReadAsync()) names.Add(r.GetString(0)); + return names; + } + + protected static async Task ImportAsync(YdbConnection c, string table, params object[][] rows) + { + var importer = c.BeginBulkUpsertImport(table, IdNameColumns); + foreach (var row in rows) await importer.AddRowAsync(row); + await importer.FlushAsync(); + } + + protected static async Task ImportRangeAsync(YdbConnection c, string table, int n, string prefix) + { + var importer = c.BeginBulkUpsertImport(table, IdNameColumns); + foreach (var row in Enumerable.Range(0, n).Select(i => new object[] { i, $"{prefix}{i}" })) + await importer.AddRowAsync(row); + await importer.FlushAsync(); + } + + protected static void PrepareAllTypesInsert(YdbCommand cmd, string table) + { + cmd.CommandText = @$" +INSERT INTO {table} + (id, bool_column, bigint_column, smallint_column, tinyint_column, float_column, double_column, decimal_column, + uint8_column, uint16_column, uint32_column, uint64_column, text_column, binary_column, json_column, + jsondocument_column, date_column, datetime_column, timestamp_column, interval_column) VALUES +(@name1, @name2, @name3, @name4, @name5, @name6, @name7, @name8, @name9, @name10, @name11, @name12, @name13, @name14, + @name15, @name16, @name17, @name18, @name19, @name20); +"; + cmd.Parameters.Add(new YdbParameter { ParameterName = "name1", DbType = DbType.Int32, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name2", DbType = DbType.Boolean, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name3", DbType = DbType.Int64, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name4", DbType = DbType.Int16, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name5", DbType = DbType.SByte, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name6", DbType = DbType.Single, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name7", DbType = DbType.Double, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name8", DbType = DbType.Decimal, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name9", DbType = DbType.Byte, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name10", DbType = DbType.UInt16, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name11", DbType = DbType.UInt32, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name12", DbType = DbType.UInt64, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name13", DbType = DbType.String, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name14", DbType = DbType.Binary, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name15", YdbDbType = YdbDbType.Json }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name16", YdbDbType = YdbDbType.JsonDocument }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name17", DbType = DbType.Date, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name18", DbType = DbType.DateTime, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name19", DbType = DbType.DateTime2, Value = null }); + cmd.Parameters.Add(new YdbParameter { ParameterName = "name20", YdbDbType = YdbDbType.Interval }); + } + public async Task InitializeAsync() => await OnInitializeAsync().ConfigureAwait(false); public async Task DisposeAsync() => await OnDisposeAsync().ConfigureAwait(false); diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index cac2412f..fde87dc7 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -105,84 +105,20 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() } [Fact] - public async Task SetNulls_WhenTableAllTypes_SussesSet() - { - var ydbConnection = await CreateOpenConnectionAsync(); - var ydbCommand = ydbConnection.CreateCommand(); - var tableName = "AllTypes_" + Random.Shared.Next(); - - ydbCommand.CommandText = @$" -CREATE TABLE {tableName} ( - id INT32, - bool_column BOOL, - bigint_column INT64, - smallint_column INT16, - tinyint_column INT8, - float_column FLOAT, - double_column DOUBLE, - decimal_column DECIMAL(22,9), - uint8_column UINT8, - uint16_column UINT16, - uint32_column UINT32, - uint64_column UINT64, - text_column TEXT, - binary_column BYTES, - json_column JSON, - jsondocument_column JSONDOCUMENT, - date_column DATE, - datetime_column DATETIME, - timestamp_column TIMESTAMP, - interval_column INTERVAL, - PRIMARY KEY (id) -) -"; - await ydbCommand.ExecuteNonQueryAsync(); - ydbCommand.CommandText = @$" -INSERT INTO {tableName} - (id, bool_column, bigint_column, smallint_column, tinyint_column, float_column, double_column, decimal_column, - uint8_column, uint16_column, uint32_column, uint64_column, text_column, binary_column, json_column, - jsondocument_column, date_column, datetime_column, timestamp_column, interval_column) VALUES -(@name1, @name2, @name3, @name4, @name5, @name6, @name7, @name8, @name9, @name10, @name11, @name12, @name13, @name14, - @name15, @name16, @name17, @name18, @name19, @name20); -"; - - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name1", DbType = DbType.Int32, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name2", DbType = DbType.Boolean, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name3", DbType = DbType.Int64, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name4", DbType = DbType.Int16, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name5", DbType = DbType.SByte, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name6", DbType = DbType.Single, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name7", DbType = DbType.Double, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name8", DbType = DbType.Decimal, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name9", DbType = DbType.Byte, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name10", DbType = DbType.UInt16, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name11", DbType = DbType.UInt32, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name12", DbType = DbType.UInt64, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name13", DbType = DbType.String, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name14", DbType = DbType.Binary, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name15", YdbDbType = YdbDbType.Json }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name16", YdbDbType = YdbDbType.JsonDocument }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name17", DbType = DbType.Date, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter - { ParameterName = "name18", DbType = DbType.DateTime, Value = null }); - ydbCommand.Parameters.Add( - new YdbParameter { ParameterName = "name19", DbType = DbType.DateTime2, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name20", YdbDbType = YdbDbType.Interval }); - - await ydbCommand.ExecuteNonQueryAsync(); - ydbCommand.CommandText = $"SELECT NULL, t.* FROM {tableName} t"; - var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); - Assert.True(await ydbDataReader.ReadAsync()); - for (var i = 0; i < 21; i++) + public async Task SetNulls_WhenTableAllTypes_SussesSet() => + await WithAllTypesTableAsync(async (c, table) => { - Assert.True(ydbDataReader.IsDBNull(i)); - } - - Assert.False(await ydbDataReader.ReadAsync()); - - ydbCommand.CommandText = $"DROP TABLE {tableName}"; - await ydbCommand.ExecuteNonQueryAsync(); - } + var insert = c.CreateCommand(); + PrepareAllTypesInsert(insert, table); + await insert.ExecuteNonQueryAsync(); + + var select = c.CreateCommand(); + select.CommandText = $"SELECT NULL, t.* FROM {table} t"; + var r = await select.ExecuteReaderAsync(); + Assert.True(await r.ReadAsync()); + for (var i = 0; i < 21; i++) Assert.True(r.IsDBNull(i)); + Assert.False(await r.ReadAsync()); + }); [Fact] public async Task DisableDiscovery_WhenPropertyIsTrue_SimpleWorking() @@ -302,167 +238,49 @@ private List GenerateTasks(string connectionString) => Enumerable.Range(0, }).ToList(); [Fact] - public async Task BulkUpsertImporter_HappyPath_Add_Flush() - { - var tableName = $"BulkImporter_{Guid.NewGuid():N}"; - - await using var conn = await CreateOpenConnectionAsync(); - try - { - await using (var createCmd = conn.CreateCommand()) - { - createCmd.CommandText = $""" - CREATE TABLE {tableName} ( - Id Int32, - Name Text, - PRIMARY KEY (Id) - ) - """; - await createCmd.ExecuteNonQueryAsync(); - } - - var columns = new[] { "Id", "Name" }; - - var importer = conn.BeginBulkUpsertImport(tableName, columns); - - await importer.AddRowAsync(1, "Alice"); - await importer.AddRowAsync(2, "Bob"); - await importer.FlushAsync(); - - await using (var checkCmd = conn.CreateCommand()) - { - checkCmd.CommandText = $"SELECT COUNT(*) FROM {tableName}"; - var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync()); - Assert.Equal(2, count); - } - - importer = conn.BeginBulkUpsertImport(tableName, columns); - await importer.AddRowAsync(3, "Charlie"); - await importer.AddRowAsync(4, "Diana"); - await importer.FlushAsync(); - - await using (var checkCmd = conn.CreateCommand()) - { - checkCmd.CommandText = $"SELECT Name FROM {tableName} ORDER BY Id"; - var names = new List(); - await using var reader = await checkCmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - names.Add(reader.GetString(0)); - Assert.Contains("Alice", names); - Assert.Contains("Bob", names); - Assert.Contains("Charlie", names); - Assert.Contains("Diana", names); - } - } - finally + public async Task BulkUpsertImporter_HappyPath_Add_Flush() => + await WithIdNameTableAsync(async (c, table) => { - await using var dropCmd = conn.CreateCommand(); - dropCmd.CommandText = $"DROP TABLE {tableName}"; - await dropCmd.ExecuteNonQueryAsync(); - } - } + await ImportAsync(c, table, + [1, "Alice"], + [2, "Bob"]); + Assert.Equal(2, await CountAsync(c, table)); + + await ImportAsync(c, table, + [3, "Charlie"], + [4, "Diana"]); + + var names = await ReadNamesAsync(c, table); + Assert.Contains("Alice", names); + Assert.Contains("Bob", names); + Assert.Contains("Charlie", names); + Assert.Contains("Diana", names); + }); [Fact] - public async Task BulkUpsertImporter_ThrowsOnInvalidRowCount() - { - var tableName = $"BulkImporter_{Guid.NewGuid():N}"; - await using var conn = await CreateOpenConnectionAsync(); - try + public async Task BulkUpsertImporter_ThrowsOnInvalidRowCount() => + await WithIdNameTableAsync(async (c, table) => { - await using (var createCmd = conn.CreateCommand()) - { - createCmd.CommandText = $""" - CREATE TABLE {tableName} ( - Id Int32, - Name Text, - PRIMARY KEY (Id) - ) - """; - await createCmd.ExecuteNonQueryAsync(); - } - - var columns = new[] { "Id", "Name" }; - - var importer = conn.BeginBulkUpsertImport(tableName, columns); - + var importer = c.BeginBulkUpsertImport(table, IdNameColumns); await Assert.ThrowsAsync(async () => await importer.AddRowAsync(1)); await Assert.ThrowsAsync(async () => await importer.AddRowAsync(2)); - } - finally - { - await using var dropCmd = conn.CreateCommand(); - dropCmd.CommandText = $"DROP TABLE {tableName}"; - await dropCmd.ExecuteNonQueryAsync(); - } - } + }); [Fact] - public async Task BulkUpsertImporter_MultipleImporters_Parallel() - { - var table1 = $"BulkImporter_{Guid.NewGuid():N}_1"; - var table2 = $"BulkImporter_{Guid.NewGuid():N}_2"; - - var conn = await CreateOpenConnectionAsync(); - try + public async Task BulkUpsertImporter_MultipleImporters_Parallel() => + await WithTwoIdNameTablesAsync(async (c, tables) => { - foreach (var table in new[] { table1, table2 }) - { - await using var createCmd = conn.CreateCommand(); - createCmd.CommandText = $""" - CREATE TABLE {table} ( - Id Int32, - Name Text, - PRIMARY KEY (Id) - ) - """; - await createCmd.ExecuteNonQueryAsync(); - } - - var columns = new[] { "Id", "Name" }; + var t1 = tables[0]; + var t2 = tables[1]; await Task.WhenAll( - Task.Run(async () => - { - var importer = conn.BeginBulkUpsertImport(table1, columns); - var rows = Enumerable.Range(0, 20) - .Select(i => new object[] { i, $"A{i}" }) - .ToArray(); - foreach (var row in rows) - await importer.AddRowAsync(row); - await importer.FlushAsync(); - }), - Task.Run(async () => - { - var importer = conn.BeginBulkUpsertImport(table2, columns); - var rows = Enumerable.Range(0, 20) - .Select(i => new object[] { i, $"B{i}" }) - .ToArray(); - foreach (var row in rows) - await importer.AddRowAsync(row); - await importer.FlushAsync(); - }) + Task.Run(() => ImportRangeAsync(c, t1, 20, "A")), + Task.Run(() => ImportRangeAsync(c, t2, 20, "B")) ); - foreach (var table in new[] { table1, table2 }) - { - await using var checkCmd = conn.CreateCommand(); - checkCmd.CommandText = $"SELECT COUNT(*) FROM {table}"; - var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync()); - Assert.Equal(20, count); - } - } - finally - { - foreach (var table in new[] { table1, table2 }) - { - await using var dropCmd = conn.CreateCommand(); - dropCmd.CommandText = $"DROP TABLE {table}"; - await dropCmd.ExecuteNonQueryAsync(); - } - - await conn.DisposeAsync(); - } - } + Assert.Equal(20, await CountAsync(c, t1)); + Assert.Equal(20, await CountAsync(c, t2)); + }); [Fact] public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() @@ -470,9 +288,7 @@ public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() var tableName = $"Nonexistent_{Guid.NewGuid():N}"; await using var conn = await CreateOpenConnectionAsync(); - var columns = new[] { "Id", "Name" }; - - var importer = conn.BeginBulkUpsertImport(tableName, columns); + var importer = conn.BeginBulkUpsertImport(tableName, IdNameColumns); await importer.AddRowAsync(1, "NotExists"); @@ -480,26 +296,14 @@ public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() } [Fact] - public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() - { - var table = $"BulkImporter_List_{Guid.NewGuid():N}"; + public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() => + await WithIdNameTableAsync((c, table) => Task.CompletedTask, idType: "Int64"); - await using var conn = await CreateOpenConnectionAsync(); - try + [Fact] + public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows_Int64() => + await WithIdNameTableAsync(async (c, table) => { - await using (var create = conn.CreateCommand()) - { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Text, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + var importer = c.BeginBulkUpsertImport(table, IdNameColumns); // $rows: List> var rows = YdbList @@ -510,40 +314,14 @@ PRIMARY KEY (Id) await importer.AddListAsync(rows); await importer.FlushAsync(); - await using var check = conn.CreateCommand(); - check.CommandText = $"SELECT COUNT(*) FROM {table}"; - var count = Convert.ToInt32(await check.ExecuteScalarAsync()); - Assert.Equal(2, count); - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } - } + Assert.Equal(2, await CountAsync(c, table)); + }, idType: "Int64"); [Fact] - public async Task BulkUpsertImporter_AddListAsync_WrongStructColumns_ThrowsArgumentException() - { - var table = $"BulkImporter_List_{Guid.NewGuid():N}"; - - await using var conn = await CreateOpenConnectionAsync(); - try + public async Task BulkUpsertImporter_AddListAsync_WrongStructColumns_ThrowsArgumentException() => + await WithIdNameTableAsync(async (c, table) => { - await using (var create = conn.CreateCommand()) - { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int64, - Name Text, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + var importer = c.BeginBulkUpsertImport(table, IdNameColumns); var wrong = YdbList .Struct("Id", "Wrong") @@ -552,56 +330,22 @@ PRIMARY KEY (Id) var ex = await Assert.ThrowsAsync(() => importer.AddListAsync(wrong).AsTask()); Assert.Contains("mismatch", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("expected 'Name'", ex.Message, StringComparison.OrdinalIgnoreCase); - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } - } - + }, idType: "Int64"); + [Fact] - public async Task BulkUpsertImporter_AddRowAsync_WhenLaterRowHasNull_AllowsNullValue() - { - var table = $"bulk_null_{Guid.NewGuid():N}"; - await using var conn = await CreateOpenConnectionAsync(); - try + public async Task BulkUpsertImporter_AddRowAsync_WhenLaterRowHasNull_AllowsNullValue() => + await WithIdNameTableAsync(async (c, table) => { - await using (var create = conn.CreateCommand()) - { - create.CommandText = $""" - CREATE TABLE {table} ( - Id Int32, - Name Text?, - PRIMARY KEY (Id) - ) - """; - await create.ExecuteNonQueryAsync(); - } - - var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); - + var importer = c.BeginBulkUpsertImport(table, IdNameColumns); await importer.AddRowAsync(1, "A"); - await importer.AddRowAsync(2, new YdbParameter { YdbDbType = YdbDbType.Text, Value = null }); - await importer.FlushAsync(); - await using (var check = conn.CreateCommand()) - { - check.CommandText = $"SELECT Name FROM {table} WHERE Id=1"; - Assert.Equal("A", (string)(await check.ExecuteScalarAsync())!); + await using var check = c.CreateCommand(); + check.CommandText = $"SELECT Name FROM {table} WHERE Id=1"; + Assert.Equal("A", (string)(await check.ExecuteScalarAsync())!); - check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; - Assert.True((bool)(await check.ExecuteScalarAsync())!); - } - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = $"DROP TABLE {table}"; - await drop.ExecuteNonQueryAsync(); - } - } + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + }, nameNullable: true); } From 18e08de37ce047610011fd2ebeb873157191b982 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Fri, 29 Aug 2025 04:18:58 +0300 Subject: [PATCH 09/16] fix lint --- .../src/Ado/BulkUpsert/BulkUpsertImporter.cs | 2 +- .../test/Ydb.Sdk.Ado.Tests/TestBase.cs | 30 ++++++++++++++----- .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 2 +- .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 2 +- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs index 093fb7de..9f4dc509 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs @@ -57,7 +57,7 @@ public async ValueTask AddRowAsync(params object[] values) var ydbValues = values.Select(v => v switch { - YdbValue ydbValue => ydbValue.GetProto(), + YdbValue ydbValue => ydbValue.GetProto(), YdbParameter param => param.TypedValue, YdbList => throw new ArgumentException( "YdbList cannot be used as a column value. Use AddListAsync(YdbList) to append multiple rows.", diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs index bcfe32b4..2ccb2db8 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs @@ -30,12 +30,12 @@ protected static async Task CreateOpenConnectionAsync() } private static string CreateIdNameTableSql(string table, string idType = "Int32", bool nameNullable = false) => $""" - CREATE TABLE {table} ( - Id {idType}, - Name Text{(nameNullable ? "?" : "")}, - PRIMARY KEY (Id) - ) - """; + CREATE TABLE {table} ( + Id {idType}, + Name Text{(nameNullable ? "?" : "")}, + PRIMARY KEY (Id) + ) + """; private static string CreateAllTypesTableSql(string table) => @$" CREATE TABLE {table} ( @@ -85,7 +85,14 @@ private static async Task UsingTempTableCoreAsync( { await using var drop = conn.CreateCommand(); drop.CommandText = (dropSqlFactory ?? (t => $"DROP TABLE {t}"))(table); - try { await drop.ExecuteNonQueryAsync(); } catch { /* ignore */ } + try + { + await drop.ExecuteNonQueryAsync(); + } + catch + { + /* ignore */ + } } } @@ -134,7 +141,14 @@ private static async Task UsingTempTablesCoreAsync( { await using var cmd = conn.CreateCommand(); cmd.CommandText = sql; - try { await cmd.ExecuteNonQueryAsync(); } catch { /* ignore */ } + try + { + await cmd.ExecuteNonQueryAsync(); + } + catch + { + /* ignore */ + } } } } diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs index d4a4c0b5..f7759fd1 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -47,7 +47,7 @@ private static async Task ExecAsTableAsync( await cmd.ExecuteNonQueryAsync(); } - private static async Task CountAsync(YdbConnection conn, string table) + private new static async Task CountAsync(YdbConnection conn, string table) { await using var check = conn.CreateCommand(); check.CommandText = $"SELECT COUNT(*) FROM {table}"; diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index fde87dc7..447ce536 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -297,7 +297,7 @@ public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() [Fact] public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() => - await WithIdNameTableAsync((c, table) => Task.CompletedTask, idType: "Int64"); + await WithIdNameTableAsync((_, _) => Task.CompletedTask, idType: "Int64"); [Fact] public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows_Int64() => From ffa7ec799f34569d6ecdcaa7b97a66539b67f785 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Fri, 5 Sep 2025 16:55:49 +0300 Subject: [PATCH 10/16] Rollback Simplify tests --- .../test/Ydb.Sdk.Ado.Tests/TestBase.cs | 212 ------------ .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 327 ++++++++++++------ 2 files changed, 228 insertions(+), 311 deletions(-) diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs index 2ccb2db8..09eaad17 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/TestBase.cs @@ -1,7 +1,5 @@ -using System.Data; using Xunit; using Ydb.Sdk.Ado.Tests.Utils; -using Ydb.Sdk.Ado.YdbType; namespace Ydb.Sdk.Ado.Tests; @@ -9,8 +7,6 @@ public abstract class TestBase : IAsyncLifetime { protected static string ConnectionString => TestUtils.ConnectionString; - protected static readonly string[] IdNameColumns = ["Id", "Name"]; - protected static YdbConnection CreateConnection() => new( new YdbConnectionStringBuilder(ConnectionString) { LoggerFactory = TestUtils.LoggerFactory } ); @@ -29,214 +25,6 @@ protected static async Task CreateOpenConnectionAsync() return connection; } - private static string CreateIdNameTableSql(string table, string idType = "Int32", bool nameNullable = false) => $""" - CREATE TABLE {table} ( - Id {idType}, - Name Text{(nameNullable ? "?" : "")}, - PRIMARY KEY (Id) - ) - """; - - private static string CreateAllTypesTableSql(string table) => @$" -CREATE TABLE {table} ( - id INT32, - bool_column BOOL, - bigint_column INT64, - smallint_column INT16, - tinyint_column INT8, - float_column FLOAT, - double_column DOUBLE, - decimal_column DECIMAL(22,9), - uint8_column UINT8, - uint16_column UINT16, - uint32_column UINT32, - uint64_column UINT64, - text_column TEXT, - binary_column BYTES, - json_column JSON, - jsondocument_column JSONDOCUMENT, - date_column DATE, - datetime_column DATETIME, - timestamp_column TIMESTAMP, - interval_column INTERVAL, - PRIMARY KEY (id) -) -"; - - private static async Task UsingTempTableCoreAsync( - YdbConnection conn, - Func createSqlFactory, - Func body, - Func? dropSqlFactory = null) - { - var table = $"tmp_{Guid.NewGuid():N}"; - - await using (var create = conn.CreateCommand()) - { - create.CommandText = createSqlFactory(table); - await create.ExecuteNonQueryAsync(); - } - - try - { - await body(conn, table); - } - finally - { - await using var drop = conn.CreateCommand(); - drop.CommandText = (dropSqlFactory ?? (t => $"DROP TABLE {t}"))(table); - try - { - await drop.ExecuteNonQueryAsync(); - } - catch - { - /* ignore */ - } - } - } - - private static async Task UsingTempTableAsync( - Func createSqlFactory, - Func body, - Func? dropSqlFactory = null) - { - await using var conn = await CreateOpenConnectionAsync(); - await UsingTempTableCoreAsync(conn, createSqlFactory, body, dropSqlFactory); - } - - protected static Task WithIdNameTableAsync( - Func body, - string idType = "Int32", - bool nameNullable = false) => - UsingTempTableAsync(t => CreateIdNameTableSql(t, idType, nameNullable), body); - - protected static Task WithAllTypesTableAsync(Func body) => - UsingTempTableAsync(CreateAllTypesTableSql, body); - - private static async Task UsingTempTablesCoreAsync( - YdbConnection conn, - int count, - Func createSqlsFactory, - Func body, - Func? dropSqlsFactory = null) - { - var names = Enumerable.Range(0, count).Select(_ => $"tmp_{Guid.NewGuid():N}").ToArray(); - - foreach (var sql in createSqlsFactory(names)) - { - await using var cmd = conn.CreateCommand(); - cmd.CommandText = sql; - await cmd.ExecuteNonQueryAsync(); - } - - try - { - await body(conn, names); - } - finally - { - var drops = (dropSqlsFactory ?? (ts => ts.Select(t => $"DROP TABLE {t}").ToArray()))(names); - foreach (var sql in drops) - { - await using var cmd = conn.CreateCommand(); - cmd.CommandText = sql; - try - { - await cmd.ExecuteNonQueryAsync(); - } - catch - { - /* ignore */ - } - } - } - } - - private static async Task UsingTempTablesAsync( - int count, - Func createSqlsFactory, - Func body, - Func? dropSqlsFactory = null) - { - await using var conn = await CreateOpenConnectionAsync(); - await UsingTempTablesCoreAsync(conn, count, createSqlsFactory, body, dropSqlsFactory); - } - - protected static Task WithTwoIdNameTablesAsync( - Func body, - string idType = "Int32", - bool nameNullable = false) => - UsingTempTablesAsync( - 2, - tables => tables.Select(t => CreateIdNameTableSql(t, idType, nameNullable)).ToArray(), - body - ); - - protected static async Task CountAsync(YdbConnection c, string table) - { - await using var cmd = c.CreateCommand(); - cmd.CommandText = $"SELECT COUNT(*) FROM {table}"; - return Convert.ToInt32(await cmd.ExecuteScalarAsync()); - } - - protected static async Task> ReadNamesAsync(YdbConnection c, string table) - { - var names = new List(); - await using var cmd = c.CreateCommand(); - cmd.CommandText = $"SELECT Name FROM {table} ORDER BY Id"; - await using var r = await cmd.ExecuteReaderAsync(); - while (await r.ReadAsync()) names.Add(r.GetString(0)); - return names; - } - - protected static async Task ImportAsync(YdbConnection c, string table, params object[][] rows) - { - var importer = c.BeginBulkUpsertImport(table, IdNameColumns); - foreach (var row in rows) await importer.AddRowAsync(row); - await importer.FlushAsync(); - } - - protected static async Task ImportRangeAsync(YdbConnection c, string table, int n, string prefix) - { - var importer = c.BeginBulkUpsertImport(table, IdNameColumns); - foreach (var row in Enumerable.Range(0, n).Select(i => new object[] { i, $"{prefix}{i}" })) - await importer.AddRowAsync(row); - await importer.FlushAsync(); - } - - protected static void PrepareAllTypesInsert(YdbCommand cmd, string table) - { - cmd.CommandText = @$" -INSERT INTO {table} - (id, bool_column, bigint_column, smallint_column, tinyint_column, float_column, double_column, decimal_column, - uint8_column, uint16_column, uint32_column, uint64_column, text_column, binary_column, json_column, - jsondocument_column, date_column, datetime_column, timestamp_column, interval_column) VALUES -(@name1, @name2, @name3, @name4, @name5, @name6, @name7, @name8, @name9, @name10, @name11, @name12, @name13, @name14, - @name15, @name16, @name17, @name18, @name19, @name20); -"; - cmd.Parameters.Add(new YdbParameter { ParameterName = "name1", DbType = DbType.Int32, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name2", DbType = DbType.Boolean, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name3", DbType = DbType.Int64, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name4", DbType = DbType.Int16, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name5", DbType = DbType.SByte, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name6", DbType = DbType.Single, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name7", DbType = DbType.Double, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name8", DbType = DbType.Decimal, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name9", DbType = DbType.Byte, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name10", DbType = DbType.UInt16, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name11", DbType = DbType.UInt32, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name12", DbType = DbType.UInt64, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name13", DbType = DbType.String, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name14", DbType = DbType.Binary, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name15", YdbDbType = YdbDbType.Json }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name16", YdbDbType = YdbDbType.JsonDocument }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name17", DbType = DbType.Date, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name18", DbType = DbType.DateTime, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name19", DbType = DbType.DateTime2, Value = null }); - cmd.Parameters.Add(new YdbParameter { ParameterName = "name20", YdbDbType = YdbDbType.Interval }); - } - public async Task InitializeAsync() => await OnInitializeAsync().ConfigureAwait(false); public async Task DisposeAsync() => await OnDisposeAsync().ConfigureAwait(false); diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index 447ce536..89413315 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -2,7 +2,6 @@ using Xunit; using Ydb.Sdk.Ado.Tests.Utils; using Ydb.Sdk.Ado.YdbType; -using Ydb.Sdk.Value; namespace Ydb.Sdk.Ado.Tests; @@ -105,20 +104,84 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() } [Fact] - public async Task SetNulls_WhenTableAllTypes_SussesSet() => - await WithAllTypesTableAsync(async (c, table) => + public async Task SetNulls_WhenTableAllTypes_SussesSet() + { + var ydbConnection = await CreateOpenConnectionAsync(); + var ydbCommand = ydbConnection.CreateCommand(); + var tableName = "AllTypes_" + Random.Shared.Next(); + + ydbCommand.CommandText = @$" +CREATE TABLE {tableName} ( + id INT32, + bool_column BOOL, + bigint_column INT64, + smallint_column INT16, + tinyint_column INT8, + float_column FLOAT, + double_column DOUBLE, + decimal_column DECIMAL(22,9), + uint8_column UINT8, + uint16_column UINT16, + uint32_column UINT32, + uint64_column UINT64, + text_column TEXT, + binary_column BYTES, + json_column JSON, + jsondocument_column JSONDOCUMENT, + date_column DATE, + datetime_column DATETIME, + timestamp_column TIMESTAMP, + interval_column INTERVAL, + PRIMARY KEY (id) +) +"; + await ydbCommand.ExecuteNonQueryAsync(); + ydbCommand.CommandText = @$" +INSERT INTO {tableName} + (id, bool_column, bigint_column, smallint_column, tinyint_column, float_column, double_column, decimal_column, + uint8_column, uint16_column, uint32_column, uint64_column, text_column, binary_column, json_column, + jsondocument_column, date_column, datetime_column, timestamp_column, interval_column) VALUES +(@name1, @name2, @name3, @name4, @name5, @name6, @name7, @name8, @name9, @name10, @name11, @name12, @name13, @name14, + @name15, @name16, @name17, @name18, @name19, @name20); +"; + + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name1", DbType = DbType.Int32, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name2", DbType = DbType.Boolean, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name3", DbType = DbType.Int64, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name4", DbType = DbType.Int16, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name5", DbType = DbType.SByte, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name6", DbType = DbType.Single, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name7", DbType = DbType.Double, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name8", DbType = DbType.Decimal, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name9", DbType = DbType.Byte, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name10", DbType = DbType.UInt16, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name11", DbType = DbType.UInt32, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name12", DbType = DbType.UInt64, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name13", DbType = DbType.String, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name14", DbType = DbType.Binary, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name15", YdbDbType = YdbDbType.Json }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name16", YdbDbType = YdbDbType.JsonDocument }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name17", DbType = DbType.Date, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter + { ParameterName = "name18", DbType = DbType.DateTime, Value = null }); + ydbCommand.Parameters.Add( + new YdbParameter { ParameterName = "name19", DbType = DbType.DateTime2, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name20", YdbDbType = YdbDbType.Interval }); + + await ydbCommand.ExecuteNonQueryAsync(); + ydbCommand.CommandText = $"SELECT NULL, t.* FROM {tableName} t"; + var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); + Assert.True(await ydbDataReader.ReadAsync()); + for (var i = 0; i < 21; i++) { - var insert = c.CreateCommand(); - PrepareAllTypesInsert(insert, table); - await insert.ExecuteNonQueryAsync(); - - var select = c.CreateCommand(); - select.CommandText = $"SELECT NULL, t.* FROM {table} t"; - var r = await select.ExecuteReaderAsync(); - Assert.True(await r.ReadAsync()); - for (var i = 0; i < 21; i++) Assert.True(r.IsDBNull(i)); - Assert.False(await r.ReadAsync()); - }); + Assert.True(ydbDataReader.IsDBNull(i)); + } + + Assert.False(await ydbDataReader.ReadAsync()); + + ydbCommand.CommandText = $"DROP TABLE {tableName}"; + await ydbCommand.ExecuteNonQueryAsync(); + } [Fact] public async Task DisableDiscovery_WhenPropertyIsTrue_SimpleWorking() @@ -238,49 +301,167 @@ private List GenerateTasks(string connectionString) => Enumerable.Range(0, }).ToList(); [Fact] - public async Task BulkUpsertImporter_HappyPath_Add_Flush() => - await WithIdNameTableAsync(async (c, table) => + public async Task BulkUpsertImporter_HappyPath_Add_Flush() + { + var tableName = $"BulkImporter_{Guid.NewGuid():N}"; + + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var createCmd = conn.CreateCommand()) + { + createCmd.CommandText = $""" + CREATE TABLE {tableName} ( + Id Int32, + Name Text, + PRIMARY KEY (Id) + ) + """; + await createCmd.ExecuteNonQueryAsync(); + } + + var columns = new[] { "Id", "Name" }; + + var importer = conn.BeginBulkUpsertImport(tableName, columns); + + await importer.AddRowAsync(1, "Alice"); + await importer.AddRowAsync(2, "Bob"); + await importer.FlushAsync(); + + await using (var checkCmd = conn.CreateCommand()) + { + checkCmd.CommandText = $"SELECT COUNT(*) FROM {tableName}"; + var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync()); + Assert.Equal(2, count); + } + + importer = conn.BeginBulkUpsertImport(tableName, columns); + await importer.AddRowAsync(3, "Charlie"); + await importer.AddRowAsync(4, "Diana"); + await importer.FlushAsync(); + + await using (var checkCmd = conn.CreateCommand()) + { + checkCmd.CommandText = $"SELECT Name FROM {tableName} ORDER BY Id"; + var names = new List(); + await using var reader = await checkCmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + names.Add(reader.GetString(0)); + Assert.Contains("Alice", names); + Assert.Contains("Bob", names); + Assert.Contains("Charlie", names); + Assert.Contains("Diana", names); + } + } + finally { - await ImportAsync(c, table, - [1, "Alice"], - [2, "Bob"]); - Assert.Equal(2, await CountAsync(c, table)); - - await ImportAsync(c, table, - [3, "Charlie"], - [4, "Diana"]); - - var names = await ReadNamesAsync(c, table); - Assert.Contains("Alice", names); - Assert.Contains("Bob", names); - Assert.Contains("Charlie", names); - Assert.Contains("Diana", names); - }); + await using var dropCmd = conn.CreateCommand(); + dropCmd.CommandText = $"DROP TABLE {tableName}"; + await dropCmd.ExecuteNonQueryAsync(); + } + } [Fact] - public async Task BulkUpsertImporter_ThrowsOnInvalidRowCount() => - await WithIdNameTableAsync(async (c, table) => + public async Task BulkUpsertImporter_ThrowsOnInvalidRowCount() + { + var tableName = $"BulkImporter_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try { - var importer = c.BeginBulkUpsertImport(table, IdNameColumns); + await using (var createCmd = conn.CreateCommand()) + { + createCmd.CommandText = $""" + CREATE TABLE {tableName} ( + Id Int32, + Name Utf8, + PRIMARY KEY (Id) + ) + """; + await createCmd.ExecuteNonQueryAsync(); + } + + var columns = new[] { "Id", "Name" }; + + var importer = conn.BeginBulkUpsertImport(tableName, columns); + await Assert.ThrowsAsync(async () => await importer.AddRowAsync(1)); await Assert.ThrowsAsync(async () => await importer.AddRowAsync(2)); - }); + } + finally + { + await using var dropCmd = conn.CreateCommand(); + dropCmd.CommandText = $"DROP TABLE {tableName}"; + await dropCmd.ExecuteNonQueryAsync(); + } + } [Fact] - public async Task BulkUpsertImporter_MultipleImporters_Parallel() => - await WithTwoIdNameTablesAsync(async (c, tables) => + public async Task BulkUpsertImporter_MultipleImporters_Parallel() + { + var table1 = $"BulkImporter_{Guid.NewGuid():N}_1"; + var table2 = $"BulkImporter_{Guid.NewGuid():N}_2"; + + var conn = await CreateOpenConnectionAsync(); + try { - var t1 = tables[0]; - var t2 = tables[1]; + foreach (var table in new[] { table1, table2 }) + { + await using var createCmd = conn.CreateCommand(); + createCmd.CommandText = $""" + CREATE TABLE {table} ( + Id Int32, + Name Utf8, + PRIMARY KEY (Id) + ) + """; + await createCmd.ExecuteNonQueryAsync(); + } + + var columns = new[] { "Id", "Name" }; await Task.WhenAll( - Task.Run(() => ImportRangeAsync(c, t1, 20, "A")), - Task.Run(() => ImportRangeAsync(c, t2, 20, "B")) + Task.Run(async () => + { + var importer = conn.BeginBulkUpsertImport(table1, columns); + var rows = Enumerable.Range(0, 20) + .Select(i => new object[] { i, $"A{i}" }) + .ToArray(); + foreach (var row in rows) + await importer.AddRowAsync(row); + await importer.FlushAsync(); + }), + Task.Run(async () => + { + var importer = conn.BeginBulkUpsertImport(table2, columns); + var rows = Enumerable.Range(0, 20) + .Select(i => new object[] { i, $"B{i}" }) + .ToArray(); + foreach (var row in rows) + await importer.AddRowAsync(row); + await importer.FlushAsync(); + }) ); - Assert.Equal(20, await CountAsync(c, t1)); - Assert.Equal(20, await CountAsync(c, t2)); - }); + foreach (var table in new[] { table1, table2 }) + { + await using var checkCmd = conn.CreateCommand(); + checkCmd.CommandText = $"SELECT COUNT(*) FROM {table}"; + var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync()); + Assert.Equal(20, count); + } + } + finally + { + foreach (var table in new[] { table1, table2 }) + { + await using var dropCmd = conn.CreateCommand(); + dropCmd.CommandText = $"DROP TABLE {table}"; + await dropCmd.ExecuteNonQueryAsync(); + } + + await conn.DisposeAsync(); + } + } [Fact] public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() @@ -288,64 +469,12 @@ public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() var tableName = $"Nonexistent_{Guid.NewGuid():N}"; await using var conn = await CreateOpenConnectionAsync(); - var importer = conn.BeginBulkUpsertImport(tableName, IdNameColumns); + var columns = new[] { "Id", "Name" }; + + var importer = conn.BeginBulkUpsertImport(tableName, columns); await importer.AddRowAsync(1, "NotExists"); await Assert.ThrowsAsync(async () => { await importer.FlushAsync(); }); } - - [Fact] - public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() => - await WithIdNameTableAsync((_, _) => Task.CompletedTask, idType: "Int64"); - - [Fact] - public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows_Int64() => - await WithIdNameTableAsync(async (c, table) => - { - var importer = c.BeginBulkUpsertImport(table, IdNameColumns); - - // $rows: List> - var rows = YdbList - .Struct("Id", "Name") - .AddRow(1L, "A") - .AddRow(2L, "B"); - - await importer.AddListAsync(rows); - await importer.FlushAsync(); - - Assert.Equal(2, await CountAsync(c, table)); - }, idType: "Int64"); - - [Fact] - public async Task BulkUpsertImporter_AddListAsync_WrongStructColumns_ThrowsArgumentException() => - await WithIdNameTableAsync(async (c, table) => - { - var importer = c.BeginBulkUpsertImport(table, IdNameColumns); - - var wrong = YdbList - .Struct("Id", "Wrong") - .AddRow(1L, "A"); - - var ex = await Assert.ThrowsAsync(() => importer.AddListAsync(wrong).AsTask()); - Assert.Contains("mismatch", ex.Message, StringComparison.OrdinalIgnoreCase); - Assert.Contains("expected 'Name'", ex.Message, StringComparison.OrdinalIgnoreCase); - }, idType: "Int64"); - - [Fact] - public async Task BulkUpsertImporter_AddRowAsync_WhenLaterRowHasNull_AllowsNullValue() => - await WithIdNameTableAsync(async (c, table) => - { - var importer = c.BeginBulkUpsertImport(table, IdNameColumns); - await importer.AddRowAsync(1, "A"); - await importer.AddRowAsync(2, new YdbParameter { YdbDbType = YdbDbType.Text, Value = null }); - await importer.FlushAsync(); - - await using var check = c.CreateCommand(); - check.CommandText = $"SELECT Name FROM {table} WHERE Id=1"; - Assert.Equal("A", (string)(await check.ExecuteScalarAsync())!); - - check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; - Assert.True((bool)(await check.ExecuteScalarAsync())!); - }, nameNullable: true); } From e6fa60fe92d33ef05f08e9b9d95c88e4d8bf8987 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Fri, 5 Sep 2025 17:54:48 +0300 Subject: [PATCH 11/16] Restore 3 test files to aab00359 --- .../Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs | 2 +- .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 255 +++++++++++++----- 2 files changed, 194 insertions(+), 63 deletions(-) diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs index f7759fd1..d4a4c0b5 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/Value/YdbListTests.cs @@ -47,7 +47,7 @@ private static async Task ExecAsTableAsync( await cmd.ExecuteNonQueryAsync(); } - private new static async Task CountAsync(YdbConnection conn, string table) + private static async Task CountAsync(YdbConnection conn, string table) { await using var check = conn.CreateCommand(); check.CommandText = $"SELECT COUNT(*) FROM {table}"; diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index 3cf2d07a..cac2412f 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -2,6 +2,7 @@ using Xunit; using Ydb.Sdk.Ado.Tests.Utils; using Ydb.Sdk.Ado.YdbType; +using Ydb.Sdk.Value; namespace Ydb.Sdk.Ado.Tests; @@ -106,44 +107,44 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() [Fact] public async Task SetNulls_WhenTableAllTypes_SussesSet() { - await using var ydbConnection = await CreateOpenConnectionAsync(); + var ydbConnection = await CreateOpenConnectionAsync(); var ydbCommand = ydbConnection.CreateCommand(); var tableName = "AllTypes_" + Random.Shared.Next(); - ydbCommand.CommandText = $""" - CREATE TABLE {tableName} ( - id INT32, - bool_column BOOL, - bigint_column INT64, - smallint_column INT16, - tinyint_column INT8, - float_column FLOAT, - double_column DOUBLE, - decimal_column DECIMAL(22,9), - uint8_column UINT8, - uint16_column UINT16, - uint32_column UINT32, - uint64_column UINT64, - text_column TEXT, - binary_column BYTES, - json_column JSON, - jsondocument_column JSONDOCUMENT, - date_column DATE, - datetime_column DATETIME, - timestamp_column TIMESTAMP, - interval_column INTERVAL, - PRIMARY KEY (id) - ) - """; + ydbCommand.CommandText = @$" +CREATE TABLE {tableName} ( + id INT32, + bool_column BOOL, + bigint_column INT64, + smallint_column INT16, + tinyint_column INT8, + float_column FLOAT, + double_column DOUBLE, + decimal_column DECIMAL(22,9), + uint8_column UINT8, + uint16_column UINT16, + uint32_column UINT32, + uint64_column UINT64, + text_column TEXT, + binary_column BYTES, + json_column JSON, + jsondocument_column JSONDOCUMENT, + date_column DATE, + datetime_column DATETIME, + timestamp_column TIMESTAMP, + interval_column INTERVAL, + PRIMARY KEY (id) +) +"; await ydbCommand.ExecuteNonQueryAsync(); - ydbCommand.CommandText = - $""" - INSERT INTO {tableName} (id, bool_column, bigint_column, smallint_column, tinyint_column, float_column, - double_column, decimal_column, uint8_column, uint16_column, uint32_column, uint64_column, text_column, - binary_column, json_column, jsondocument_column, date_column, datetime_column, timestamp_column, - interval_column) VALUES (@name1, @name2, @name3, @name4, @name5, @name6, @name7, @name8, @name9, @name10, - @name11, @name12, @name13, @name14, @name15, @name16, @name17, @name18, @name19, @name20); - """; + ydbCommand.CommandText = @$" +INSERT INTO {tableName} + (id, bool_column, bigint_column, smallint_column, tinyint_column, float_column, double_column, decimal_column, + uint8_column, uint16_column, uint32_column, uint64_column, text_column, binary_column, json_column, + jsondocument_column, date_column, datetime_column, timestamp_column, interval_column) VALUES +(@name1, @name2, @name3, @name4, @name5, @name6, @name7, @name8, @name9, @name10, @name11, @name12, @name13, @name14, + @name15, @name16, @name17, @name18, @name19, @name20); +"; ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name1", DbType = DbType.Int32, Value = null }); ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name2", DbType = DbType.Boolean, Value = null }); @@ -198,7 +199,7 @@ public async Task OpenAsync_WhenCancelTokenIsCanceled_ThrowYdbException() await using var connection = CreateConnection(); connection.ConnectionString = ConnectionString + ";MinSessionPool=1"; using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); + cts.Cancel(); await Assert.ThrowsAnyAsync(async () => await connection.OpenAsync(cts.Token)); Assert.Equal(ConnectionState.Closed, connection.State); } @@ -210,7 +211,7 @@ public async Task YdbDataReader_WhenCancelTokenIsCanceled_ThrowYdbException() var command = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; SELECT 1;" }; var ydbDataReader = await command.ExecuteReaderAsync(); using var cts = new CancellationTokenSource(); - await cts.CancelAsync(); + cts.Cancel(); await ydbDataReader.ReadAsync(cts.Token); // first part in memory Assert.False(ydbDataReader.IsClosed); @@ -220,8 +221,6 @@ public async Task YdbDataReader_WhenCancelTokenIsCanceled_ThrowYdbException() (await Assert.ThrowsAsync(async () => await ydbDataReader.NextResultAsync(cts.Token))).Code); Assert.True(ydbDataReader.IsClosed); Assert.Equal(ConnectionState.Broken, connection.State); - // CLOSE OLD CONNECTION! (return to pool) - await connection.CloseAsync(); // ReSharper disable once MethodSupportsCancellation await connection.OpenAsync(); @@ -272,7 +271,7 @@ public async Task ExecuteReaderAsync_WhenExecutedYdbDataReaderThenCancelTokenIsC await ydbDataReader.ReadAsync(cts.Token); Assert.Equal(1, ydbDataReader.GetValue(0)); Assert.True(await ydbDataReader.NextResultAsync(cts.Token)); - await cts.CancelAsync(); + cts.Cancel(); await ydbDataReader.ReadAsync(cts.Token); Assert.Equal(1, ydbDataReader.GetValue(0)); // ReSharper disable once MethodSupportsCancellation @@ -307,10 +306,10 @@ public async Task BulkUpsertImporter_HappyPath_Add_Flush() { var tableName = $"BulkImporter_{Guid.NewGuid():N}"; - await using var ydbConnection = await CreateOpenConnectionAsync(); + await using var conn = await CreateOpenConnectionAsync(); try { - await using (var createCmd = ydbConnection.CreateCommand()) + await using (var createCmd = conn.CreateCommand()) { createCmd.CommandText = $""" CREATE TABLE {tableName} ( @@ -324,25 +323,25 @@ PRIMARY KEY (Id) var columns = new[] { "Id", "Name" }; - var importer = ydbConnection.BeginBulkUpsertImport(tableName, columns); + var importer = conn.BeginBulkUpsertImport(tableName, columns); await importer.AddRowAsync(1, "Alice"); await importer.AddRowAsync(2, "Bob"); await importer.FlushAsync(); - await using (var checkCmd = ydbConnection.CreateCommand()) + await using (var checkCmd = conn.CreateCommand()) { checkCmd.CommandText = $"SELECT COUNT(*) FROM {tableName}"; var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync()); Assert.Equal(2, count); } - importer = ydbConnection.BeginBulkUpsertImport(tableName, columns); + importer = conn.BeginBulkUpsertImport(tableName, columns); await importer.AddRowAsync(3, "Charlie"); await importer.AddRowAsync(4, "Diana"); await importer.FlushAsync(); - await using (var checkCmd = ydbConnection.CreateCommand()) + await using (var checkCmd = conn.CreateCommand()) { checkCmd.CommandText = $"SELECT Name FROM {tableName} ORDER BY Id"; var names = new List(); @@ -357,7 +356,7 @@ PRIMARY KEY (Id) } finally { - await using var dropCmd = ydbConnection.CreateCommand(); + await using var dropCmd = conn.CreateCommand(); dropCmd.CommandText = $"DROP TABLE {tableName}"; await dropCmd.ExecuteNonQueryAsync(); } @@ -367,25 +366,31 @@ PRIMARY KEY (Id) public async Task BulkUpsertImporter_ThrowsOnInvalidRowCount() { var tableName = $"BulkImporter_{Guid.NewGuid():N}"; - await using var ydbConnection = await CreateOpenConnectionAsync(); + await using var conn = await CreateOpenConnectionAsync(); try { - await using (var createCmd = ydbConnection.CreateCommand()) + await using (var createCmd = conn.CreateCommand()) { - createCmd.CommandText = $"CREATE TABLE {tableName} (Id Int32, Name Utf8, PRIMARY KEY (Id))"; + createCmd.CommandText = $""" + CREATE TABLE {tableName} ( + Id Int32, + Name Text, + PRIMARY KEY (Id) + ) + """; await createCmd.ExecuteNonQueryAsync(); } var columns = new[] { "Id", "Name" }; - var importer = ydbConnection.BeginBulkUpsertImport(tableName, columns); + var importer = conn.BeginBulkUpsertImport(tableName, columns); await Assert.ThrowsAsync(async () => await importer.AddRowAsync(1)); await Assert.ThrowsAsync(async () => await importer.AddRowAsync(2)); } finally { - await using var dropCmd = ydbConnection.CreateCommand(); + await using var dropCmd = conn.CreateCommand(); dropCmd.CommandText = $"DROP TABLE {tableName}"; await dropCmd.ExecuteNonQueryAsync(); } @@ -397,16 +402,16 @@ public async Task BulkUpsertImporter_MultipleImporters_Parallel() var table1 = $"BulkImporter_{Guid.NewGuid():N}_1"; var table2 = $"BulkImporter_{Guid.NewGuid():N}_2"; - await using var ydbConnection = await CreateOpenConnectionAsync(); + var conn = await CreateOpenConnectionAsync(); try { foreach (var table in new[] { table1, table2 }) { - await using var createCmd = ydbConnection.CreateCommand(); + await using var createCmd = conn.CreateCommand(); createCmd.CommandText = $""" CREATE TABLE {table} ( Id Int32, - Name Utf8, + Name Text, PRIMARY KEY (Id) ) """; @@ -418,8 +423,7 @@ PRIMARY KEY (Id) await Task.WhenAll( Task.Run(async () => { - // ReSharper disable once AccessToDisposedClosure - var importer = ydbConnection.BeginBulkUpsertImport(table1, columns); + var importer = conn.BeginBulkUpsertImport(table1, columns); var rows = Enumerable.Range(0, 20) .Select(i => new object[] { i, $"A{i}" }) .ToArray(); @@ -429,8 +433,7 @@ await Task.WhenAll( }), Task.Run(async () => { - // ReSharper disable once AccessToDisposedClosure - var importer = ydbConnection.BeginBulkUpsertImport(table2, columns); + var importer = conn.BeginBulkUpsertImport(table2, columns); var rows = Enumerable.Range(0, 20) .Select(i => new object[] { i, $"B{i}" }) .ToArray(); @@ -442,7 +445,7 @@ await Task.WhenAll( foreach (var table in new[] { table1, table2 }) { - await using var checkCmd = ydbConnection.CreateCommand(); + await using var checkCmd = conn.CreateCommand(); checkCmd.CommandText = $"SELECT COUNT(*) FROM {table}"; var count = Convert.ToInt32(await checkCmd.ExecuteScalarAsync()); Assert.Equal(20, count); @@ -452,10 +455,12 @@ await Task.WhenAll( { foreach (var table in new[] { table1, table2 }) { - await using var dropCmd = ydbConnection.CreateCommand(); + await using var dropCmd = conn.CreateCommand(); dropCmd.CommandText = $"DROP TABLE {table}"; await dropCmd.ExecuteNonQueryAsync(); } + + await conn.DisposeAsync(); } } @@ -463,14 +468,140 @@ await Task.WhenAll( public async Task BulkUpsertImporter_ThrowsOnNonexistentTable() { var tableName = $"Nonexistent_{Guid.NewGuid():N}"; - await using var ydbConnection = await CreateOpenConnectionAsync(); + await using var conn = await CreateOpenConnectionAsync(); var columns = new[] { "Id", "Name" }; - var importer = ydbConnection.BeginBulkUpsertImport(tableName, columns); + var importer = conn.BeginBulkUpsertImport(tableName, columns); await importer.AddRowAsync(1, "NotExists"); await Assert.ThrowsAsync(async () => { await importer.FlushAsync(); }); } + + [Fact] + public async Task BulkUpsertImporter_AddListAsync_HappyPath_InsertsRows() + { + var table = $"BulkImporter_List_{Guid.NewGuid():N}"; + + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Name Text, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + + // $rows: List> + var rows = YdbList + .Struct("Id", "Name") + .AddRow(1L, "A") + .AddRow(2L, "B"); + + await importer.AddListAsync(rows); + await importer.FlushAsync(); + + await using var check = conn.CreateCommand(); + check.CommandText = $"SELECT COUNT(*) FROM {table}"; + var count = Convert.ToInt32(await check.ExecuteScalarAsync()); + Assert.Equal(2, count); + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } + + [Fact] + public async Task BulkUpsertImporter_AddListAsync_WrongStructColumns_ThrowsArgumentException() + { + var table = $"BulkImporter_List_{Guid.NewGuid():N}"; + + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int64, + Name Text, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + + var wrong = YdbList + .Struct("Id", "Wrong") + .AddRow(1L, "A"); + + var ex = await Assert.ThrowsAsync(() => importer.AddListAsync(wrong).AsTask()); + Assert.Contains("mismatch", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("expected 'Name'", ex.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } + + [Fact] + public async Task BulkUpsertImporter_AddRowAsync_WhenLaterRowHasNull_AllowsNullValue() + { + var table = $"bulk_null_{Guid.NewGuid():N}"; + await using var conn = await CreateOpenConnectionAsync(); + try + { + await using (var create = conn.CreateCommand()) + { + create.CommandText = $""" + CREATE TABLE {table} ( + Id Int32, + Name Text?, + PRIMARY KEY (Id) + ) + """; + await create.ExecuteNonQueryAsync(); + } + + var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + + await importer.AddRowAsync(1, "A"); + + await importer.AddRowAsync(2, new YdbParameter { YdbDbType = YdbDbType.Text, Value = null }); + + await importer.FlushAsync(); + + await using (var check = conn.CreateCommand()) + { + check.CommandText = $"SELECT Name FROM {table} WHERE Id=1"; + Assert.Equal("A", (string)(await check.ExecuteScalarAsync())!); + + check.CommandText = $"SELECT Name IS NULL FROM {table} WHERE Id=2"; + Assert.True((bool)(await check.ExecuteScalarAsync())!); + } + } + finally + { + await using var drop = conn.CreateCommand(); + drop.CommandText = $"DROP TABLE {table}"; + await drop.ExecuteNonQueryAsync(); + } + } } From 4332b989f9149132dbb4750bc3afb148cd98abf6 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Fri, 5 Sep 2025 18:06:29 +0300 Subject: [PATCH 12/16] fix lint --- src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index cac2412f..5020056e 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -560,7 +560,7 @@ PRIMARY KEY (Id) await drop.ExecuteNonQueryAsync(); } } - + [Fact] public async Task BulkUpsertImporter_AddRowAsync_WhenLaterRowHasNull_AllowsNullValue() { From b9c64ec594d8961159312930263436e11ccb8dc0 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 8 Sep 2025 05:00:07 +0300 Subject: [PATCH 13/16] fix: ConfigureAwait(false) in AddRowAsync --- src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs index 9f4dc509..92e171f4 100644 --- a/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs +++ b/src/Ydb.Sdk/src/Ado/BulkUpsert/BulkUpsertImporter.cs @@ -72,7 +72,7 @@ public async ValueTask AddRowAsync(params object[] values) var rowSize = protoStruct.CalculateSize(); if (_currentBytes + rowSize > _maxBatchByteSize && _rows.Count > 0) - await FlushAsync(); + await FlushAsync().ConfigureAwait(false); _rows.Add(protoStruct); _currentBytes += rowSize; From 8cc8b73da2430cb46b546465dc84092a9700cb8d Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 8 Sep 2025 05:38:33 +0300 Subject: [PATCH 14/16] fix(tests): await using for connections/readers --- src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index 5020056e..afe44722 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -69,7 +69,7 @@ public async Task SetConnectionString_WhenConnectionIsOpen_ThrowException() [Fact] public async Task BeginTransaction_WhenConnectionIsClosed_ThrowException() { - var ydbConnection = await CreateOpenConnectionAsync(); + await using var ydbConnection = await CreateOpenConnectionAsync(); await ydbConnection.CloseAsync(); Assert.Equal("Connection is closed", Assert.Throws(() => ydbConnection.BeginTransaction()).Message); @@ -78,7 +78,7 @@ public async Task BeginTransaction_WhenConnectionIsClosed_ThrowException() [Fact] public async Task ExecuteScalar_WhenConnectionIsClosed_ThrowException() { - var ydbConnection = await CreateOpenConnectionAsync(); + await using var ydbConnection = await CreateOpenConnectionAsync(); await ydbConnection.CloseAsync(); var ydbCommand = ydbConnection.CreateCommand(); @@ -91,7 +91,7 @@ public async Task ExecuteScalar_WhenConnectionIsClosed_ThrowException() [Fact] public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() { - var ydbConnection = await CreateOpenConnectionAsync(); + await using var ydbConnection = await CreateOpenConnectionAsync(); var ydbCommand = ydbConnection.CreateCommand(); ydbCommand.CommandText = "SELECT 1; SELECT 2; SELECT 3;"; @@ -107,7 +107,7 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() [Fact] public async Task SetNulls_WhenTableAllTypes_SussesSet() { - var ydbConnection = await CreateOpenConnectionAsync(); + await using var ydbConnection = await CreateOpenConnectionAsync(); var ydbCommand = ydbConnection.CreateCommand(); var tableName = "AllTypes_" + Random.Shared.Next(); From f48288edaa835e195a9b11042a225723a33eaa27 Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 8 Sep 2025 06:21:33 +0300 Subject: [PATCH 15/16] fix(tests): await using YdbDataReader to avoid hangs --- .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 50 ++++++++++--------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index afe44722..983646a8 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -95,7 +95,7 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() var ydbCommand = ydbConnection.CreateCommand(); ydbCommand.CommandText = "SELECT 1; SELECT 2; SELECT 3;"; - var reader = await ydbCommand.ExecuteReaderAsync(); + await using var reader = await ydbCommand.ExecuteReaderAsync(); await reader.ReadAsync(); Assert.Equal(1, reader.GetInt32(0)); @@ -171,7 +171,7 @@ INSERT INTO {tableName} await ydbCommand.ExecuteNonQueryAsync(); ydbCommand.CommandText = $"SELECT NULL, t.* FROM {tableName} t"; - var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); + await using var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); Assert.True(await ydbDataReader.ReadAsync()); for (var i = 0; i < 21; i++) { @@ -209,33 +209,36 @@ public async Task YdbDataReader_WhenCancelTokenIsCanceled_ThrowYdbException() { await using var connection = await CreateOpenConnectionAsync(); var command = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; SELECT 1;" }; - var ydbDataReader = await command.ExecuteReaderAsync(); using var cts = new CancellationTokenSource(); cts.Cancel(); - await ydbDataReader.ReadAsync(cts.Token); // first part in memory - Assert.False(ydbDataReader.IsClosed); - Assert.Equal(1, ydbDataReader.GetValue(0)); - Assert.Equal(ConnectionState.Open, connection.State); - Assert.Equal(StatusCode.ClientTransportTimeout, - (await Assert.ThrowsAsync(async () => await ydbDataReader.NextResultAsync(cts.Token))).Code); - Assert.True(ydbDataReader.IsClosed); + await using (var ydbDataReader = await command.ExecuteReaderAsync()) + { + await ydbDataReader.ReadAsync(cts.Token); + Assert.False(ydbDataReader.IsClosed); + Assert.Equal(1, ydbDataReader.GetValue(0)); + Assert.Equal(ConnectionState.Open, connection.State); + Assert.Equal(StatusCode.ClientTransportTimeout, + (await Assert.ThrowsAsync(async () => await ydbDataReader.NextResultAsync(cts.Token))).Code); + Assert.True(ydbDataReader.IsClosed); + } Assert.Equal(ConnectionState.Broken, connection.State); - // ReSharper disable once MethodSupportsCancellation await connection.OpenAsync(); // ReSharper disable once MethodSupportsCancellation - ydbDataReader = await command.ExecuteReaderAsync(); - // ReSharper disable once MethodSupportsCancellation - await ydbDataReader.NextResultAsync(); - await ydbDataReader.ReadAsync(cts.Token); - Assert.False(ydbDataReader.IsClosed); - Assert.Equal(1, ydbDataReader.GetValue(0)); - Assert.False(ydbDataReader.IsClosed); - - Assert.Equal(StatusCode.ClientTransportTimeout, - (await Assert.ThrowsAsync(async () => await ydbDataReader.NextResultAsync(cts.Token))).Code); - Assert.True(ydbDataReader.IsClosed); + await using (var ydbDataReader = await command.ExecuteReaderAsync()) + { + // ReSharper disable once MethodSupportsCancellation + await ydbDataReader.NextResultAsync(); + await ydbDataReader.ReadAsync(cts.Token); + Assert.False(ydbDataReader.IsClosed); + Assert.Equal(1, ydbDataReader.GetValue(0)); + Assert.False(ydbDataReader.IsClosed); + + Assert.Equal(StatusCode.ClientTransportTimeout, + (await Assert.ThrowsAsync(async () => await ydbDataReader.NextResultAsync(cts.Token))).Code); + Assert.True(ydbDataReader.IsClosed); + } Assert.Equal(ConnectionState.Broken, connection.State); } @@ -266,7 +269,7 @@ public async Task ExecuteReaderAsync_WhenExecutedYdbDataReaderThenCancelTokenIsC await using var connection = await CreateOpenConnectionAsync(); var ydbCommand = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; " }; var cts = new CancellationTokenSource(); - var ydbDataReader = await ydbCommand.ExecuteReaderAsync(cts.Token); + await using var ydbDataReader = await ydbCommand.ExecuteReaderAsync(cts.Token); await ydbDataReader.ReadAsync(cts.Token); Assert.Equal(1, ydbDataReader.GetValue(0)); @@ -501,7 +504,6 @@ PRIMARY KEY (Id) var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); - // $rows: List> var rows = YdbList .Struct("Id", "Name") .AddRow(1L, "A") From a0292e9058dbbcb9d5c58f66662cdced03e20f2e Mon Sep 17 00:00:00 2001 From: bogdangalka Date: Mon, 8 Sep 2025 07:37:25 +0300 Subject: [PATCH 16/16] try fix --- .../Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs | 158 +++++++++--------- 1 file changed, 82 insertions(+), 76 deletions(-) diff --git a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs index 983646a8..78201f67 100644 --- a/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs +++ b/src/Ydb.Sdk/test/Ydb.Sdk.Ado.Tests/YdbConnectionTests.cs @@ -41,7 +41,7 @@ public async Task TlsSettings_WhenUseGrpcs_ReturnValidConnection() { await using var ydbConnection = new YdbConnection(_connectionStringTls); await ydbConnection.OpenAsync(); - var command = ydbConnection.CreateCommand(); + await using var command = ydbConnection.CreateCommand(); command.CommandText = Tables.CreateTables; await command.ExecuteNonQueryAsync(); command.CommandText = Tables.UpsertData; @@ -81,7 +81,7 @@ public async Task ExecuteScalar_WhenConnectionIsClosed_ThrowException() await using var ydbConnection = await CreateOpenConnectionAsync(); await ydbConnection.CloseAsync(); - var ydbCommand = ydbConnection.CreateCommand(); + await using var ydbCommand = ydbConnection.CreateCommand(); ydbCommand.CommandText = "SELECT 1"; Assert.Equal("Connection is closed", @@ -93,7 +93,7 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() { await using var ydbConnection = await CreateOpenConnectionAsync(); - var ydbCommand = ydbConnection.CreateCommand(); + await using var ydbCommand = ydbConnection.CreateCommand(); ydbCommand.CommandText = "SELECT 1; SELECT 2; SELECT 3;"; await using var reader = await ydbCommand.ExecuteReaderAsync(); await reader.ReadAsync(); @@ -108,10 +108,12 @@ public async Task ClosedYdbDataReader_WhenConnectionIsClosed_ThrowException() public async Task SetNulls_WhenTableAllTypes_SussesSet() { await using var ydbConnection = await CreateOpenConnectionAsync(); - var ydbCommand = ydbConnection.CreateCommand(); + await using var ydbCommand = ydbConnection.CreateCommand(); var tableName = "AllTypes_" + Random.Shared.Next(); - ydbCommand.CommandText = @$" + try + { + ydbCommand.CommandText = @$" CREATE TABLE {tableName} ( id INT32, bool_column BOOL, @@ -136,8 +138,8 @@ decimal_column DECIMAL(22,9), PRIMARY KEY (id) ) "; - await ydbCommand.ExecuteNonQueryAsync(); - ydbCommand.CommandText = @$" + await ydbCommand.ExecuteNonQueryAsync(); + ydbCommand.CommandText = @$" INSERT INTO {tableName} (id, bool_column, bigint_column, smallint_column, tinyint_column, float_column, double_column, decimal_column, uint8_column, uint16_column, uint32_column, uint64_column, text_column, binary_column, json_column, @@ -146,42 +148,45 @@ INSERT INTO {tableName} @name15, @name16, @name17, @name18, @name19, @name20); "; - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name1", DbType = DbType.Int32, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name2", DbType = DbType.Boolean, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name3", DbType = DbType.Int64, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name4", DbType = DbType.Int16, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name5", DbType = DbType.SByte, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name6", DbType = DbType.Single, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name7", DbType = DbType.Double, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name8", DbType = DbType.Decimal, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name9", DbType = DbType.Byte, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name10", DbType = DbType.UInt16, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name11", DbType = DbType.UInt32, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name12", DbType = DbType.UInt64, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name13", DbType = DbType.String, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name14", DbType = DbType.Binary, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name15", YdbDbType = YdbDbType.Json }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name16", YdbDbType = YdbDbType.JsonDocument }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name17", DbType = DbType.Date, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter - { ParameterName = "name18", DbType = DbType.DateTime, Value = null }); - ydbCommand.Parameters.Add( - new YdbParameter { ParameterName = "name19", DbType = DbType.DateTime2, Value = null }); - ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name20", YdbDbType = YdbDbType.Interval }); - - await ydbCommand.ExecuteNonQueryAsync(); - ydbCommand.CommandText = $"SELECT NULL, t.* FROM {tableName} t"; - await using var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); - Assert.True(await ydbDataReader.ReadAsync()); - for (var i = 0; i < 21; i++) + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name1", DbType = DbType.Int32, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name2", DbType = DbType.Boolean, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name3", DbType = DbType.Int64, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name4", DbType = DbType.Int16, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name5", DbType = DbType.SByte, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name6", DbType = DbType.Single, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name7", DbType = DbType.Double, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name8", DbType = DbType.Decimal, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name9", DbType = DbType.Byte, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name10", DbType = DbType.UInt16, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name11", DbType = DbType.UInt32, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name12", DbType = DbType.UInt64, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name13", DbType = DbType.String, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name14", DbType = DbType.Binary, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name15", YdbDbType = YdbDbType.Json }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name16", YdbDbType = YdbDbType.JsonDocument }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name17", DbType = DbType.Date, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter + { ParameterName = "name18", DbType = DbType.DateTime, Value = null }); + ydbCommand.Parameters.Add( + new YdbParameter { ParameterName = "name19", DbType = DbType.DateTime2, Value = null }); + ydbCommand.Parameters.Add(new YdbParameter { ParameterName = "name20", YdbDbType = YdbDbType.Interval }); + + await ydbCommand.ExecuteNonQueryAsync(); + ydbCommand.CommandText = $"SELECT NULL, t.* FROM {tableName} t"; + await using var ydbDataReader = await ydbCommand.ExecuteReaderAsync(); + Assert.True(await ydbDataReader.ReadAsync()); + for (var i = 0; i < 21; i++) + { + Assert.True(ydbDataReader.IsDBNull(i)); + } + + Assert.False(await ydbDataReader.ReadAsync()); + } + finally { - Assert.True(ydbDataReader.IsDBNull(i)); + ydbCommand.CommandText = $"DROP TABLE {tableName}"; + await ydbCommand.ExecuteNonQueryAsync(); } - - Assert.False(await ydbDataReader.ReadAsync()); - - ydbCommand.CommandText = $"DROP TABLE {tableName}"; - await ydbCommand.ExecuteNonQueryAsync(); } [Fact] @@ -208,37 +213,39 @@ public async Task OpenAsync_WhenCancelTokenIsCanceled_ThrowYdbException() public async Task YdbDataReader_WhenCancelTokenIsCanceled_ThrowYdbException() { await using var connection = await CreateOpenConnectionAsync(); - var command = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; SELECT 1;" }; - using var cts = new CancellationTokenSource(); - cts.Cancel(); - + await using var command = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; SELECT 1;" }; await using (var ydbDataReader = await command.ExecuteReaderAsync()) { - await ydbDataReader.ReadAsync(cts.Token); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await ydbDataReader.ReadAsync(cts.Token); // first part in memory Assert.False(ydbDataReader.IsClosed); Assert.Equal(1, ydbDataReader.GetValue(0)); Assert.Equal(ConnectionState.Open, connection.State); - Assert.Equal(StatusCode.ClientTransportTimeout, - (await Assert.ThrowsAsync(async () => await ydbDataReader.NextResultAsync(cts.Token))).Code); + + var nextTask = ydbDataReader.NextResultAsync(cts.Token); + cts.Cancel(); + var ex = await Assert.ThrowsAsync(async () => await nextTask); + Assert.Equal(StatusCode.ClientTransportTimeout, ex.Code); Assert.True(ydbDataReader.IsClosed); + Assert.Equal(ConnectionState.Broken, connection.State); } - Assert.Equal(ConnectionState.Broken, connection.State); await connection.OpenAsync(); - // ReSharper disable once MethodSupportsCancellation - await using (var ydbDataReader = await command.ExecuteReaderAsync()) - { - // ReSharper disable once MethodSupportsCancellation - await ydbDataReader.NextResultAsync(); - await ydbDataReader.ReadAsync(cts.Token); - Assert.False(ydbDataReader.IsClosed); - Assert.Equal(1, ydbDataReader.GetValue(0)); - Assert.False(ydbDataReader.IsClosed); - - Assert.Equal(StatusCode.ClientTransportTimeout, - (await Assert.ThrowsAsync(async () => await ydbDataReader.NextResultAsync(cts.Token))).Code); - Assert.True(ydbDataReader.IsClosed); - } + await using var ydbDataReader2 = await command.ExecuteReaderAsync(); + await ydbDataReader2.NextResultAsync(); + using var cts2 = new CancellationTokenSource(); + await ydbDataReader2.ReadAsync(cts2.Token); + Assert.False(ydbDataReader2.IsClosed); + Assert.Equal(1, ydbDataReader2.GetValue(0)); + Assert.False(ydbDataReader2.IsClosed); + + var nextTask2 = ydbDataReader2.NextResultAsync(cts2.Token); + cts2.Cancel(); + var ex2 = await Assert.ThrowsAsync(async () => await nextTask2); + Assert.Equal(StatusCode.ClientTransportTimeout, ex2.Code); + Assert.True(ydbDataReader2.IsClosed); Assert.Equal(ConnectionState.Broken, connection.State); } @@ -246,7 +253,7 @@ public async Task YdbDataReader_WhenCancelTokenIsCanceled_ThrowYdbException() public async Task ExecuteMethods_WhenCancelTokenIsCanceled_ConnectionIsBroken() { await using var connection = await CreateOpenConnectionAsync(); - var command = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; SELECT 1;" }; + await using var command = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; SELECT 1;" }; using var cts = new CancellationTokenSource(); await cts.CancelAsync(); @@ -267,7 +274,7 @@ await Assert.ThrowsAnyAsync(async () => public async Task ExecuteReaderAsync_WhenExecutedYdbDataReaderThenCancelTokenIsCanceled_ReturnValues() { await using var connection = await CreateOpenConnectionAsync(); - var ydbCommand = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; " }; + await using var ydbCommand = new YdbCommand(connection) { CommandText = "SELECT 1; SELECT 1; " }; var cts = new CancellationTokenSource(); await using var ydbDataReader = await ydbCommand.ExecuteReaderAsync(cts.Token); @@ -277,7 +284,6 @@ public async Task ExecuteReaderAsync_WhenExecutedYdbDataReaderThenCancelTokenIsC cts.Cancel(); await ydbDataReader.ReadAsync(cts.Token); Assert.Equal(1, ydbDataReader.GetValue(0)); - // ReSharper disable once MethodSupportsCancellation Assert.False(await ydbDataReader.NextResultAsync()); } @@ -297,7 +303,7 @@ private List GenerateTasks(string connectionString) => Enumerable.Range(0, } await using var connection = ydbConnection; - var command = connection.CreateCommand(); + using var command = connection.CreateCommand(); command.CommandText = "SELECT " + i; var scalar = (int)(await command.ExecuteScalarAsync())!; Assert.Equal(i, scalar); @@ -326,11 +332,10 @@ PRIMARY KEY (Id) var columns = new[] { "Id", "Name" }; - var importer = conn.BeginBulkUpsertImport(tableName, columns); - - await importer.AddRowAsync(1, "Alice"); - await importer.AddRowAsync(2, "Bob"); - await importer.FlushAsync(); + var importer1 = conn.BeginBulkUpsertImport(tableName, columns); + await importer1.AddRowAsync(1, "Alice"); + await importer1.AddRowAsync(2, "Bob"); + await importer1.FlushAsync(); await using (var checkCmd = conn.CreateCommand()) { @@ -339,10 +344,10 @@ PRIMARY KEY (Id) Assert.Equal(2, count); } - importer = conn.BeginBulkUpsertImport(tableName, columns); - await importer.AddRowAsync(3, "Charlie"); - await importer.AddRowAsync(4, "Diana"); - await importer.FlushAsync(); + var importer2 = conn.BeginBulkUpsertImport(tableName, columns); + await importer2.AddRowAsync(3, "Charlie"); + await importer2.AddRowAsync(4, "Diana"); + await importer2.FlushAsync(); await using (var checkCmd = conn.CreateCommand()) { @@ -504,6 +509,7 @@ PRIMARY KEY (Id) var importer = conn.BeginBulkUpsertImport(table, ["Id", "Name"]); + // $rows: List> var rows = YdbList .Struct("Id", "Name") .AddRow(1L, "A")