Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Share common interface (IDatabaseContainer) for ADO.NET compatible containers #920

Merged
merged 7 commits into from
Oct 14, 2023

Conversation

0xced
Copy link
Contributor

@0xced 0xced commented Jun 12, 2023

What does this PR do?

This PR introduces a new IDatabaseContainer interface with a single method: string GetConnectionString();

All containers that expose a GetConnectionString() method have been updated to also implement the new IDatabaseContainer interface.

Why is it important?

This is required in order to implement a generic database fixture that could look like this. The key is the TContainer : IContainer, IDatabaseContainer constraint.

using System;
using System.Data.Common;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Testcontainers.MsSql;
using Testcontainers.MySql;
using Testcontainers.PostgreSql;
using Xunit;

namespace SampleCode;

public abstract class DatabaseFixture<TBuilder, TContainer> : IAsyncLifetime
    where TBuilder : IContainerBuilder<TBuilder, TContainer>, new()
    where TContainer : IContainer, IDatabaseContainer
{
    private string _connectionString;
    private TContainer _container;

    protected abstract DbProviderFactory ProviderFactory { get; }

    public string ConnectionString => _connectionString ?? throw new InvalidOperationException($"{nameof(IAsyncLifetime.InitializeAsync)} must be called before accessing the connection string");

    async Task IAsyncLifetime.InitializeAsync()
    {
        _container = new TBuilder().Build();
        await _container.StartAsync();
        _connectionString = _container.GetConnectionString();
        using var connection = ProviderFactory.CreateConnection() ?? throw new InvalidOperationException($"ProviderFactory.CreateConnection() returned null ({ProviderFactory})");
        connection.ConnectionString = _connectionString;
        await connection.OpenAsync();
        await connection.CloseAsync();
    }

    async Task IAsyncLifetime.DisposeAsync()
    {
        if (_container != null)
        {
            await _container.StopAsync();
        }
    }
}

public class MsSqlDbFixture : DatabaseFixture<MsSqlBuilder, MsSqlContainer>
{
    protected override DbProviderFactory ProviderFactory => Microsoft.Data.SqlClient.SqlClientFactory.Instance;
}

public class MySqlDbFixture : DatabaseFixture<MySqlBuilder, MySqlContainer>
{
    protected override DbProviderFactory ProviderFactory => MySqlConnector.MySqlConnectorFactory.Instance;
}

public class PostgreSqlDbFixture : DatabaseFixture<PostgreSqlBuilder, PostgreSqlContainer>
{
    protected override DbProviderFactory ProviderFactory => Npgsql.NpgsqlFactory.Instance;
}

public class PostgreSqlTest : IClassFixture<PostgreSqlDbFixture>
{
    private readonly PostgreSqlDbFixture _dbFixture;

    public PostgreSqlTest(PostgreSqlDbFixture dbFixture) => _dbFixture = dbFixture;

    [Fact]
    public async Task TestOnPostgreSql()
    {
        await using var connection = new Npgsql.NpgsqlConnection(_dbFixture.ConnectionString);
        await using var command = connection.CreateCommand();

        // ...
    }
}

This will also be required to implement a new wait strategy for database where an ADO.NET provider is available.

Note that this issue came up when I tried to use Testcontainers for improving Dapper tests. Currently some tests are skipped if a database is not available. Using Testcontainers would enable running more tests when Docker is installed.

Related issues

N/A

How to test this PR

A new Testcontainers.Databases.Tests project has been added to make sure that all container types that have a GetConnectionString method implement this new IDatabaseContainer interface. There are currently 21 containers that implement the new IDatabaseContainer interface.

Note that thanks to the ProjectReference globbing any new container added will be automatically tested and a test will fail if a GetConnectionString method exists but the IDatabaseContainer interface is not declared.

<ProjectReference Include="$(SolutionDir)src/Testcontainers.*/Testcontainers.*.csproj" />

@netlify
Copy link

netlify bot commented Jun 12, 2023

Deploy Preview for testcontainers-dotnet ready!

Name Link
🔨 Latest commit 9dd0cbb
🔍 Latest deploy log https://app.netlify.com/sites/testcontainers-dotnet/deploys/652a4b0bacd44b0008517e04
😎 Deploy Preview https://deploy-preview-920--testcontainers-dotnet.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify site configuration.

Copy link
Collaborator

@HofmeisterAn HofmeisterAn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. That is something I have been thinking about adding again as well. I would like to propose that we only take into account modules that are compatible with ADO.NET (similar to TC for Java, which consideres support of JDBC). Perhaps it would be a good idea to rename the interface to something like IAdoDotNetDatabaseContainer. WDYT?

