Skip to content

Commit

Permalink
Add custom patcher API
Browse files Browse the repository at this point in the history
It provides an easy way to resolve a custom component or
just delegate to an existing patcher.
  • Loading branch information
jahav committed Sep 16, 2024
1 parent 8d30ddd commit a262476
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 3 deletions.
25 changes: 25 additions & 0 deletions src/DataIsland.Core/DataIslandBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,31 @@ public DataIslandBuilder AddPatcher<TDataAccess>(IDependencyPatcher<TDataAccess>
return this;
}

/// <summary>
/// <para>
/// Add a custom patcher that resolves the data access from a tenant and service provider.
/// </para>
/// <para>
/// An example of usage: Create a SQL connection to a database created in a test. Note that it
/// is still necessary to open the connection:
/// <code>
/// builder.AddPatcher&lt;SqlConnection, SqlDatabaseTenant&gt;(
/// (sp, db) => new SqlConnection(db.ConnectionString));
/// </code>
/// </para>
/// </summary>
/// <typeparam name="TDataAccess">Type of service to resolve. A library to access <typeparamref name="TTenant"/>.</typeparam>
/// <typeparam name="TTenant">A data store.</typeparam>
/// <param name="factoryMethod">A factory method that will receive tenant instantiated for a
/// test and should return instance of <typeparamref name="TDataAccess"/> that accesses data
/// from <typeparamref name="TTenant"/>.</param>
/// <returns>This data builder for fluent API.</returns>
public DataIslandBuilder AddPatcher<TDataAccess, TTenant>(Func<IServiceProvider, TTenant, TDataAccess> factoryMethod)
where TTenant : class
{
return AddPatcher(new LambdaPatcher<TDataAccess, TTenant>(factoryMethod));

Check warning on line 87 in src/DataIsland.Core/DataIslandBuilder.cs

View workflow job for this annotation

GitHub Actions / build

The type 'TDataAccess' cannot be used as type parameter 'TDataAccess' in the generic type or method 'LambdaPatcher<TDataAccess, TTenant>'. Nullability of type argument 'TDataAccess' doesn't match 'notnull' constraint.

Check warning on line 87 in src/DataIsland.Core/DataIslandBuilder.cs

View workflow job for this annotation

GitHub Actions / build

The type 'TDataAccess' cannot be used as type parameter 'TDataAccess' in the generic type or method 'LambdaPatcher<TDataAccess, TTenant>'. Nullability of type argument 'TDataAccess' doesn't match 'notnull' constraint.
}

public IDataIsland Build()
{
foreach (var (templateName, template) in _templates)
Expand Down
10 changes: 9 additions & 1 deletion src/DataIsland.Core/DynamicCaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.Threading.Tasks;
using ITenantFactory = object;
using IDependencyPatcher = object;
Expand All @@ -16,7 +17,14 @@ public static void Register(this IDependencyPatcher patcher, Type dataAccessType
var patcherInterface = typeof(IDependencyPatcher<>).MakeGenericType(dataAccessType);
var registerMethod = patcherInterface.GetMethod("Register");
Debug.Assert(registerMethod is not null);
registerMethod.Invoke(patcher, [services]);
try
{
registerMethod.Invoke(patcher, [services]);
}
catch (TargetInvocationException ex)
{
throw ex.GetBaseException();
}
}

/// <inheritdoc cref="IComponentPool{TComponent,TComponentSpec}.AcquireComponentsAsync"/>
Expand Down
34 changes: 34 additions & 0 deletions src/DataIsland.Core/LambdaPatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace DataIsland;

internal class LambdaPatcher<TDataAccess, TTenant>(Func<IServiceProvider, TTenant, TDataAccess> _factoryMethod)
: IDependencyPatcher<TDataAccess>
where TDataAccess : notnull
where TTenant : class
{
public void Register(IServiceCollection serviceCollection)
{
var originalRegistrations = serviceCollection.Where(x => x.ServiceType == typeof(TDataAccess)).ToList();
if (originalRegistrations.Count == 0)
throw new InvalidOperationException($"Service {typeof(TDataAccess)} isn't in the service collection - it can't be patched. Ensure the service is registered as a service before calling the patch method to patch the registration.");

var replacements = new List<ServiceDescriptor>(originalRegistrations.Count);
foreach (var registration in originalRegistrations)
{
replacements.Add(new ServiceDescriptor(typeof(TDataAccess), sp =>
{
var testContext = sp.GetRequiredService<ITestContext>();
var tenant = testContext.GetTenant<TTenant>(typeof(TDataAccess));
return _factoryMethod(sp, tenant);
}, registration.Lifetime));
}

serviceCollection.RemoveAll(typeof(TDataAccess));
serviceCollection.Add(replacements);
}
}
2 changes: 1 addition & 1 deletion tests/DataIsland.Core.Tests/DataIsland.Core.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.4.2" />
Expand Down
67 changes: 66 additions & 1 deletion tests/DataIsland.Core.Tests/DataIslandBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System.Linq.Expressions;
using System.Diagnostics;
using System.Linq.Expressions;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using Moq;

namespace DataIsland.Core.Tests;
Expand All @@ -20,6 +22,69 @@ public void Each_data_access_type_can_have_only_one_patcher()
Assert.StartsWith("An item with the same key has already been added.", ex.Message);
}

