1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-21 12:05:42 +01:00

Create DataTableBuilder (#4608)

* Add DataTableBuilder Using Expressions

* Format

* Unwrap Underlying Enum Type

* Formatting
This commit is contained in:
Justin Baur 2024-09-05 20:44:45 -04:00 committed by GitHub
parent ec2522de8b
commit 329eef82cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 386 additions and 41 deletions

View File

@ -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}

View File

@ -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;
/// <summary>
/// Provides a way to build a <see cref="DataTable"/> based on the properties of <see cref="T"/>.
/// </summary>
/// <typeparam name="T"></typeparam>
public class DataTableBuilder<T>
{
private readonly FrozenDictionary<string, (Type Type, Func<T, object?> Getter)> _columnBuilders;
/// <summary>
/// Creates a new instance of <see cref="DataTableBuilder{T}"/>.
/// </summary>
/// <example>
/// <code>
/// new DataTableBuilder<MyObject>(
/// [
/// i => i.Id,
/// i => i.Name,
/// ]
/// );
/// </code>
/// </example>
/// <param name="columnExpressions"></param>
/// <exception cref="ArgumentException"></exception>
public DataTableBuilder(Expression<Func<T, object?>>[] columnExpressions)
{
ArgumentNullException.ThrowIfNull(columnExpressions);
ArgumentOutOfRangeException.ThrowIfZero(columnExpressions.Length);
var columnBuilders = new Dictionary<string, (Type Type, Func<T, object?>)>(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<T>
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<Func<T, object?>> 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<T> 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<OrganizationSponsorship> _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<Guid> ids)
{
return ids.ToArrayTVP("GuidId");
@ -63,24 +207,9 @@ public static class DapperHelpers
public static DataTable ToTvp(this IEnumerable<OrganizationSponsorship> organizationSponsorships)
{
var table = new DataTable();
var table = _organizationSponsorshipTableBuilder.Build(organizationSponsorships ?? []);
table.SetTypeName("[dbo].[OrganizationSponsorshipType]");
var columnData = new List<(string name, Type type, Func<OrganizationSponsorship, object?> 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<T>(this IEnumerable<T> entities, DataTable table,

View File

@ -8,6 +8,26 @@ namespace Bit.Infrastructure.Dapper.Tools.Helpers;
/// </summary>
public static class SendHelpers
{
private static readonly DataTableBuilder<Send> _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,
]
);
/// <summary>
/// Converts an IEnumerable of Sends to a DataTable
/// </summary>
@ -16,27 +36,6 @@ public static class SendHelpers
/// <returns>A data table matching the schema of dbo.Send containing one row mapped from the items in <see cref="Send"/>s</returns>
public static DataTable ToDataTable(this IEnumerable<Send> sends)
{
var sendsTable = new DataTable();
var columnData = new List<(string name, Type type, Func<Send, object> 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 ?? []);
}
}

View File

@ -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<TestItem>(
[
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<DataColumn>(),
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<T>`
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<DataRow>(),
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<ArgumentException>(() => new DataTableBuilder<TestItem>([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<ArgumentException>(() => new DataTableBuilder<TestItem>([i => i.Id, i => i.Id]));
Assert.Equal(
"Property with name 'Id' was already added, properties can only be added once.",
argException.Message
);
}
}

View File

@ -0,0 +1 @@
global using Xunit;

View File

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Infrastructure.Dapper\Infrastructure.Dapper.csproj" />
</ItemGroup>
</Project>