This will also be required to implement a new wait strategy for database where an ADO.NET provider is available.

What do you mean by that? Why would that be necessary?

@0xced
Copy link
Contributor Author

0xced commented Jun 13, 2023

Thank you. That is something I have been thinking about adding again as well. I would like to propose that we only take into account modules that are compatible with ADO.NET (similar to TC for Java, which consideres support of JDBC). Perhaps it would be a good idea to rename the interface to something like IAdoDotNetDatabaseContainer. WDYT?

I'd rather have all containers that provide a connection string to implement the new IDatabaseContainer interface. Coupling the connection string to ADO.NET providers would not really make sense without an additional DbProviderFactory property and thus taking a dependency on specific providers.

public interface IAdoDotNetDatabaseContainer
{
  string GetConnectionString();
  DbProviderFactory DbProviderFactory { get; }
}

And I'd rather let the choice of the ADO.NET provider to the Testcontainers consumer. For PostgreSQL a dependency on Npgsql would be the obvious choice, but which version? That's a decision I'd rather not make at the Testcontainers level. For MySQL the decision is even harder: MySqlConnector or MySql.Data? Same problem for SQL Server: System.Data.SqlClient or Microsoft.Data.SqlClient? These are all decisions that are better made outside of Testcontainers.

What do you mean by that? Why would that be necessary?

Because the connection string must come from the container itself. Here's my prototype of a new wait strategy that waits until the database is actually available. We see that a new interface is required to implement this strategy if we don't want to use reflection on the IContainer instance.

namespace DotNet.Testcontainers.Configurations
{
  using System;
  using System.Data.Common;
  using System.Diagnostics;
  using System.Threading.Tasks;
  using DotNet.Testcontainers.Containers;

  internal class UntilDatabaseIsAvailable : IWaitUntil
  {
    private static readonly TimeSpan DefaultFrequency = TimeSpan.FromSeconds(1);
    private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMinutes(1);

    private readonly DbProviderFactory _dbProviderFactory;
    private readonly TimeSpan _frequency;
    private readonly TimeSpan _timeout;

    public UntilDatabaseIsAvailable(DbProviderFactory dbProviderFactory, TimeSpan frequency, TimeSpan timeout)
    {
      _dbProviderFactory = dbProviderFactory;
      _frequency = frequency == default ? DefaultFrequency : frequency;
      _timeout = timeout == default ? DefaultTimeout : timeout;
    }

    public async Task<bool> UntilAsync(IContainer container)
    {
      if (!(container is IDatabaseContainer dbContainer))
      {
        throw new InvalidOperationException($"{container.GetType().FullName} must implement the {nameof(IDatabaseContainer)} interface in order to wait until the database is available.");
      }

      var stopwatch = Stopwatch.StartNew();

      var connectionString = dbContainer.GetConnectionString();
      while (!await IsAvailableAsync(connectionString, stopwatch))
      {
        await Task.Delay(_frequency);
      }

      return true;
    }

    private async Task<bool> IsAvailableAsync(string connectionString, Stopwatch stopwatch)
    {
      using (var connection = _dbProviderFactory.CreateConnection() ?? throw new InvalidOperationException($"{_dbProviderFactory.GetType().FullName}.CreateConnection() returned null."))
      {
        connection.ConnectionString = connectionString;
        try
        {
          await connection.OpenAsync();
          return true;
        }
        catch (DbException exception)
        {
          if (stopwatch.Elapsed > _timeout)
          {
            throw new TimeoutException($"The database was not available at \"{connectionString}\" after waiting for {_timeout.TotalSeconds:F0} seconds.", exception);
          }
          return false;
        }
      }
    }
  }
}

This is how I envisioned it but I'm totally open to alternatives.

Copy link
Collaborator

@HofmeisterAn HofmeisterAn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather have all containers that provide a connection string to implement the new IDatabaseContainer interface.

I understand your perspective, but implementing the IDatabaseContainer interface for modules that are not associated with a database like LocalStack or WebDriver sounds not right.

Coupling the connection string to ADO.NET providers would not really make sense without an additional DbProviderFactory property and thus taking a dependency on specific providers.

My intention was not to connect them specifically to ADO.NET providers. I simply wanted to include the interface in those modules because they have something in common. The other modules do not share this similarity.

And I'd rather let the choice of the ADO.NET provider to the Testcontainers consumer.

OC, that is why (as you described below), we do not include such providers, client libraries, etc. in our dependencies.

Because the connection string must come from the container itself. Here's my prototype of a new wait strategy that waits until the database is actually available.

