diff --git a/src/SqlMapAttributes.cs b/src/SqlMapAttributes.cs index 2e45526..4970401 100644 --- a/src/SqlMapAttributes.cs +++ b/src/SqlMapAttributes.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient.Server; using System.Reflection; @@ -1325,5 +1327,81 @@ public override void AppendReaderExpressions(Expression expProperty, IList + /// Maps a collection property to a SQL table-valued parameter for writes + /// and a second result set for reads. The element type must have its own + /// MapToSql* attributes for column mapping. + /// + public class MapToSqlTableValuedParameterAttribute : CollectionMapAttributeBase + { + public MapToSqlTableValuedParameterAttribute(string parameterName, string typeName) + : base(parameterName) + { + TypeName = typeName; + } + + public string TypeName { get; } + + public override void AppendCollectionInParameterExpressions( + List expressions, + ParameterExpression prmSqlPrms, + Expression expCollection, + Type elementType, + ParameterExpression expLogger, + ILogger logger) + { + // Build expression: + // SqlParameterCollectionExtensions.AddSqlTableValuedParameter(prms, paramName, collection, logger) + // Then set TypeName on the last added SqlParameter + + var miAddTvp = typeof(SqlParameterCollectionExtensions) + .GetMethods() + .First(m => m.Name == nameof(SqlParameterCollectionExtensions.AddSqlTableValuedParameter) + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 4) // (prms, paramName, values, logger) + .MakeGenericMethod(elementType); + + // A default (uninitialized) ImmutableArray throws InvalidOperationException when enumerated, + // so it is normalized to ImmutableArray.Empty before being treated as an empty collection. + var immutableArrayType = typeof(ImmutableArray<>).MakeGenericType(elementType); + if (expCollection.Type == immutableArrayType) + { + var isDefaultProperty = immutableArrayType.GetProperty(nameof(ImmutableArray.IsDefault)); + var emptyField = immutableArrayType.GetField(nameof(ImmutableArray.Empty)); + expCollection = Expression.Condition( + Expression.Property(expCollection, isDefaultProperty), + Expression.Field(null, emptyField), + expCollection); + } + + // A value-type collection (e.g. ImmutableArray) is not reference-assignable to the + // IEnumerable parameter, so Expression.Call rejects it without an explicit conversion. + var expEnumerable = Expression.Convert(expCollection, typeof(IEnumerable<>).MakeGenericType(elementType)); + + // Call: prms.AddSqlTableValuedParameter(parameterName, collection, logger) + expressions.Add(Expression.Call( + miAddTvp, + prmSqlPrms, + Expression.Constant(ParameterName), + expEnumerable, + expLogger)); + + // Set TypeName on the last parameter: ((SqlParameter)prms[prms.Count - 1]).TypeName = typeName + var expLastIndex = Expression.Subtract( + Expression.Property(prmSqlPrms, nameof(DbParameterCollection.Count)), + Expression.Constant(1)); + var expLastParam = Expression.Convert( + Expression.Property(prmSqlPrms, "Item", expLastIndex), + typeof(SqlParameter)); + expressions.Add(Expression.Assign( + Expression.Property(expLastParam, nameof(SqlParameter.TypeName)), + Expression.Constant(TypeName))); + } + } + #endregion } diff --git a/src/SqlParameterCollectionExtensions.cs b/src/SqlParameterCollectionExtensions.cs index 3969974..26acc90 100644 --- a/src/SqlParameterCollectionExtensions.cs +++ b/src/SqlParameterCollectionExtensions.cs @@ -1152,9 +1152,11 @@ public static DbParameterCollection AddSqlTableValuedParameter(this DbParameterC { tvp.Add(TvpMapper.ToTvpRecord(val, null, logger)); } + // An IEnumerable with zero elements cannot supply TDS metadata and is rejected by the driver; + // SQL Server treats a DbNull table-valued parameter as an empty table, so that is how zero rows are represented. var prm = new SqlParameter(NormalizeSqlParameterName(parameterName), SqlDbType.Structured) { - Value = tvp, + Value = tvp.Count > 0 ? (object)tvp : System.DBNull.Value, Direction = ParameterDirection.Input }; prms.Add(prm); @@ -1368,9 +1370,11 @@ public static DbParameterCollection AddSqlTableValuedParameter(val, columnList, logger)); } + // An IEnumerable with zero elements cannot supply TDS metadata and is rejected by the driver; + // SQL Server treats a DbNull table-valued parameter as an empty table, so that is how zero rows are represented. var prm = new SqlParameter(NormalizeSqlParameterName(parameterName), SqlDbType.Structured) { - Value = tvp, + Value = tvp.Count > 0 ? (object)tvp : System.DBNull.Value, Direction = ParameterDirection.Input }; prms.Add(prm); diff --git a/test/CollectionMapReadTests.cs b/test/CollectionMapReadTests.cs new file mode 100644 index 0000000..1c75560 --- /dev/null +++ b/test/CollectionMapReadTests.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Xunit; +using FluentAssertions; +using NSubstitute; + +namespace ArgentSea.Sql.Test +{ + internal class CollectionReadChild + { + [MapToSqlInt("ChildId", true)] + public int ChildId { get; set; } + + [MapToSqlNVarChar("ChildName", 100)] + public string ChildName { get; set; } + } + + internal class CollectionReadParentImmutableArray + { + [MapToSqlInt("Id", true)] + public int Id { get; set; } + + [MapToSqlNVarChar("Name", 255)] + public string Name { get; set; } + + [MapToSqlTableValuedParameter("@Children", "ChildTableType")] + public ImmutableArray Children { get; set; } + } + + internal class CollectionReadChildA + { + [MapToSqlInt("AId", true)] + public int AId { get; set; } + } + + internal class CollectionReadChildB + { + [MapToSqlInt("BId", true)] + public int BId { get; set; } + } + + internal class CollectionReadParent + { + [MapToSqlInt("Id", true)] + public int Id { get; set; } + + [MapToSqlNVarChar("Name", 255)] + public string Name { get; set; } + + [MapToSqlTableValuedParameter("@Children", "ChildTableType")] + public List Children { get; set; } + } + + internal class TwoCollectionsParent + { + [MapToSqlInt("Id", true)] + public int Id { get; set; } + + [MapToSqlTableValuedParameter("@First", "FirstTableType")] + public List FirstChildren { get; set; } + + [MapToSqlTableValuedParameter("@Second", "SecondTableType")] + public List SecondChildren { get; set; } + } + + internal class PlainNoCollectionModel + { + [MapToSqlInt("Id", true)] + public int Id { get; set; } + + [MapToSqlNVarChar("Name", 255)] + public string Name { get; set; } + } + + public class CollectionMapReadTests + { + [Fact] + public void ModelFromReaderWithCollectionsHandler_OneCollection_HydratesParentAndChildren() + { + // Arrange: result set 0 is the parent's single row, result set 1 is the collection's rows. + var rdr = Substitute.For(); + rdr.IsClosed.Returns(false); + rdr.HasRows.Returns(true); + rdr.NextResult().Returns(true); + rdr.Read().Returns(true, false, //parent + true, true, false // children + ); + rdr.FieldCount.Returns(2); + rdr.GetName(0).Returns("Id", "ChildId"); + rdr.GetName(1).Returns("Name", "ChildName"); + rdr.GetFieldValue(0).Returns(1, 10, 20); + rdr.GetFieldValue(1).Returns("Parent", "Ten", "Twenty"); + rdr.GetString(1).Returns("Parent", "Ten", "Twenty"); + rdr.IsDBNull(0).Returns(false); + rdr.IsDBNull(1).Returns(false); + + var dbLogger = new DebugLogger(); + + // Act + var result = Mapper.ModelFromReaderWithCollectionsHandler(new CollectionReadParent(), 0, "testSproc", null, rdr, null, "test connection", dbLogger); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(1, "that is the parent scalar value"); + result.Name.Should().Be("Parent"); + result.Children.Should().NotBeNull(); + result.Children.Count.Should().Be(2, "two child rows were returned by the second result set"); + result.Children[0].ChildId.Should().Be(10); + result.Children[0].ChildName.Should().Be("Ten"); + result.Children[1].ChildId.Should().Be(20); + result.Children[1].ChildName.Should().Be("Twenty"); + } + + [Fact] + public void ModelFromReaderWithCollectionsHandler_TwoCollections_HydrateInDeclarationOrder() + { + // Arrange: result set 0 is the parent row, result set 1 is FirstChildren (declared first), + // result set 2 is SecondChildren (declared second). + var rdr = Substitute.For(); + rdr.IsClosed.Returns(false); + rdr.HasRows.Returns(true); + rdr.NextResult().Returns(true, true); + rdr.Read().Returns(true, false, //parent + true, true, false, //FirstChildren + true, false // SecondChildren + ); + rdr.FieldCount.Returns(1); + rdr.GetName(0).Returns("Id", "AId", "BId"); + rdr.GetFieldValue(0).Returns(1, 100, 200, 300); + rdr.IsDBNull(0).Returns(false); + + var dbLogger = new DebugLogger(); + + // Act + var result = Mapper.ModelFromReaderWithCollectionsHandler(new TwoCollectionsParent(), 0, "testSproc", null, rdr, null, "test connection", dbLogger); + + // Assert + result.Should().NotBeNull(); + result.FirstChildren.Should().NotBeNull(); + result.FirstChildren.Count.Should().Be(2, "FirstChildren is declared before SecondChildren and reads the second result set"); + result.FirstChildren[0].AId.Should().Be(100); + result.FirstChildren[1].AId.Should().Be(200); + result.SecondChildren.Should().NotBeNull(); + result.SecondChildren.Count.Should().Be(1, "SecondChildren is declared last and reads the third result set"); + result.SecondChildren[0].BId.Should().Be(300); + } + + [Fact] + public void ModelFromReaderWithCollectionsHandler_EmptyChildResultSet_ProducesEmptyListNotNull() + { + // Arrange: the second result set exists (NextResult returns true) but has zero rows. + var rdr = Substitute.For(); + rdr.IsClosed.Returns(false); + rdr.NextResult().Returns(true); + rdr.HasRows.Returns(true, false); // parent has rows; the child result set does not + rdr.Read().Returns(true, false); //parent only + rdr.FieldCount.Returns(2); + rdr.GetName(0).Returns("Id"); + rdr.GetName(1).Returns("Name"); + rdr.GetFieldValue(0).Returns(1); + rdr.GetFieldValue(1).Returns("Parent"); + rdr.GetString(1).Returns("Parent"); + rdr.IsDBNull(0).Returns(false); + rdr.IsDBNull(1).Returns(false); + + var dbLogger = new DebugLogger(); + + // Act + var result = Mapper.ModelFromReaderWithCollectionsHandler(new CollectionReadParent(), 0, "testSproc", null, rdr, null, "test connection", dbLogger); + + // Assert + result.Should().NotBeNull(); + result.Children.Should().NotBeNull("a present-but-empty child result set must yield an empty collection, not null"); + result.Children.Should().BeEmpty(); + } + + [Fact] + public void ModelFromReaderWithCollectionsHandler_MissingChildResultSet_Throws() + { + // Arrange: the query returns only the parent's own result set - the second result set the model's + // Children property requires never arrives at all (NextResult returns false, not true-with-zero-rows). + var rdr = Substitute.For(); + rdr.IsClosed.Returns(false); + rdr.NextResult().Returns(false); + rdr.HasRows.Returns(true); + rdr.Read().Returns(true, false); //parent only + rdr.FieldCount.Returns(2); + rdr.GetName(0).Returns("Id"); + rdr.GetName(1).Returns("Name"); + rdr.GetFieldValue(0).Returns(1); + rdr.GetFieldValue(1).Returns("Parent"); + rdr.GetString(1).Returns("Parent"); + rdr.IsDBNull(0).Returns(false); + rdr.IsDBNull(1).Returns(false); + + var dbLogger = new DebugLogger(); + + // Act + Action act = () => Mapper.ModelFromReaderWithCollectionsHandler(new CollectionReadParent(), 0, "testSproc", null, rdr, null, "test connection", dbLogger); + + // Assert: a missing result set is a contract violation between the model and its query, so this must + // throw rather than silently produce an empty collection - which would look identical to "no children" + // and could cause a subsequent save to diff away real child rows. + act.Should().Throw() + .WithMessage("*CollectionReadParent*") + .Which.Message.Should().Contain("Children"); + } + + [Fact] + public void ModelFromReaderWithCollectionsHandler_MissingRecord_ReturnsNullWithoutThrowing() + { + // Arrange: the first (parent) result set has no rows at all - the grain/record does not exist. + var rdr = Substitute.For(); + rdr.IsClosed.Returns(false); + rdr.HasRows.Returns(false); + + var dbLogger = new DebugLogger(); + + // Act + var result = Mapper.ModelFromReaderWithCollectionsHandler(new CollectionReadParent(), 0, "testSproc", null, rdr, null, "test connection", dbLogger); + + // Assert + result.Should().BeNull("an empty first result set means the record does not exist"); + rdr.DidNotReceive().NextResult(); // must not attempt to hydrate collections against a missing record + } + + [Fact] + public void ModelFromReaderWithCollectionsHandler_ModelWithoutCollections_BehavesLikeScalarOnlyRead() + { + // Arrange: a model with no CollectionMap-attributed properties should read exactly one result set. + var rdr = Substitute.For(); + rdr.IsClosed.Returns(false); + rdr.HasRows.Returns(true); + rdr.Read().Returns(true, false); + rdr.FieldCount.Returns(2); + rdr.GetName(0).Returns("Id"); + rdr.GetName(1).Returns("Name"); + rdr.GetFieldValue(0).Returns(7); + rdr.GetFieldValue(1).Returns("Plain"); + rdr.GetString(1).Returns("Plain"); + rdr.IsDBNull(0).Returns(false); + rdr.IsDBNull(1).Returns(false); + + var dbLogger = new DebugLogger(); + + // Act + var result = Mapper.ModelFromReaderWithCollectionsHandler(new PlainNoCollectionModel(), 0, "testSproc", null, rdr, null, "test connection", dbLogger); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(7); + result.Name.Should().Be("Plain"); + rdr.DidNotReceive().NextResult(); // zero collection properties means zero additional result sets are read + } + + [Fact] + public void ModelFromReaderWithCollectionsHandler_ImmutableArrayCollectionProperty_HydratesParentAndChildren() + { + // Arrange: result set 0 is the parent's single row, result set 1 is the collection's rows. The + // collection property is ImmutableArray rather than List, exercising the same conversion the + // read side already applies via Mapper.ConvertListToCollectionPropertyType. + var rdr = Substitute.For(); + rdr.IsClosed.Returns(false); + rdr.HasRows.Returns(true); + rdr.NextResult().Returns(true); + rdr.Read().Returns(true, false, //parent + true, true, false // children + ); + rdr.FieldCount.Returns(2); + rdr.GetName(0).Returns("Id", "ChildId"); + rdr.GetName(1).Returns("Name", "ChildName"); + rdr.GetFieldValue(0).Returns(1, 10, 20); + rdr.GetFieldValue(1).Returns("Parent", "Ten", "Twenty"); + rdr.GetString(1).Returns("Parent", "Ten", "Twenty"); + rdr.IsDBNull(0).Returns(false); + rdr.IsDBNull(1).Returns(false); + + var dbLogger = new DebugLogger(); + + // Act + var result = Mapper.ModelFromReaderWithCollectionsHandler(new CollectionReadParentImmutableArray(), 0, "testSproc", null, rdr, null, "test connection", dbLogger); + + // Assert + result.Should().NotBeNull(); + result.Id.Should().Be(1, "that is the parent scalar value"); + result.Name.Should().Be("Parent"); + result.Children.IsDefault.Should().BeFalse("a hydrated collection must never be the default ImmutableArray"); + result.Children.Length.Should().Be(2, "two child rows were returned by the second result set"); + result.Children[0].ChildId.Should().Be(10); + result.Children[0].ChildName.Should().Be("Ten"); + result.Children[1].ChildId.Should().Be(20); + result.Children[1].ChildName.Should().Be("Twenty"); + } + + [Fact] + public void HasCollectionMapProperties_ReportsPresenceCorrectly() + { + Mapper.HasCollectionMapProperties(typeof(CollectionReadParent)).Should().BeTrue(); + Mapper.HasCollectionMapProperties(typeof(PlainNoCollectionModel)).Should().BeFalse(); + } + } +} diff --git a/test/CollectionMapWriteTests.cs b/test/CollectionMapWriteTests.cs new file mode 100644 index 0000000..3813c55 --- /dev/null +++ b/test/CollectionMapWriteTests.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Xunit; +using Microsoft.Data.SqlClient; +using Microsoft.Data.SqlClient.Server; +using FluentAssertions; + +namespace ArgentSea.Sql.Test +{ + internal class CollectionWriteChild + { + [MapToSqlInt("ChildId", true)] + public int ChildId { get; set; } + + [MapToSqlNVarChar("ChildName", 100)] + public string ChildName { get; set; } + } + + internal class CollectionWriteParent + { + [MapToSqlInt("Id", true)] + public int Id { get; set; } + + [MapToSqlNVarChar("Name", 255)] + public string Name { get; set; } + + [MapToSqlTableValuedParameter("@Children", "ChildTableType")] + public List Children { get; set; } + } + + internal class CollectionWriteParentImmutableArray + { + [MapToSqlInt("Id", true)] + public int Id { get; set; } + + [MapToSqlNVarChar("Name", 255)] + public string Name { get; set; } + + [MapToSqlTableValuedParameter("@Children", "ChildTableType")] + public ImmutableArray Children { get; set; } + } + + public class CollectionMapWriteTests + { + [Fact] + public void AddSqlTableValuedParameter_NonEmptyCollection_ProducesRecordList() + { + // Arrange + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var values = new List + { + new CollectionWriteChild { ChildId = 1, ChildName = "One" }, + new CollectionWriteChild { ChildId = 2, ChildName = "Two" }, + }; + + // Act + prms.AddSqlTableValuedParameter("@Children", values, dbLogger); + + // Assert + var prm = (SqlParameter)prms["@Children"]; + prm.SqlDbType.Should().Be(System.Data.SqlDbType.Structured, "table-valued parameters use the Structured data type"); + prm.Value.Should().BeAssignableTo>("a populated collection is sent as a list of records"); + ((IReadOnlyCollection)prm.Value).Count.Should().Be(2, "two child rows were provided"); + } + + [Fact] + public void AddSqlTableValuedParameter_EmptyCollection_ProducesDbNullNotAnEmptyEnumeration() + { + // Arrange + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var values = new List(); + + // Act + prms.AddSqlTableValuedParameter("@Children", values, dbLogger); + + // Assert + // SQL Server cannot infer TVP row metadata from an IEnumerable with zero elements, so an + // empty collection must be represented as DbNull (which SQL Server treats as an empty table), never as + // a non-null empty enumeration. + var prm = (SqlParameter)prms["@Children"]; + prm.SqlDbType.Should().Be(System.Data.SqlDbType.Structured); + prm.Value.Should().Be(DBNull.Value, "SQL Server represents a zero-row table-valued parameter as DbNull"); + } + + [Fact] + public void AddSqlTableValuedParameter_WithColumnList_EmptyCollection_ProducesDbNull() + { + // Arrange + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var values = new List(); + + // Act + prms.AddSqlTableValuedParameter("@Children", values, new List { "ChildId", "ChildName" }, dbLogger); + + // Assert + var prm = (SqlParameter)prms["@Children"]; + prm.Value.Should().Be(DBNull.Value, "the column-list overload must apply the same empty-collection rule as the default overload"); + } + + [Fact] + public void CreateInputParameters_NonEmptyCollectionProperty_SetsTypeNameAndRows() + { + // Arrange + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var model = new CollectionWriteParent + { + Id = 1, + Name = "Parent", + Children = new List + { + new CollectionWriteChild { ChildId = 10, ChildName = "Ten" }, + new CollectionWriteChild { ChildId = 20, ChildName = "Twenty" }, + new CollectionWriteChild { ChildId = 30, ChildName = "Thirty" }, + } + }; + + // Act + prms.CreateInputParameters(model, dbLogger); + + // Assert + var prm = (SqlParameter)prms["@Children"]; + prm.TypeName.Should().Be("ChildTableType", "the attribute declares this as the table type name"); + prm.Value.Should().BeAssignableTo>(); + ((IReadOnlyCollection)prm.Value).Count.Should().Be(3, "three child rows were provided"); + } + + [Fact] + public void CreateInputParameters_EmptyCollectionProperty_SendsDbNullTvp() + { + // Arrange + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var model = new CollectionWriteParent + { + Id = 1, + Name = "Parent", + Children = new List() + }; + + // Act + prms.CreateInputParameters(model, dbLogger); + + // Assert + var prm = (SqlParameter)prms["@Children"]; + prm.TypeName.Should().Be("ChildTableType"); + prm.Value.Should().Be(DBNull.Value, "an empty collection must still produce a valid (empty) table-valued parameter"); + } + + [Fact] + public void CreateInputParameters_NonEmptyImmutableArrayCollectionProperty_SetsTypeNameAndRows() + { + // Arrange + // ImmutableArray is a value type, so the collection property's static expression type is not + // reference-assignable to the IEnumerable parameter of AddSqlTableValuedParameter without + // an explicit conversion in the expression tree. + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var model = new CollectionWriteParentImmutableArray + { + Id = 1, + Name = "Parent", + Children = ImmutableArray.Create( + new CollectionWriteChild { ChildId = 10, ChildName = "Ten" }, + new CollectionWriteChild { ChildId = 20, ChildName = "Twenty" }, + new CollectionWriteChild { ChildId = 30, ChildName = "Thirty" }) + }; + + // Act + prms.CreateInputParameters(model, dbLogger); + + // Assert + var prm = (SqlParameter)prms["@Children"]; + prm.TypeName.Should().Be("ChildTableType", "the attribute declares this as the table type name"); + prm.Value.Should().BeAssignableTo>(); + ((IReadOnlyCollection)prm.Value).Count.Should().Be(3, "three child rows were provided"); + } + + [Fact] + public void CreateInputParameters_EmptyImmutableArrayCollectionProperty_SendsDbNullTvp() + { + // Arrange + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var model = new CollectionWriteParentImmutableArray + { + Id = 1, + Name = "Parent", + Children = ImmutableArray.Empty + }; + + // Act + prms.CreateInputParameters(model, dbLogger); + + // Assert + var prm = (SqlParameter)prms["@Children"]; + prm.TypeName.Should().Be("ChildTableType"); + prm.Value.Should().Be(DBNull.Value, "an empty collection must still produce a valid (empty) table-valued parameter"); + } + + [Fact] + public void CreateInputParameters_DefaultImmutableArrayCollectionProperty_DoesNotThrowAndSendsDbNullTvp() + { + // Arrange + // A default (uninitialized) ImmutableArray - as opposed to ImmutableArray.Empty - throws + // InvalidOperationException when enumerated. Since the model never distinguishes "never assigned" + // from "assigned as empty", it must be treated the same as an empty collection rather than throwing. + var dbLogger = new DebugLogger(); + var prms = new ParameterCollection(); + var model = new CollectionWriteParentImmutableArray + { + Id = 1, + Name = "Parent", + Children = default + }; + model.Children.IsDefault.Should().BeTrue("the test must exercise the uninitialized struct, not ImmutableArray.Empty"); + + // Act + Action act = () => prms.CreateInputParameters(model, dbLogger); + + // Assert + act.Should().NotThrow("a default ImmutableArray must be treated as an empty collection, not enumerated directly"); + var prm = (SqlParameter)prms["@Children"]; + prm.TypeName.Should().Be("ChildTableType"); + prm.Value.Should().Be(DBNull.Value, "a default collection must still produce a valid (empty) table-valued parameter"); + } + } +}