Skip to content

Commit 3674643

Browse files
committed
Added SQL Server feature to be able to provide a collection of complex objects as a JSON parameter
1 parent f04a301 commit 3674643

File tree

13 files changed

+244
-18
lines changed

13 files changed

+244
-18
lines changed

samples/Thinktecture.EntityFrameworkCore.SqlServer.Samples/Database/DemoDbContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
2929
modelBuilder.ConfigureTempTable<Guid>();
3030
modelBuilder.ConfigureTempTable<Guid, Guid>();
3131

32+
modelBuilder.ConfigureComplexCollectionParameter<MyParameter>();
33+
3234
modelBuilder.Entity<Customer>(builder =>
3335
{
3436
builder.Property(c => c.FirstName).HasMaxLength(100);
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace Thinktecture.Database;
2+
3+
public record MyParameter(Guid Column1, int Column2);

samples/Thinktecture.EntityFrameworkCore.SqlServer.Samples/Program.cs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ public static async Task Main(string[] args)
4343
await DoBulkDeleteAsync(ctx);
4444
ctx.ChangeTracker.Clear();
4545

46-
await DoContainsUsingCollectionParameterAsync(ctx, new List<Guid> { customerId });
46+
await DoScalarCollectionParameterAsync(ctx, new List<Guid> { customerId });
47+
await DoComplexCollectionParameterAsync(ctx, customerId);
4748
ctx.ChangeTracker.Clear();
4849

4950
// Bulk insert into temp tables
@@ -76,14 +77,24 @@ public static async Task Main(string[] args)
7677
Console.WriteLine("Exiting samples...");
7778
}
7879

79-
private static async Task DoContainsUsingCollectionParameterAsync(DemoDbContext ctx, List<Guid> customerIds)
80+
private static async Task DoScalarCollectionParameterAsync(DemoDbContext ctx, List<Guid> customerIds)
8081
{
8182
var customerIdsQuery = ctx.ToScalarCollectionParameter(customerIds);
8283

83-
var customers = await ctx.Customers.Join(customerIdsQuery, c => c.Id, t => t, (c, t) => c).ToListAsync();
84+
var customers = await ctx.Customers.Where(c => customerIdsQuery.Contains(c.Id)).ToListAsync();
85+
8486
Console.WriteLine($"Found customers: {String.Join(", ", customers.Select(c => c.Id))}");
8587
}
8688

89+
private static async Task DoComplexCollectionParameterAsync(DemoDbContext ctx, Guid customerId)
90+
{
91+
var parameters = ctx.ToComplexCollectionParameter(new[] { new MyParameter(customerId, 42) });
92+
93+
var customers = await ctx.Customers.Join(parameters, c => c.Id, t => t.Column1, (c, t) => new { Customer = c, Number = t.Column2}).ToListAsync();
94+
95+
Console.WriteLine($"Found customers: {String.Join(", ", customers.Select(c => c.Customer.Id))}");
96+
}
97+
8798
private static async Task DoTenantQueriesAsync(DemoDbContext ctx)
8899
{
89100
await ctx.Customers

src/Thinktecture.EntityFrameworkCore.BulkOperations/EntityFrameworkCore/Parameters/ICollectionParameterFactory.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,19 @@ public interface ICollectionParameterFactory
1010
/// <summary>
1111
/// Creates an <see cref="IQueryable{T}"/> out of provided <paramref name="values"/>.
1212
/// </summary>
13-
/// <param name="ctx">An instance of <see cref="DbContext"/> to use the values with.</param>
14-
/// <param name="values">A collection of values to create a query from.</param>
15-
/// <typeparam name="T">Type of the values.</typeparam>
13+
/// <param name="ctx">An instance of <see cref="DbContext"/> to use the <paramref name="values"/> with.</param>
14+
/// <param name="values">A collection of <paramref name="values"/> to create a query from.</param>
15+
/// <typeparam name="T">Type of the <paramref name="values"/>.</typeparam>
1616
/// <returns>An <see cref="IQueryable{T}"/> giving access to the provided <paramref name="values"/>.</returns>
1717
IQueryable<T> CreateScalarQuery<T>(DbContext ctx, IEnumerable<T> values);
18+
19+
/// <summary>
20+
/// Creates an <see cref="IQueryable{T}"/> out of provided <paramref name="objects"/>.
21+
/// </summary>
22+
/// <param name="ctx">An instance of <see cref="DbContext"/> to use the <paramref name="objects"/> with.</param>
23+
/// <param name="objects">A collection of <paramref name="objects"/> to create a query from.</param>
24+
/// <typeparam name="T">Type of the <paramref name="objects"/>.</typeparam>
25+
/// <returns>An <see cref="IQueryable{T}"/> giving access to the provided <paramref name="objects"/>.</returns>
26+
IQueryable<T> CreateComplexQuery<T>(DbContext ctx, IEnumerable<T> objects)
27+
where T : class;
1828
}

src/Thinktecture.EntityFrameworkCore.BulkOperations/Extensions/BulkOperationsDbContextExtensions.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -310,14 +310,27 @@ public static Task TruncateTableAsync<T>(
310310
/// <summary>
311311
/// Converts the provided <paramref name="values"/> to a "parameter" to be used in queries.
312312
/// </summary>
313-
/// <param name="ctx">An instance of <see cref="DbContext"/> to use the values with.</param>
314-
/// <param name="values">A collection of values to create a query from.</param>
315-
/// <typeparam name="T">Type of the values.</typeparam>
313+
/// <param name="ctx">An instance of <see cref="DbContext"/> to use the <paramref name="values"/> with.</param>
314+
/// <param name="values">A collection of <paramref name="values"/> to create a query from.</param>
315+
/// <typeparam name="T">Type of the <paramref name="values"/>.</typeparam>
316316
/// <returns>An <see cref="IQueryable{T}"/> giving access to the provided <paramref name="values"/>.</returns>
317317
public static IQueryable<T> ToScalarCollectionParameter<T>(this DbContext ctx, IEnumerable<T> values)
318318
{
319-
var factory = ctx.GetService<ICollectionParameterFactory>();
319+
return ctx.GetService<ICollectionParameterFactory>()
320+
.CreateScalarQuery(ctx, values);
321+
}
320322

321-
return factory.CreateScalarQuery(ctx, values);
323+
/// <summary>
324+
/// Converts the provided <paramref name="objects"/> to a "parameter" to be used in queries.
325+
/// </summary>
326+
/// <param name="ctx">An instance of <see cref="DbContext"/> to use the <paramref name="objects"/> with.</param>
327+
/// <param name="objects">A collection of <paramref name="objects"/> to create a query from.</param>
328+
/// <typeparam name="T">Type of the <paramref name="objects"/>.</typeparam>
329+
/// <returns>An <see cref="IQueryable{T}"/> giving access to the provided <paramref name="objects"/>.</returns>
330+
public static IQueryable<T> ToComplexCollectionParameter<T>(this DbContext ctx, IEnumerable<T> objects)
331+
where T : class
332+
{
333+
return ctx.GetService<ICollectionParameterFactory>()
334+
.CreateComplexQuery(ctx, objects);
322335
}
323336
}

src/Thinktecture.EntityFrameworkCore.BulkOperations/Extensions/BulkOperationsModelBuilderExtensions.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,22 @@ public static EntityTypeBuilder<ScalarCollectionParameter<T>> ConfigureScalarCol
4040
.HasNoKey();
4141
}
4242

43+
/// <summary>
44+
/// Introduces and configures a complex parameter.
45+
/// </summary>
46+
/// <param name="modelBuilder">A model builder.</param>
47+
/// <typeparam name="T">Type of the parameter.</typeparam>
48+
/// <returns>An entity type builder for further configuration.</returns>
49+
/// <exception cref="ArgumentNullException"><paramref name="modelBuilder"/> is <c>null</c>.</exception>
50+
public static EntityTypeBuilder<T> ConfigureComplexCollectionParameter<T>(this ModelBuilder modelBuilder)
51+
where T : class
52+
{
53+
return modelBuilder.Entity<T>()
54+
.ToTable(typeof(T).ShortDisplayName(),
55+
tableBuilder => tableBuilder.ExcludeFromMigrations())
56+
.HasNoKey();
57+
}
58+
4359
/// <summary>
4460
/// Introduces and configures a temp table.
4561
/// </summary>

src/Thinktecture.EntityFrameworkCore.SqlServer/EntityFrameworkCore/Infrastructure/SqlServerDbContextOptionsExtension.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
using System.Diagnostics.CodeAnalysis;
22
using System.Globalization;
3+
using System.Text;
34
using System.Text.Json;
45
using Microsoft.EntityFrameworkCore.Infrastructure;
56
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
67
using Microsoft.EntityFrameworkCore.Migrations;
78
using Microsoft.EntityFrameworkCore.Query;
89
using Microsoft.EntityFrameworkCore.SqlServer.Query.Internal;
10+
using Microsoft.EntityFrameworkCore.Storage;
911
using Microsoft.Extensions.DependencyInjection;
1012
using Microsoft.Extensions.DependencyInjection.Extensions;
13+
using Microsoft.Extensions.ObjectPool;
1114
using Thinktecture.EntityFrameworkCore.BulkOperations;
1215
using Thinktecture.EntityFrameworkCore.Migrations;
1316
using Thinktecture.EntityFrameworkCore.Parameters;
@@ -143,7 +146,9 @@ public void ApplyServices(IServiceCollection services)
143146
{
144147
var jsonSerializerOptions = _collectionParameterJsonSerializerOptions ?? new JsonSerializerOptions();
145148

146-
services.AddSingleton<ICollectionParameterFactory>(_ => new SqlServerCollectionParameterFactory(jsonSerializerOptions));
149+
services.AddSingleton<ICollectionParameterFactory>(serviceProvider => new SqlServerCollectionParameterFactory(jsonSerializerOptions,
150+
serviceProvider.GetRequiredService<ObjectPool<StringBuilder>>(),
151+
serviceProvider.GetRequiredService<ISqlGenerationHelper>()));
147152
services.Add<IConventionSetPlugin, SqlServerCollectionParameterConventionSetPlugin>(GetLifetime<IConventionSetPlugin>());
148153
}
149154

src/Thinktecture.EntityFrameworkCore.SqlServer/EntityFrameworkCore/Parameters/SqlServerCollectionParameterFactory.cs

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
using System.Text.Json;
77
using Microsoft.Data.SqlClient;
88
using Microsoft.EntityFrameworkCore.Metadata;
9+
using Microsoft.EntityFrameworkCore.Storage;
910
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
11+
using Microsoft.Extensions.ObjectPool;
1012

1113
namespace Thinktecture.EntityFrameworkCore.Parameters;
1214

@@ -15,15 +17,24 @@ public class SqlServerCollectionParameterFactory : ICollectionParameterFactory
1517
{
1618
private readonly ConcurrentDictionary<IEntityType, CollectionParameterInfo> _cache;
1719
private readonly JsonSerializerOptions _jsonSerializerOptions;
20+
private readonly ObjectPool<StringBuilder> _stringBuilderPool;
21+
private readonly ISqlGenerationHelper _sqlGenerationHelper;
1822

1923
/// <summary>
2024
/// Initializes new instance of <see cref="SqlServerCollectionParameterFactory"/>.
2125
/// </summary>
2226
/// <param name="jsonSerializerOptions">JSON serialization options.</param>
27+
/// <param name="stringBuilderPool">String builder pool.</param>
28+
/// <param name="sqlGenerationHelper"></param>
2329
/// <exception cref="ArgumentNullException">If <paramref name="jsonSerializerOptions"/> is <c>null</c>.</exception>
24-
public SqlServerCollectionParameterFactory(JsonSerializerOptions jsonSerializerOptions)
30+
public SqlServerCollectionParameterFactory(
31+
JsonSerializerOptions jsonSerializerOptions,
32+
ObjectPool<StringBuilder> stringBuilderPool,
33+
ISqlGenerationHelper sqlGenerationHelper)
2534
{
2635
_jsonSerializerOptions = jsonSerializerOptions ?? throw new ArgumentNullException(nameof(jsonSerializerOptions));
36+
_stringBuilderPool = stringBuilderPool ?? throw new ArgumentNullException(nameof(stringBuilderPool));
37+
_sqlGenerationHelper = sqlGenerationHelper ?? throw new ArgumentNullException(nameof(sqlGenerationHelper));
2738
_cache = new ConcurrentDictionary<IEntityType, CollectionParameterInfo>();
2839
}
2940

@@ -43,23 +54,101 @@ public IQueryable<T> CreateScalarQuery<T>(DbContext ctx, IEnumerable<T> values)
4354
return ctx.Set<ScalarCollectionParameter<T>>().FromSqlRaw(parameterInfo.Statement, parameter).Select(e => e.Value);
4455
}
4556