[Fact]
public void Custom_patcher_patches_services_and_uses_resolved_tenant()
{
// Arrange
var component = new DummyComponent();
var tenant = new DummyTenant();
var patchedDataAccess = new TestDataAccess();
var poolMock = CreateComponentPoolMock<DummyComponent, DummyComponentSpec>("component", component);
var factoryMock = CreateTenantFactory<DummyTenant, DummyComponent, DummyTenantSpec>(component, tenant);
var patcherCalled = false;
var island = new DataIslandBuilder()
.AddTemplate("template", template =>
{
template.AddComponent<DummyComponent, DummyComponentSpec>("component");
template.AddTenant<DummyTenant, DummyTenantSpec>("tenant", "component");
template.AddDataAccess<TestDataAccess>("tenant");
})
.AddComponentPool(poolMock.Object, factoryMock.Object)
.AddPatcher<TestDataAccess, DummyTenant>((_, patcherTenant) =>
{
patcherCalled = true;
Assert.Same(tenant, patcherTenant);
return patchedDataAccess;
})
.Build();
var services = new ServiceCollection();
services.AddScoped<TestDataAccess>();
var testContext = new Mock<ITestContext>();
testContext.Setup(x => x.GetTenant<DummyTenant>(typeof(TestDataAccess))).Returns(tenant);
services.AddSingleton(testContext.Object);

// Act
island.PatchServices(services);
var serviceProvider = services.BuildServiceProvider();
var resolvedDataAccess = serviceProvider.GetRequiredService<TestDataAccess>();

// Assert
Assert.True(patcherCalled);
Assert.Same(patchedDataAccess, resolvedDataAccess);
}

[Fact]
public void Custom_patcher_requires_existing_service()
{
// Arrange
var island = StartBuilder<DummyComponent, DummyComponentSpec, DummyTenant, DummyTenantSpec>()
.AddTemplate("template", template =>
{
template.AddComponent<DummyComponent, DummyComponentSpec>("component");
template.AddTenant<DummyTenant, DummyTenantSpec>("tenant", "component");
template.AddDataAccess<TestDataAccess>("tenant");
})
.AddPatcher<TestDataAccess, DummyTenant>((_, _) => throw new UnreachableException())
.Build();
var services = new ServiceCollection();

// Act
var ex = Assert.Throws<InvalidOperationException>(() => island.PatchServices(services));

// Assert
Assert.Equal("Service DataIsland.Core.Tests.DataIslandBuilderTests+TestDataAccess isn't in the service collection - it can't be patched. Ensure the service is registered as a service before calling the patch method to patch the registration.", ex.Message);
}

#endregion

#region AddComponent
Expand Down

0 comments on commit a262476

Please sign in to comment.