I am still not able to understand it completely. What is the issue with the current wait strategies? Based on our experience, relying solely on the first database connection often leads to unreliable test results. We have frequently observed situations where the databases allowed connections but were not yet ready to process any data, causing the tests to fail. The current implementation of the wait strategies is shared across TC language implementations and is considered stable.

@0xced
Copy link
Contributor Author

0xced commented Aug 4, 2023

I understand your perspective, but implementing the IDatabaseContainer interface for modules that are not associated with a database like LocalStack or WebDriver sounds not right.

I agree. Happy to remove the IDatabaseContainer from those containers if you think this pull request might move forward.

I am still not able to understand it completely. What is the issue with the current wait strategies?

Using the following configuration hangs the PostgreSQL container forever (the Testcontainers.PostgreSql.PostgreSqlBuilder.WaitUntil.UntilAsync method never returns true):

WithVolumeMount("Testcontainers.PostgreSql.Data", "/var/lib/postgresql/data")

But my idea was not to replace the current strategies, only to add another possible strategy when a DbProviderFactory instance is provided. I have also prototyped this in my UntilDatabaseIsAvailable branch.

Based on our experience, relying solely on the first database connection often leads to unreliable test results. We have frequently observed situations where the databases allowed connections but were not yet ready to process any data, causing the tests to fail. The current implementation of the wait strategies is shared across TC language implementations and is considered stable.

My experience differs. Successfully opening a connection to a database with the most common ADO.NET drivers has been my strategy of choice and worked well for me. This is what I have implemented in my DockerRunner library but I don't plan to maintain it, hence my interest in Testcontainers. 😉

@HofmeisterAn
Copy link
Collaborator

I agree. Happy to remove the IDatabaseContainer from those containers if you think this pull request might move forward.

That would be greate, thanks.

Using the following configuration hangs the PostgreSQL container forever (the Testcontainers.PostgreSql.PostgreSqlBuilder.WaitUntil.UntilAsync method never returns true):

Interesting, I haven't tried it that way. Odd that the log message differs. Can you share the container's output?

But my idea was not to replace the current strategies, only to add another possible strategy when a DbProviderFactory instance is provided. I have also prototyped this in my UntilDatabaseIsAvailable branch.

I am just worried that it won't indicate readiness reliable. We had a couple of issues in the past (but we used the container's CLI tools to test the database connection). As long as it is reliable, we can ship it with TC. Does it make sense to split it in separate PRs?

@0xced
Copy link
Contributor Author

0xced commented Aug 31, 2023

That would be great, thanks.

I have removed IDatabaseContainer from LocalStackContainer and WebDriverContainer in 3a88750.

And since I rebased on the develop branch, I also added the IDatabaseContainer interface to ClickHouse and Kusto in 459250d.

Interesting, I haven't tried it that way. Odd that the log message differs. Can you share the container's output?

I'll open a separate issue about this because there's already enough to discuss. 😉

Does it make sense to split it in separate PRs?

Yes, it totally makes sense in two separate pull requests.

@0xced
Copy link
Contributor Author

0xced commented Sep 19, 2023

I just rebased on the develop branch. Is there anything left to discuss for this new interface?

I'll be able to open a new pull request to discuss a potential new wait strategy once this is merged.

@HofmeisterAn
Copy link
Collaborator

I just rebased on the develop branch. Is there anything left to discuss for this new interface?

I'll be able to open a new pull request to discuss a potential new wait strategy once this is merged.

Sorry for my late response. I will try to take a look at both PRs this week.

Copy link
Collaborator

@HofmeisterAn HofmeisterAn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall, I am fine with the PR, thanks for your patience once again. I still think that it makes more semantic sense to implement the interface only for containers compatible with IDbConnection (Maybe it makes sense to add a common interface to the Testcontainers modules that offers access to the module. However, aligning this interface with module-specific idiomatic language is challenging, as they refer to different names: connection string, endpoint, base address, etc.).

Especially where container instances are shared, as demonstrated in the example above. These instances must have some similarities, and I do not understand the advantages of using the interface for a shared PostgreSQL and Kafka instance. I am curious to hear your thoughts on this matter: @cristianrgreco, @eddumelendez, @kiview, @mdelapenya

@0xced
Copy link
Contributor Author

0xced commented Oct 11, 2023

I still think that it makes more semantic sense to implement the interface only for containers compatible with IDbConnection.