46-
private static CollectionParameterInfo GetScalarParameterInfo<T>(IEntityType entityType)
57+
/// <inheritdoc />
58+
public IQueryable<T> CreateComplexQuery<T>(DbContext ctx, IEnumerable<T> objects)
59+
where T : class
60+
{
61+
var entityType = ctx.Model.GetEntityType(typeof(T));
62+
var parameterInfo = _cache.GetOrAdd(entityType, GetComplexParameterInfo<T>);
63+
64+
var parameter = new SqlParameter
65+
{
66+
DbType = DbType.String,
67+
SqlDbType = SqlDbType.NVarChar,
68+
Value = parameterInfo.ParameterFactory(objects, _jsonSerializerOptions)
69+
};
70+
71+
return ctx.Set<T>().FromSqlRaw(parameterInfo.Statement, parameter);
72+
}
73+
74+
private CollectionParameterInfo GetScalarParameterInfo<T>(IEntityType entityType)
4775
{
4876
var storeObject = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) ?? throw new Exception($"Could not create StoreObjectIdentifier for table '{entityType.Name}'.");
4977

5078
var property = entityType.GetProperties().Single();
5179

52-
var columnName = property.GetColumnName(storeObject);
80+
var columnName = property.GetColumnName(storeObject) ?? throw new Exception($"The property '{property.Name}' has no column name.");
81+
var escapedColumnName = _sqlGenerationHelper.DelimitIdentifier(columnName);
5382
var columnType = property.GetColumnType(storeObject);
5483
var converter = property.GetValueConverter();
84+
var sb = _stringBuilderPool.Get();
5585

56-
var sqlStatement = $@"SELECT [{columnName}] FROM OPENJSON({{0}}, '$') WITH ([{columnName}] {columnType} '$')";
86+
try
87+
{
88+
sb.Append("SELECT ").Append(escapedColumnName).Append(" FROM OPENJSON({0}, '$') WITH (")
89+
.Append(escapedColumnName).Append(" ").Append(columnType).Append(" '$')");
90+
91+
var parameterFactory = CreateParameterFactory<T>(converter);
92+
93+
return new CollectionParameterInfo(sb.ToString(), parameterFactory);
94+
}
95+
finally
96+
{
97+
_stringBuilderPool.Return(sb);
98+
}
99+
}
57100

58-
var parameterFactory = CreateParameterFactory<T>(converter);
101+
private CollectionParameterInfo GetComplexParameterInfo<T>(IEntityType entityType)
102+
{
103+
var sqlStatement = CreateSqlStatementForComplexType(entityType);
104+
var parameterFactory = CreateParameterFactory<T>(null);
59105

60106
return new CollectionParameterInfo(sqlStatement, parameterFactory);
61107
}
62108

109+
private string CreateSqlStatementForComplexType(IEntityType entityType)
110+
{
111+
var sb = _stringBuilderPool.Get();
112+
var withClause = _stringBuilderPool.Get();
113+
114+
try
115+
{
116+
var storeObject = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) ?? throw new Exception($"Could not create StoreObjectIdentifier for table '{entityType.Name}'.");
117+
118+
sb.Append("SELECT ");
119+
120+
var isFirst = true;
121+
122+
foreach (var property in entityType.GetProperties())
123+
{
124+
if (!isFirst)
125+
{
126+
sb.Append(", ");
127+
withClause.Append(", ");
128+
}
129+
130+
var columnName = property.GetColumnName(storeObject) ?? throw new Exception($"The property '{property.Name}' has no column name.");
131+
var escapedColumnName = _sqlGenerationHelper.DelimitIdentifier(columnName);
132+
var columnType = property.GetColumnType(storeObject) ?? throw new Exception($"The property '{property.Name}' has no column type.");
133+
134+
sb.Append("[").Append(columnName).Append("]");
135+
136+
withClause.Append(escapedColumnName).Append(" ").Append(columnType).Append($" '$.{property.Name}'");
137+
138+
isFirst = false;
139+
}
140+
141+
sb.Append(" FROM OPENJSON({0}, '$') WITH (").Append(withClause).Append(")");
142+
143+
return sb.ToString();
144+
}
145+
finally
146+
{
147+
_stringBuilderPool.Return(sb);
148+
_stringBuilderPool.Return(withClause);
149+
}
150+
}
151+
63152
private static Func<IEnumerable, JsonSerializerOptions, JsonCollectionParameter> CreateParameterFactory<T>(
64153
ValueConverter? converter)
65154
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Thinktecture.TestDatabaseContext;
2+
3+
namespace Thinktecture.Extensions.DbContextExtensionsTests;
4+
5+
public class ToComplexCollectionParameter : IntegrationTestsBase
6+
{
7+
public ToComplexCollectionParameter(ITestOutputHelper testOutputHelper)
8+
: base(testOutputHelper, true)
9+
{
10+
}
11+
12+
[Fact]
13+
public void Should_do_roundtrip()
14+
{
15+
ActDbContext.ToComplexCollectionParameter(new[] { new MyParameter(new Guid("D3E99F44-40A1-4E4E-820F-9D7C7B02AFA5"), new ConvertibleClass(42)) })
16+
.ToList()
17+
.Should().BeEquivalentTo(new[] { new MyParameter(new Guid("D3E99F44-40A1-4E4E-820F-9D7C7B02AFA5"), new ConvertibleClass(42)) });
18+
}
19+
20+
[Fact]
21+
public async Task Should_work_with_joins()
22+
{
23+
var testEntity = new TestEntity
24+
{
25+
Id = new Guid("7F8B0E79-2C91-4682-9F61-6FC86B4E5244"),
26+
Name = "Name",
27+
ConvertibleClass = new ConvertibleClass(42)
28+
};
29+
await ArrangeDbContext.AddAsync(testEntity);
30+
await ArrangeDbContext.SaveChangesAsync();
31+
32+
var collectionParameter = ActDbContext.ToComplexCollectionParameter(new[] { new MyParameter(testEntity.Id, new ConvertibleClass(42)) });
33+
var loadedEntities = await ActDbContext.TestEntities
34+
.Join(collectionParameter, t => new { t.Id, ConvertibleClass = t.ConvertibleClass! }, p => new { Id = p.Column1, ConvertibleClass = p.Column2 }, (t, p) => t)
35+
.ToListAsync();
36+
37+
loadedEntities.Should().HaveCount(1);
38+
var loadedEntity = loadedEntities[0];
39+
loadedEntity.Should().BeEquivalentTo(new TestEntity
40+
{
41+
Id = new Guid("7F8B0E79-2C91-4682-9F61-6FC86B4E5244"),
42+
Name = "Name",
43+
ConvertibleClass = new ConvertibleClass(42)
44+
});
45+
}
46+
}

tests/Thinktecture.EntityFrameworkCore.SqlServer.Tests/IntegrationTestsBase.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Data.Common;
22
using System.Diagnostics;
33
using System.Diagnostics.CodeAnalysis;
4+
using System.Text.Json;
45
using Microsoft.EntityFrameworkCore.Diagnostics;
56
using Microsoft.EntityFrameworkCore.Diagnostics.Internal;
67
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -9,13 +10,16 @@
910
using Thinktecture.EntityFrameworkCore.Infrastructure;
1011
using Thinktecture.EntityFrameworkCore.Query;
1112
using Thinktecture.EntityFrameworkCore.Testing;
13+
using Thinktecture.Json;
1214
using Thinktecture.TestDatabaseContext;
1315

1416
namespace Thinktecture;
1517

1618
[Collection("SqlServerTests")]
1719
public class IntegrationTestsBase : SqlServerDbContextIntegrationTests<TestDbContext>
1820
{
21+
private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { Converters = { new ConvertibleClassConverter() } };
22+
1923
protected Action<ModelBuilder>? ConfigureModel { get; set; }
2024
protected IReadOnlyCollection<string> SqlStatements { get; }
2125

@@ -75,7 +79,7 @@ protected override void ConfigureSqlServer(SqlServerDbContextOptionsBuilder buil
7579

7680
builder.AddBulkOperationSupport()
7781
.AddRowNumberSupport()
78-
.AddCollectionParameterSupport();
82+
.AddCollectionParameterSupport(_jsonSerializerOptions);
7983

8084
if (IsTenantDatabaseSupportEnabled)
8185
builder.AddTenantDatabaseSupport<TestTenantDatabaseProviderFactory>();

0 commit comments

Comments
 (0)