From 329eef82cdd68d1fa65bd883212bfcaaba7a325f Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 5 Sep 2024 20:44:45 -0400 Subject: [PATCH] Create DataTableBuilder (#4608) * Add DataTableBuilder Using Expressions * Format * Unwrap Underlying Enum Type * Formatting --- bitwarden-server.sln | 9 +- src/Infrastructure.Dapper/DapperHelpers.cs | 165 ++++++++++++++-- .../Tools/Helpers/SendHelpers.cs | 43 ++--- .../DataTableBuilderTests.cs | 180 ++++++++++++++++++ .../GlobalUsings.cs | 1 + .../Infrastructure.Dapper.Test.csproj | 29 +++ 6 files changed, 386 insertions(+), 41 deletions(-) create mode 100644 test/Infrastructure.Dapper.Test/DataTableBuilderTests.cs create mode 100644 test/Infrastructure.Dapper.Test/GlobalUsings.cs create mode 100644 test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj diff --git a/bitwarden-server.sln b/bitwarden-server.sln index 154ffe361..ad643c43c 100644 --- a/bitwarden-server.sln +++ b/bitwarden-server.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29102.190 @@ -124,6 +124,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventsProcessor.Test", "tes EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\Notifications.Test\Notifications.Test.csproj", "{90D85D8F-5577-4570-A96E-5A2E185F0F6F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -308,6 +310,10 @@ Global {90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Release|Any CPU.Build.0 = Release|Any CPU + {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -357,6 +363,7 @@ Global {916AFD8C-30AF-49B6-A5C9-28CA1B5D9298} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} + {4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} diff --git a/src/Infrastructure.Dapper/DapperHelpers.cs b/src/Infrastructure.Dapper/DapperHelpers.cs index 865cad39e..c25661244 100644 --- a/src/Infrastructure.Dapper/DapperHelpers.cs +++ b/src/Infrastructure.Dapper/DapperHelpers.cs @@ -1,4 +1,8 @@ -using System.Data; +using System.Collections.Frozen; +using System.Data; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; using Bit.Core.Entities; using Bit.Core.Models.Data; using Dapper; @@ -7,8 +11,148 @@ using Dapper; namespace Bit.Infrastructure.Dapper; +/// +/// Provides a way to build a based on the properties of . +/// +/// +public class DataTableBuilder +{ + private readonly FrozenDictionary Getter)> _columnBuilders; + + /// + /// Creates a new instance of . + /// + /// + /// + /// new DataTableBuilder( + /// [ + /// i => i.Id, + /// i => i.Name, + /// ] + /// ); + /// + /// + /// + /// + public DataTableBuilder(Expression>[] columnExpressions) + { + ArgumentNullException.ThrowIfNull(columnExpressions); + ArgumentOutOfRangeException.ThrowIfZero(columnExpressions.Length); + + var columnBuilders = new Dictionary)>(columnExpressions.Length); + + for (var i = 0; i < columnExpressions.Length; i++) + { + var columnExpression = columnExpressions[i]; + + if (!TryGetPropertyInfo(columnExpression, out var propertyInfo)) + { + throw new ArgumentException($"Could not determine the property info from the given expression '{columnExpression}'."); + } + + // Unwrap possible Nullable + var type = Nullable.GetUnderlyingType(propertyInfo.PropertyType) ?? propertyInfo.PropertyType; + + // This needs to be after unwrapping the `Nullable` since enums can be nullable + if (type.IsEnum) + { + // Get the backing type of the enum + type = Enum.GetUnderlyingType(type); + } + + if (!columnBuilders.TryAdd(propertyInfo.Name, (type, columnExpression.Compile()))) + { + throw new ArgumentException($"Property with name '{propertyInfo.Name}' was already added, properties can only be added once."); + } + } + + _columnBuilders = columnBuilders.ToFrozenDictionary(); + } + + private static bool TryGetPropertyInfo(Expression> columnExpression, [MaybeNullWhen(false)] out PropertyInfo property) + { + property = null; + + // Reference type properties + // i => i.Data + if (columnExpression.Body is MemberExpression { Member: PropertyInfo referencePropertyInfo }) + { + property = referencePropertyInfo; + return true; + } + + // Value type properties will implicitly box into the object so + // we need to look past the Convert expression + // i => (System.Object?)i.Id + if ( + columnExpression.Body is UnaryExpression + { + NodeType: ExpressionType.Convert, + Operand: MemberExpression { Member: PropertyInfo valuePropertyInfo }, + } + ) + { + // This could be an implicit cast from the property into our return type object? + property = valuePropertyInfo; + return true; + } + + // Other possible expression bodies here + return false; + } + + public DataTable Build(IEnumerable source) + { + ArgumentNullException.ThrowIfNull(source); + + var table = new DataTable(); + + foreach (var (name, (type, _)) in _columnBuilders) + { + table.Columns.Add(new DataColumn(name, type)); + } + + foreach (var entity in source) + { + var row = table.NewRow(); + + foreach (var (name, (_, getter)) in _columnBuilders) + { + var value = getter(entity); + if (value is null) + { + row[name] = DBNull.Value; + } + else + { + row[name] = value; + } + } + + table.Rows.Add(row); + } + + return table; + } +} + public static class DapperHelpers { + private static readonly DataTableBuilder _organizationSponsorshipTableBuilder = new( + [ + os => os.Id, + os => os.SponsoringOrganizationId, + os => os.SponsoringOrganizationUserId, + os => os.SponsoredOrganizationId, + os => os.FriendlyName, + os => os.OfferedToEmail, + os => os.PlanSponsorshipType, + os => os.LastSyncDate, + os => os.ValidUntil, + os => os.ToDelete, + ] + ); + public static DataTable ToGuidIdArrayTVP(this IEnumerable ids) { return ids.ToArrayTVP("GuidId"); @@ -63,24 +207,9 @@ public static class DapperHelpers public static DataTable ToTvp(this IEnumerable organizationSponsorships) { - var table = new DataTable(); + var table = _organizationSponsorshipTableBuilder.Build(organizationSponsorships ?? []); table.SetTypeName("[dbo].[OrganizationSponsorshipType]"); - - var columnData = new List<(string name, Type type, Func getter)> - { - (nameof(OrganizationSponsorship.Id), typeof(Guid), ou => ou.Id), - (nameof(OrganizationSponsorship.SponsoringOrganizationId), typeof(Guid), ou => ou.SponsoringOrganizationId), - (nameof(OrganizationSponsorship.SponsoringOrganizationUserId), typeof(Guid), ou => ou.SponsoringOrganizationUserId), - (nameof(OrganizationSponsorship.SponsoredOrganizationId), typeof(Guid), ou => ou.SponsoredOrganizationId), - (nameof(OrganizationSponsorship.FriendlyName), typeof(string), ou => ou.FriendlyName), - (nameof(OrganizationSponsorship.OfferedToEmail), typeof(string), ou => ou.OfferedToEmail), - (nameof(OrganizationSponsorship.PlanSponsorshipType), typeof(byte), ou => ou.PlanSponsorshipType), - (nameof(OrganizationSponsorship.LastSyncDate), typeof(DateTime), ou => ou.LastSyncDate), - (nameof(OrganizationSponsorship.ValidUntil), typeof(DateTime), ou => ou.ValidUntil), - (nameof(OrganizationSponsorship.ToDelete), typeof(bool), ou => ou.ToDelete), - }; - - return organizationSponsorships.BuildTable(table, columnData); + return table; } public static DataTable BuildTable(this IEnumerable entities, DataTable table, diff --git a/src/Infrastructure.Dapper/Tools/Helpers/SendHelpers.cs b/src/Infrastructure.Dapper/Tools/Helpers/SendHelpers.cs index 6df9000f7..973e0331f 100644 --- a/src/Infrastructure.Dapper/Tools/Helpers/SendHelpers.cs +++ b/src/Infrastructure.Dapper/Tools/Helpers/SendHelpers.cs @@ -8,6 +8,26 @@ namespace Bit.Infrastructure.Dapper.Tools.Helpers; /// public static class SendHelpers { + private static readonly DataTableBuilder _sendTableBuilder = new( + [ + s => s.Id, + s => s.UserId, + s => s.OrganizationId, + s => s.Type, + s => s.Data, + s => s.Key, + s => s.Password, + s => s.MaxAccessCount, + s => s.AccessCount, + s => s.CreationDate, + s => s.RevisionDate, + s => s.ExpirationDate, + s => s.DeletionDate, + s => s.Disabled, + s => s.HideEmail, + ] + ); + /// /// Converts an IEnumerable of Sends to a DataTable /// @@ -16,27 +36,6 @@ public static class SendHelpers /// A data table matching the schema of dbo.Send containing one row mapped from the items in s public static DataTable ToDataTable(this IEnumerable sends) { - var sendsTable = new DataTable(); - - var columnData = new List<(string name, Type type, Func getter)> - { - (nameof(Send.Id), typeof(Guid), c => c.Id), - (nameof(Send.UserId), typeof(Guid), c => c.UserId), - (nameof(Send.OrganizationId), typeof(Guid), c => c.OrganizationId), - (nameof(Send.Type), typeof(short), c => c.Type), - (nameof(Send.Data), typeof(string), c => c.Data), - (nameof(Send.Key), typeof(string), c => c.Key), - (nameof(Send.Password), typeof(string), c => c.Password), - (nameof(Send.MaxAccessCount), typeof(int), c => c.MaxAccessCount), - (nameof(Send.AccessCount), typeof(int), c => c.AccessCount), - (nameof(Send.CreationDate), typeof(DateTime), c => c.CreationDate), - (nameof(Send.RevisionDate), typeof(DateTime), c => c.RevisionDate), - (nameof(Send.ExpirationDate), typeof(DateTime), c => c.ExpirationDate), - (nameof(Send.DeletionDate), typeof(DateTime), c => c.DeletionDate), - (nameof(Send.Disabled), typeof(bool), c => c.Disabled), - (nameof(Send.HideEmail), typeof(bool), c => c.HideEmail), - }; - - return sends.BuildTable(sendsTable, columnData); + return _sendTableBuilder.Build(sends ?? []); } } diff --git a/test/Infrastructure.Dapper.Test/DataTableBuilderTests.cs b/test/Infrastructure.Dapper.Test/DataTableBuilderTests.cs new file mode 100644 index 000000000..b885729b7 --- /dev/null +++ b/test/Infrastructure.Dapper.Test/DataTableBuilderTests.cs @@ -0,0 +1,180 @@ +using System.Data; + +namespace Bit.Infrastructure.Dapper.Test; + +public class DataTableBuilderTests +{ + public class TestItem + { + // Normal value type + public int Id { get; set; } + // Normal reference type + public string? Name { get; set; } + // Nullable value type + public DateTime? DeletedDate { get; set; } + public object? ObjectProp { get; set; } + public DefaultEnum DefaultEnum { get; set; } + public DefaultEnum? NullableDefaultEnum { get; set; } + public ByteEnum ByteEnum { get; set; } + public ByteEnum? NullableByteEnum { get; set; } + + public int Method() + { + throw new NotImplementedException(); + } + } + + public enum DefaultEnum + { + Zero, + One, + } + + public enum ByteEnum : byte + { + Zero, + One, + } + + [Fact] + public void DataTableBuilder_Works() + { + var dtb = new DataTableBuilder( + [ + i => i.Id, + i => i.Name, + i => i.DeletedDate, + i => i.ObjectProp, + i => i.DefaultEnum, + i => i.NullableDefaultEnum, + i => i.ByteEnum, + i => i.NullableByteEnum, + ] + ); + + var table = dtb.Build( + [ + new TestItem + { + Id = 4, + Name = "Test", + DeletedDate = new DateTime(2024, 8, 8), + ObjectProp = 1, + DefaultEnum = DefaultEnum.One, + NullableDefaultEnum = DefaultEnum.Zero, + ByteEnum = ByteEnum.One, + NullableByteEnum = ByteEnum.Zero, + }, + new TestItem + { + Id = int.MaxValue, + Name = null, + DeletedDate = null, + ObjectProp = "Hi", + DefaultEnum = DefaultEnum.Zero, + NullableDefaultEnum = null, + ByteEnum = ByteEnum.Zero, + NullableByteEnum = null, + }, + ] + ); + + Assert.Collection( + table.Columns.Cast(), + column => + { + Assert.Equal("Id", column.ColumnName); + Assert.Equal(typeof(int), column.DataType); + }, + column => + { + Assert.Equal("Name", column.ColumnName); + Assert.Equal(typeof(string), column.DataType); + }, + column => + { + Assert.Equal("DeletedDate", column.ColumnName); + // Checking that it will unwrap the `Nullable` + Assert.Equal(typeof(DateTime), column.DataType); + }, + column => + { + Assert.Equal("ObjectProp", column.ColumnName); + Assert.Equal(typeof(object), column.DataType); + }, + column => + { + Assert.Equal("DefaultEnum", column.ColumnName); + Assert.Equal(typeof(int), column.DataType); + }, + column => + { + Assert.Equal("NullableDefaultEnum", column.ColumnName); + Assert.Equal(typeof(int), column.DataType); + }, + column => + { + Assert.Equal("ByteEnum", column.ColumnName); + Assert.Equal(typeof(byte), column.DataType); + }, + column => + { + Assert.Equal("NullableByteEnum", column.ColumnName); + Assert.Equal(typeof(byte), column.DataType); + } + ); + + + Assert.Collection( + table.Rows.Cast(), + row => + { + Assert.Collection( + row.ItemArray, + item => Assert.Equal(4, item), + item => Assert.Equal("Test", item), + item => Assert.Equal(new DateTime(2024, 8, 8), item), + item => Assert.Equal(1, item), + item => Assert.Equal((int)DefaultEnum.One, item), + item => Assert.Equal((int)DefaultEnum.Zero, item), + item => Assert.Equal((byte)ByteEnum.One, item), + item => Assert.Equal((byte)ByteEnum.Zero, item) + ); + }, + row => + { + Assert.Collection( + row.ItemArray, + item => Assert.Equal(int.MaxValue, item), + item => Assert.Equal(DBNull.Value, item), + item => Assert.Equal(DBNull.Value, item), + item => Assert.Equal("Hi", item), + item => Assert.Equal((int)DefaultEnum.Zero, item), + item => Assert.Equal(DBNull.Value, item), + item => Assert.Equal((byte)ByteEnum.Zero, item), + item => Assert.Equal(DBNull.Value, item) + ); + } + ); + } + + [Fact] + public void DataTableBuilder_ThrowsOnInvalidExpression() + { + var argException = Assert.Throws(() => new DataTableBuilder([i => i.Method()])); + Assert.Equal( + "Could not determine the property info from the given expression 'i => Convert(i.Method(), Object)'.", + argException.Message + ); + } + + [Fact] + public void DataTableBuilder_ThrowsOnRepeatExpression() + { + var argException = Assert.Throws(() => new DataTableBuilder([i => i.Id, i => i.Id])); + Assert.Equal( + "Property with name 'Id' was already added, properties can only be added once.", + argException.Message + ); + } +} diff --git a/test/Infrastructure.Dapper.Test/GlobalUsings.cs b/test/Infrastructure.Dapper.Test/GlobalUsings.cs new file mode 100644 index 000000000..9df1d4217 --- /dev/null +++ b/test/Infrastructure.Dapper.Test/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj new file mode 100644 index 000000000..fba0791a3 --- /dev/null +++ b/test/Infrastructure.Dapper.Test/Infrastructure.Dapper.Test.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + +