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