Actually I agree. I have pushed 22f383c which does exactly this. I have improved the test to makes sure that IDatabaseContainer is implemented only for containers that can be accessed with an ADO.NET provider. I used the test projects themselves to determine exactly what containers are compatible with IDbConnection. A container is deemed compatible when its test project references an assembly which is an ADO.NET provider. This is determined by the presence of a DbProviderFactory subclass.

It's a good thing I actually wrote the test like this because I would have missed both ClickHouse and SqlEdge. Also, the day when a Firebird test container will be added, referencing the FirebirdSql.Data.FirebirdClient package in the test project will automatically test that the container must implement IDatabaseContainer.

Here's what the test results look like.

DatabaseContainersTest results

0xced and others added 5 commits October 13, 2023 23:05
This is required in order to implement a generic database fixture that could look like this.

```csharp
using System;
using System.Data.Common;
using System.Threading.Tasks;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Testcontainers.MsSql;
using Testcontainers.MySql;
using Testcontainers.PostgreSql;
using Xunit;

namespace SampleCode;

public abstract class DatabaseFixture<TBuilder, TContainer> : IAsyncLifetime
    where TBuilder : IContainerBuilder<TBuilder, TContainer>, new()
    where TContainer : IContainer, IDatabaseContainer
{
    private string _connectionString;
    private TContainer _container;

    protected abstract DbProviderFactory ProviderFactory { get; }

    public string ConnectionString => _connectionString ?? throw new InvalidOperationException($"{nameof(IAsyncLifetime.InitializeAsync)} must be called before accessing the connection string");

    async Task IAsyncLifetime.InitializeAsync()
    {
        _container = new TBuilder().Build();
        await _container.StartAsync();
        _connectionString = _container.GetConnectionString();
        using var connection = ProviderFactory.CreateConnection() ?? throw new InvalidOperationException($"ProviderFactory.CreateConnection() returned null ({ProviderFactory})");
        connection.ConnectionString = _connectionString;
        await connection.OpenAsync();
        await connection.CloseAsync();
    }

    async Task IAsyncLifetime.DisposeAsync()
    {
        if (_container != null)
        {
            await _container.StopAsync();
        }
    }
}

public class MsSqlDbFixture : DatabaseFixture<MsSqlBuilder, MsSqlContainer>
{
    protected override DbProviderFactory ProviderFactory => Microsoft.Data.SqlClient.SqlClientFactory.Instance;
}

public class MySqlDbFixture : DatabaseFixture<MySqlBuilder, MySqlContainer>
{
    protected override DbProviderFactory ProviderFactory => MySqlConnector.MySqlConnectorFactory.Instance;
}

public class PostgreSqlDbFixture : DatabaseFixture<PostgreSqlBuilder, PostgreSqlContainer>
{
    protected override DbProviderFactory ProviderFactory => Npgsql.NpgsqlFactory.Instance;
}

public class PostgreSqlTest : IClassFixture<PostgreSqlDbFixture>
{
    private readonly PostgreSqlDbFixture _dbFixture;

    public PostgreSqlTest(PostgreSqlDbFixture dbFixture) => _dbFixture = dbFixture;

    [Fact]
    public async Task TestOnPostgreSql()
    {
        await using var connection = new Npgsql.NpgsqlConnection(_dbFixture.ConnectionString);
        await using var command = connection.CreateCommand();

        // ...
    }
}
```

This will also be required to implement a new wait strategy for database where an ADO.NET provider is available.
@HofmeisterAn
Copy link
Collaborator

I used the test projects themselves to determine exactly what containers are compatible with IDbConnection.

It is a smart idea 💡, although it is a bit fuzzy. It works as long as modules do not contain container implementations (n), where one container supports a .NET data provider, and one does not. If that is the case sometime, we will figure something out. For now, it looks good. Thanks. I will merge it later 🎉.

@HofmeisterAn HofmeisterAn added the enhancement New feature or request label Oct 14, 2023
@HofmeisterAn HofmeisterAn changed the title Introduce a new IDatabaseContainer interface feat: Share common interface (IDatabaseContainer) for ADO.NET compatible containers Oct 14, 2023
Copy link
Collaborator

@HofmeisterAn HofmeisterAn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your pull request. Sorry for the delay in getting it upstream. It's a great improvement. Thanks again.

@HofmeisterAn HofmeisterAn merged commit 44a1d3a into testcontainers:develop Oct 14, 2023
9 checks passed
@0xced 0xced deleted the IDatabaseContainer branch October 14, 2023 08:50
@0xced
Copy link
Contributor Author

0xced commented Oct 14, 2023

Awesome, thanks for merging! Also thanks for tinkering the little details such as using JetBrains.Annotations at the right place and the fruitful discussions. 😀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants