mirror of
https://github.com/bitwarden/server.git
synced 2024-11-23 12:25:16 +01:00
Create DataTableBuilder (#4608)
* Add DataTableBuilder Using Expressions * Format * Unwrap Underlying Enum Type * Formatting
This commit is contained in:
parent
ec2522de8b
commit
329eef82cd
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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 ?? []);
|
||||
}
|
||||
}
|
||||
|
180
test/Infrastructure.Dapper.Test/DataTableBuilderTests.cs
Normal file
180
test/Infrastructure.Dapper.Test/DataTableBuilderTests.cs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
1
test/Infrastructure.Dapper.Test/GlobalUsings.cs
Normal file
1
test/Infrastructure.Dapper.Test/GlobalUsings.cs
Normal file
@ -0,0 +1 @@
|
||||
global using Xunit;
|
@ -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>
|
Loading…
Reference in New Issue
Block a user