diff --git a/Aspire.sln b/Aspire.sln index 305c92b64ad..3fd55c66a03 100644 --- a/Aspire.sln +++ b/Aspire.sln @@ -172,6 +172,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.MongoDB.Driver.Tests EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConfigurationSchemaGenerator.Tests", "tests\ConfigurationSchemaGenerator.Tests\ConfigurationSchemaGenerator.Tests.csproj", "{00FEA181-84C9-42A7-AC81-29A9F176A1A0}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Oracle.EntityFrameworkCore", "src\Components\Aspire.Oracle.EntityFrameworkCore\Aspire.Oracle.EntityFrameworkCore.csproj", "{A778F29A-6C40-4C53-A793-F23F20679ADE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Aspire.Oracle.EntityFrameworkCore.Tests", "tests\Aspire.Oracle.EntityFrameworkCore.Tests\Aspire.Oracle.EntityFrameworkCore.Tests.csproj", "{A331C123-35A5-4E81-9999-354159821374}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -458,6 +462,14 @@ Global {00FEA181-84C9-42A7-AC81-29A9F176A1A0}.Debug|Any CPU.Build.0 = Debug|Any CPU {00FEA181-84C9-42A7-AC81-29A9F176A1A0}.Release|Any CPU.ActiveCfg = Release|Any CPU {00FEA181-84C9-42A7-AC81-29A9F176A1A0}.Release|Any CPU.Build.0 = Release|Any CPU + {A778F29A-6C40-4C53-A793-F23F20679ADE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A778F29A-6C40-4C53-A793-F23F20679ADE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A778F29A-6C40-4C53-A793-F23F20679ADE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A778F29A-6C40-4C53-A793-F23F20679ADE}.Release|Any CPU.Build.0 = Release|Any CPU + {A331C123-35A5-4E81-9999-354159821374}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A331C123-35A5-4E81-9999-354159821374}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A331C123-35A5-4E81-9999-354159821374}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A331C123-35A5-4E81-9999-354159821374}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -537,6 +549,8 @@ Global {20A5A907-A135-4735-B4BF-E13514F360E3} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} {E592E447-BA3C-44FA-86C1-EBEDC864A644} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} {00FEA181-84C9-42A7-AC81-29A9F176A1A0} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} + {A778F29A-6C40-4C53-A793-F23F20679ADE} = {27381127-6C45-4B4C-8F18-41FF48DFE4B2} + {A331C123-35A5-4E81-9999-354159821374} = {4981B3A5-4AFD-4191-BF7D-8692D9783D60} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6DCEDFEC-988E-4CB3-B45B-191EB5086E0C} diff --git a/Directory.Packages.props b/Directory.Packages.props index d66e980bb6d..f18f6ff3b47 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -79,6 +79,7 @@ + @@ -105,4 +106,4 @@ - + \ No newline at end of file diff --git a/src/Aspire.Hosting/Oracle/IOracleDatabaseParentResource.cs b/src/Aspire.Hosting/Oracle/IOracleDatabaseParentResource.cs new file mode 100644 index 00000000000..d5b63d1d050 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/IOracleDatabaseParentResource.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a Oracle Database resource that requires a connection string. +/// +public interface IOracleDatabaseParentResource : IResourceWithConnectionString, IResourceWithEnvironment +{ +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseBuilderExtensions.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseBuilderExtensions.cs new file mode 100644 index 00000000000..4aa5775a73c --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseBuilderExtensions.cs @@ -0,0 +1,105 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Publishing; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Oracle Database resources to an . +/// +public static class OracleDatabaseBuilderExtensions +{ + private const string PasswordEnvVarName = "ORACLE_PWD"; + + /// + /// Adds a Oracle Database container to the application model. The default image is "database/free" and the tag is "latest". + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// The host port for Oracle Database. + /// The password for the Oracle Database container. Defaults to a random password. + /// A reference to the . + public static IResourceBuilder AddOracleDatabaseContainer(this IDistributedApplicationBuilder builder, string name, int? port = null, string? password = null) + { + password = password ?? Guid.NewGuid().ToString("N").Substring(0, 30); + var oracleDatabaseContainer = new OracleDatabaseContainerResource(name, password); + return builder.AddResource(oracleDatabaseContainer) + .WithManifestPublishingCallback(context => WriteOracleDatabaseContainerResourceToManifest(context, oracleDatabaseContainer)) + .WithAnnotation(new EndpointAnnotation(ProtocolType.Tcp, port: port, containerPort: 1521)) + .WithAnnotation(new ContainerImageAnnotation { Image = "database/free", Tag = "latest", Registry = "container-registry.oracle.com" }) + .WithEnvironment(context => + { + if (context.PublisherName == "manifest") + { + context.EnvironmentVariables.Add(PasswordEnvVarName, $"{{{oracleDatabaseContainer.Name}.inputs.password}}"); + } + else + { + context.EnvironmentVariables.Add(PasswordEnvVarName, oracleDatabaseContainer.Password); + } + }); + } + + /// + /// Adds a Oracle Database resource to the application model. A container is used for local development. + /// + /// The . + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// A reference to the . + public static IResourceBuilder AddOracleDatabase(this IDistributedApplicationBuilder builder, string name) + { + var password = Guid.NewGuid().ToString("N").Substring(0, 30); + var oracleDatabaseServer = new OracleDatabaseServerResource(name, password); + return builder.AddResource(oracleDatabaseServer) + .WithManifestPublishingCallback(WriteOracleDatabaseContainerToManifest) + .WithAnnotation(new EndpointAnnotation(ProtocolType.Tcp, containerPort: 1521)) + .WithAnnotation(new ContainerImageAnnotation { Image = "database/free", Tag = "latest", Registry = "container-registry.oracle.com" }) + .WithEnvironment(PasswordEnvVarName, () => oracleDatabaseServer.Password); + } + + /// + /// Adds a Oracle Database database to the application model. + /// + /// The Oracle Database server resource builder. + /// The name of the resource. This name will be used as the connection string name when referenced in a dependency. + /// A reference to the . + public static IResourceBuilder AddDatabase(this IResourceBuilder builder, string name) + { + var oracleDatabase = new OracleDatabaseResource(name, builder.Resource); + return builder.ApplicationBuilder.AddResource(oracleDatabase) + .WithManifestPublishingCallback(context => WriteOracleDatabaseToManifest(context, oracleDatabase)); + } + + private static void WriteOracleDatabaseContainerToManifest(ManifestPublishingContext context) + { + context.Writer.WriteString("type", "oracle.server.v0"); + } + + private static void WriteOracleDatabaseToManifest(ManifestPublishingContext context, OracleDatabaseResource oracleDatabase) + { + context.Writer.WriteString("type", "oracle.database.v0"); + context.Writer.WriteString("parent", oracleDatabase.Parent.Name); + } + + private static void WriteOracleDatabaseContainerResourceToManifest(ManifestPublishingContext context, OracleDatabaseContainerResource resource) + { + context.WriteContainer(resource); + context.Writer.WriteString( // "connectionString": "...", + "connectionString", + $"user id=system;password={{{resource.Name}.inputs.password}};data source={{{resource.Name}.bindings.tcp.host}}:{{{resource.Name}.bindings.tcp.port}};"); + context.Writer.WriteStartObject("inputs"); // "inputs": { + context.Writer.WriteStartObject("password"); // "password": { + context.Writer.WriteString("type", "string"); // "type": "string", + context.Writer.WriteBoolean("secret", true); // "secret": true, + context.Writer.WriteStartObject("default"); // "default": { + context.Writer.WriteStartObject("generate"); // "generate": { + context.Writer.WriteNumber("minLength", 10); // "minLength": 10, + context.Writer.WriteEndObject(); // } + context.Writer.WriteEndObject(); // } + context.Writer.WriteEndObject(); // } + context.Writer.WriteEndObject(); // } + } +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseContainerResource.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseContainerResource.cs new file mode 100644 index 00000000000..a41366cb602 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseContainerResource.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Oracle Database container. +/// +/// The name of the resource. +/// The Oracle Database server password. +public class OracleDatabaseContainerResource(string name, string password) : ContainerResource(name), IOracleDatabaseParentResource +{ + public string Password { get; } = password; + + /// + /// Gets the connection string for the Oracle Database server. + /// + /// A connection string for the Oracle Database server in the form "user id=system;password=password;data source=localhost:port". + public string? GetConnectionString() + { + if (!this.TryGetAllocatedEndPoints(out var allocatedEndpoints)) + { + throw new DistributedApplicationException("Expected allocated endpoints!"); + } + + var allocatedEndpoint = allocatedEndpoints.Single(); // We should only have one endpoint for Oracle Database. + + var connectionString = $"user id=system;password={PasswordUtil.EscapePassword(Password)};data source={allocatedEndpoint.Address}:{allocatedEndpoint.Port}"; + return connectionString; + } +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseResource.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseResource.cs new file mode 100644 index 00000000000..0f9e1dfa271 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseResource.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Oracle Database database. This is a child resource of a . +/// +/// The name of the resource. +/// The Oracle Database parent resource associated with this database. +public class OracleDatabaseResource(string name, IOracleDatabaseParentResource oracleParentResource) : Resource(name), IResourceWithParent, IResourceWithConnectionString +{ + public IOracleDatabaseParentResource Parent { get; } = oracleParentResource; + + /// + /// Gets the connection string for the Oracle Database. + /// + /// A connection string for the Oracle Database. + public string? GetConnectionString() + { + if (Parent.GetConnectionString() is { } connectionString) + { + return $"{connectionString}/{Name}"; + } + else + { + throw new DistributedApplicationException("Parent resource connection string was null."); + } + } +} diff --git a/src/Aspire.Hosting/Oracle/OracleDatabaseServerResource.cs b/src/Aspire.Hosting/Oracle/OracleDatabaseServerResource.cs new file mode 100644 index 00000000000..ecc9ca40d29 --- /dev/null +++ b/src/Aspire.Hosting/Oracle/OracleDatabaseServerResource.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Utils; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// A resource that represents a Oracle Database container. +/// +/// The name of the resource. +/// The Oracle Database server password. +public class OracleDatabaseServerResource(string name, string password) : Resource(name), IOracleDatabaseParentResource +{ + public string Password { get; } = password; + + /// + /// Gets the connection string for the Oracle Database server. + /// + /// A connection string for the Oracle Database server in the form "user id=system;password=password;data source=host:port". + public string? GetConnectionString() + { + if (!this.TryGetAllocatedEndPoints(out var allocatedEndpoints)) + { + throw new DistributedApplicationException("Expected allocated endpoints!"); + } + + var allocatedEndpoint = allocatedEndpoints.Single(); // We should only have one endpoint for Oracle. + + var connectionString = $"user id=system;password={PasswordUtil.EscapePassword(Password)};data source={allocatedEndpoint.Address}:{allocatedEndpoint.Port}"; + return connectionString; + } +} diff --git a/src/Components/Aspire.Oracle.EntityFrameworkCore/Aspire.Oracle.EntityFrameworkCore.csproj b/src/Components/Aspire.Oracle.EntityFrameworkCore/Aspire.Oracle.EntityFrameworkCore.csproj new file mode 100644 index 00000000000..37943f18e42 --- /dev/null +++ b/src/Components/Aspire.Oracle.EntityFrameworkCore/Aspire.Oracle.EntityFrameworkCore.csproj @@ -0,0 +1,25 @@ + + + + $(NetCurrent) + true + $(ComponentEfCorePackageTags) oracle sql + An Oracle Database provider for Entity Framework Core that integrates with Aspire, including connection pooling, health check, logging, and telemetry. + + + + + + + + + + + + + + + + + + diff --git a/src/Components/Aspire.Oracle.EntityFrameworkCore/AspireOracleEFCoreExtensions.cs b/src/Components/Aspire.Oracle.EntityFrameworkCore/AspireOracleEFCoreExtensions.cs new file mode 100644 index 00000000000..1a30234899d --- /dev/null +++ b/src/Components/Aspire.Oracle.EntityFrameworkCore/AspireOracleEFCoreExtensions.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Aspire; +using Aspire.Oracle.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; +using Oracle.EntityFrameworkCore; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods for configuring EntityFrameworkCore DbContext to Oracle database +/// +public static class AspireOracleEFCoreExtensions +{ + private const string DefaultConfigSectionName = "Aspire:Oracle:EntityFrameworkCore"; + private const DynamicallyAccessedMemberTypes RequiredByEF = DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties; + + /// + /// Registers the given as a service in the services provided by the . + /// Configures the connection pooling, health check, logging and telemetry for the . + /// + /// The that needs to be registered. + /// The to read config from and add services to. + /// A name used to retrieve the connection string from the ConnectionStrings configuration section. + /// An optional delegate that can be used for customizing options. It's invoked after the settings are read from the configuration. + /// An optional delegate to configure the for the context. + /// Reads the configuration from "Aspire:Oracle:EntityFrameworkCore:{typeof(TContext).Name}" config section, or "Aspire:Oracle:EntityFrameworkCore" if former does not exist. + /// Thrown if mandatory is null. + /// Thrown when mandatory is not provided. + public static void AddOracleDatabaseDbContext<[DynamicallyAccessedMembers(RequiredByEF)] TContext>( + this IHostApplicationBuilder builder, + string connectionName, + Action? configureSettings = null, + Action? configureDbContextOptions = null) where TContext : DbContext + { + ArgumentNullException.ThrowIfNull(builder); + + OracleEntityFrameworkCoreSettings settings = new(); + var typeSpecificSectionName = $"{DefaultConfigSectionName}:{typeof(TContext).Name}"; + var typeSpecificConfigurationSection = builder.Configuration.GetSection(typeSpecificSectionName); + if (typeSpecificConfigurationSection.Exists()) // https://github.com/dotnet/runtime/issues/91380 + { + typeSpecificConfigurationSection.Bind(settings); + } + else + { + builder.Configuration.GetSection(DefaultConfigSectionName).Bind(settings); + } + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + if (settings.DbContextPooling) + { + builder.Services.AddDbContextPool(ConfigureDbContext); + } + else + { + builder.Services.AddDbContext(ConfigureDbContext); + } + + if (settings.Tracing) + { + builder.Services.AddOpenTelemetry().WithTracing(tracerProviderBuilder => + { + tracerProviderBuilder.AddEntityFrameworkCoreInstrumentation(); + }); + } + + if (settings.Metrics) + { + builder.Services.AddOpenTelemetry().WithMetrics(meterProviderBuilder => + { + meterProviderBuilder.AddEventCountersInstrumentation(eventCountersInstrumentationOptions => + { + // https://github.com/dotnet/efcore/blob/a1cd4f45aa18314bc91d2b9ea1f71a3b7d5bf636/src/EFCore/Infrastructure/EntityFrameworkEventSource.cs#L45 + eventCountersInstrumentationOptions.AddEventSources("Microsoft.EntityFrameworkCore"); + }); + }); + } + + if (settings.HealthChecks) + { + builder.TryAddHealthCheck( + name: typeof(TContext).Name, + static hcBuilder => hcBuilder.AddDbContextCheck()); + } + + void ConfigureDbContext(DbContextOptionsBuilder dbContextOptionsBuilder) + { + if (string.IsNullOrEmpty(settings.ConnectionString)) + { + throw new InvalidOperationException($"ConnectionString is missing. It should be provided in 'ConnectionStrings:{connectionName}' or under the 'ConnectionString' key in '{DefaultConfigSectionName}' or '{typeSpecificSectionName}' configuration section."); + } + + dbContextOptionsBuilder.UseOracle(settings.ConnectionString, builder => + { + // Resiliency: + // Connection resiliency automatically retries failed database commands + if (settings.MaxRetryCount > 0) + { + builder.ExecutionStrategy(context => new OracleRetryingExecutionStrategy(context, settings.MaxRetryCount)); + } + + // The time in seconds to wait for the command to execute. + if (settings.Timeout.HasValue) + { + builder.CommandTimeout(settings.Timeout); + } + }); + + configureDbContextOptions?.Invoke(dbContextOptionsBuilder); + } + } +} diff --git a/src/Components/Aspire.Oracle.EntityFrameworkCore/AssemblyInfo.cs b/src/Components/Aspire.Oracle.EntityFrameworkCore/AssemblyInfo.cs new file mode 100644 index 00000000000..80b98ac96ba --- /dev/null +++ b/src/Components/Aspire.Oracle.EntityFrameworkCore/AssemblyInfo.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Oracle.EntityFrameworkCore; +using Aspire; + +[assembly: ConfigurationSchema("Aspire:Oracle:EntityFrameworkCore", typeof(OracleEntityFrameworkCoreSettings))] + +[assembly: LoggingCategories( + "Microsoft.EntityFrameworkCore", + "Microsoft.EntityFrameworkCore.ChangeTracking", + "Microsoft.EntityFrameworkCore.Database", + "Microsoft.EntityFrameworkCore.Database.Command", + "Microsoft.EntityFrameworkCore.Database.Transaction", + "Microsoft.EntityFrameworkCore.Database.Connection", + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.Migrations", + "Microsoft.EntityFrameworkCore.Model", + "Microsoft.EntityFrameworkCore.Model.Validation", + "Microsoft.EntityFrameworkCore.Query", + "Microsoft.EntityFrameworkCore.Update")] diff --git a/src/Components/Aspire.Oracle.EntityFrameworkCore/ConfigurationSchema.json b/src/Components/Aspire.Oracle.EntityFrameworkCore/ConfigurationSchema.json new file mode 100644 index 00000000000..0f53b36394a --- /dev/null +++ b/src/Components/Aspire.Oracle.EntityFrameworkCore/ConfigurationSchema.json @@ -0,0 +1,91 @@ +{ + "definitions": { + "logLevel": { + "properties": { + "Microsoft.EntityFrameworkCore": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.ChangeTracking": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database.Command": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database.Connection": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Database.Transaction": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Infrastructure": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Migrations": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Model": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Model.Validation": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Query": { + "$ref": "#/definitions/logLevelThreshold" + }, + "Microsoft.EntityFrameworkCore.Update": { + "$ref": "#/definitions/logLevelThreshold" + } + } + } + }, + "properties": { + "Aspire": { + "type": "object", + "properties": { + "Oracle": { + "type": "object", + "properties": { + "EntityFrameworkCore": { + "type": "object", + "properties": { + "ConnectionString": { + "type": "string", + "description": "The connection string of the Oracle database to connect to." + }, + "DbContextPooling": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the db context will be pooled or explicitly created every time it's requested." + }, + "HealthChecks": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the database health check is enabled or not. The default value is true." + }, + "MaxRetryCount": { + "type": "integer", + "description": "Gets or sets the maximum number of retry attempts. The default is 6. Set it to 0 to disable the retry mechanism." + }, + "Metrics": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the Open Telemetry metrics are enabled or not. The default value is true." + }, + "Timeout": { + "type": "integer", + "description": "The time in seconds to wait for the command to execute." + }, + "Tracing": { + "type": "boolean", + "description": "Gets or sets a boolean value that indicates whether the Open Telemetry tracing is enabled or not. The default value is true." + } + }, + "description": "Provides the client configuration settings for connecting to a Oracle database using EntityFrameworkCore." + } + } + } + } + } + }, + "type": "object" +} diff --git a/src/Components/Aspire.Oracle.EntityFrameworkCore/OracleEntityFrameworkCoreSettings.cs b/src/Components/Aspire.Oracle.EntityFrameworkCore/OracleEntityFrameworkCoreSettings.cs new file mode 100644 index 00000000000..6c869a873e8 --- /dev/null +++ b/src/Components/Aspire.Oracle.EntityFrameworkCore/OracleEntityFrameworkCoreSettings.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Aspire.Oracle.EntityFrameworkCore; + +/// +/// Provides the client configuration settings for connecting to a Oracle database using EntityFrameworkCore. +/// +public sealed class OracleEntityFrameworkCoreSettings +{ + /// + /// The connection string of the Oracle database to connect to. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the db context will be pooled or explicitly created every time it's requested. + /// + public bool DbContextPooling { get; set; } = true; + + /// + /// Gets or sets the maximum number of retry attempts. + /// The default is 6. + /// Set it to 0 to disable the retry mechanism. + /// + public int MaxRetryCount { get; set; } = 6; + + /// + /// Gets or sets a boolean value that indicates whether the database health check is enabled or not. + /// The default value is . + /// + public bool HealthChecks { get; set; } = true; + + /// + /// Gets or sets a boolean value that indicates whether the Open Telemetry tracing is enabled or not. + /// The default value is . + /// + public bool Tracing { get; set; } = true; + + /// + /// Gets or sets a boolean value that indicates whether the Open Telemetry metrics are enabled or not. + /// The default value is . + /// + public bool Metrics { get; set; } = true; + + /// + /// The time in seconds to wait for the command to execute. + /// + public int? Timeout { get; set; } +} diff --git a/src/Components/Aspire.Oracle.EntityFrameworkCore/README.md b/src/Components/Aspire.Oracle.EntityFrameworkCore/README.md new file mode 100644 index 00000000000..428ab17d95e --- /dev/null +++ b/src/Components/Aspire.Oracle.EntityFrameworkCore/README.md @@ -0,0 +1,113 @@ +# Aspire.Oracle.EntityFrameworkCore library + +Registers [EntityFrameworkCore](https://learn.microsoft.com/ef/core/) [DbContext](https://learn.microsoft.com/dotnet/api/microsoft.entityframeworkcore.dbcontext) service for connecting Oracle database. Enables connection pooling, health check, logging and telemetry. + +## Getting started + +### Prerequisites + +- Oracle database and connection string for accessing the database. + +### Install the package + +Install the .NET Aspire Oracle EntityFrameworkCore library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Oracle.EntityFrameworkCore +``` + +## Usage example + +In the _Program.cs_ file of your project, call the `AddOracleDatabaseDbContext` extension method to register a `DbContext` for use via the dependency injection container. The method takes a connection name parameter. + +```csharp +builder.AddOracleDatabaseDbContext("orcl"); +``` + +You can then retrieve the `MyDbContext` instance using dependency injection. For example, to retrieve the context from a Web API controller: + +```csharp +private readonly MyDbContext _context; + +public ProductsController(MyDbContext context) +{ + _context = context; +} +``` + +## Configuration + +The .NET Aspire Oracle EntityFrameworkCore component provides multiple options to configure the database connection based on the requirements and conventions of your project. + +### Use a connection string + +When using a connection string from the `ConnectionStrings` configuration section, you can provide the name of the connection string when calling `builder.AddOracleDatabaseDbContext()`: + +```csharp +builder.AddOracleDatabaseDbContext("myConnection"); +``` + +And then the connection string will be retrieved from the `ConnectionStrings` configuration section: + +```json +{ + "ConnectionStrings": { + "myConnection": "Data Source=TORCL;User Id=myUsername;Password=myPassword;" + } +} +``` + +See the [ODP.NET documentation](https://www.oracle.com/database/technologies/appdev/dotnet/odp.html) for more information on how to format this connection string. + +### Use configuration providers + +The .NET Aspire Oracle EntityFrameworkCore component supports [Microsoft.Extensions.Configuration](https://learn.microsoft.com/dotnet/api/microsoft.extensions.configuration). It loads the `OracleEntityFrameworkCoreSettings` from configuration by using the `Aspire:Oracle:EntityFrameworkCore` key. Example `appsettings.json` that configures some of the options: + +```json +{ + "Aspire": { + "Oracle": { + "EntityFrameworkCore": { + "DbContextPooling": true, + "HealthChecks": false, + "Tracing": false, + "Metrics": true + } + } + } +} +``` + +### Use inline delegates + +Also you can pass the `Action configureSettings` delegate to set up some or all the options inline, for example to disable health checks from code: + +```csharp + builder.AddOracleDatabaseDbContext("orcl", settings => settings.HealthChecks = false); +``` + +## AppHost extensions + + In your AppHost project, register an Oracle container and consume the connection using the following methods: + + ```csharp + var freepdb1 = builder.AddOracleDatabase("oracle").AddDatabase("freepdb1"); + + var myService = builder.AddProject() + .WithReference(freepdb1); + ``` + +The `WithReference` method configures a connection in the `MyService` project named `freepdb1`. In the _Program.cs_ file of `MyService`, the database connection can be consumed using: + +```csharp +builder.AddOracleDatabaseDbContext("freepdb1"); +``` + +## Additional documentation + +* https://learn.microsoft.com/ef/core/ +* https://github.com/dotnet/aspire/tree/main/src/Components/README.md + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/src/Components/Aspire_Components_Progress.md b/src/Components/Aspire_Components_Progress.md index 6bfd399c7ac..beec376e8f9 100644 --- a/src/Components/Aspire_Components_Progress.md +++ b/src/Components/Aspire_Components_Progress.md @@ -22,6 +22,7 @@ As part of the .NET Aspire November preview, we want to include a set of .NET As | StackExchange.Redis.OutputCaching | ✅ | ✅ | N/A | ✅ | ✅ | ✅ | ❌ | ✅ | | RabbitMQ | ✅ | ✅ | ✅ | ✅ | | | ❌ | ✅ | | MySqlConnector | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Oracle.EntityFrameworkCore | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Nomenclature used in the table above: diff --git a/src/Components/Telemetry.md b/src/Components/Telemetry.md index f5b76e3e90d..cb8df79853b 100644 --- a/src/Components/Telemetry.md +++ b/src/Components/Telemetry.md @@ -223,6 +223,33 @@ Aspire.Npgsql.EntityFrameworkCore.PostgreSQL: - "db.client.connections.timeouts" - "db.client.connections.usage" +Aspire.Oracle.EntityFrameworkCore: +- Log categories: + - "Microsoft.EntityFrameworkCore.ChangeTracking" + - "Microsoft.EntityFrameworkCore.Database.Command" + - "Microsoft.EntityFrameworkCore.Database.Connection" + - "Microsoft.EntityFrameworkCore.Database.Transaction" + - "Microsoft.EntityFrameworkCore.Infrastructure" + - "Microsoft.EntityFrameworkCore.Migrations" + - "Microsoft.EntityFrameworkCore.Model" + - "Microsoft.EntityFrameworkCore.Model.Validation" + - "Microsoft.EntityFrameworkCore.Query" + - "Microsoft.EntityFrameworkCore.Update" +- Activity source names: + - "OpenTelemetry.Instrumentation.EntityFrameworkCore" +- Metric names: + - "Microsoft.EntityFrameworkCore": + - "ec_Microsoft_EntityFrameworkCore_active_db_contexts" + - "ec_Microsoft_EntityFrameworkCore_total_queries" + - "ec_Microsoft_EntityFrameworkCore_queries_per_second" + - "ec_Microsoft_EntityFrameworkCore_total_save_changes" + - "ec_Microsoft_EntityFrameworkCore_save_changes_per_second" + - "ec_Microsoft_EntityFrameworkCore_compiled_query_cache_hit_rate" + - "ec_Microsoft_Entity_total_execution_strategy_operation_failures" + - "ec_Microsoft_E_execution_strategy_operation_failures_per_second" + - "ec_Microsoft_EntityFramew_total_optimistic_concurrency_failures" + - "ec_Microsoft_EntityF_optimistic_concurrency_failures_per_second" + Aspire.RabbitMQ.Client: - Log categories: - "RabbitMQ.Client" diff --git a/tests/Aspire.Hosting.Tests/Oracle/AddOracleDatabaseTests.cs b/tests/Aspire.Hosting.Tests/Oracle/AddOracleDatabaseTests.cs new file mode 100644 index 00000000000..eaf7e13a7e6 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Oracle/AddOracleDatabaseTests.cs @@ -0,0 +1,206 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Sockets; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Aspire.Hosting.Tests.Oracle; + +public class AddOracleDatabaseTests +{ + [Fact] + public void AddOracleDatabaseWithDefaultsAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddOracleDatabaseContainer("orcl"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.GetContainerResources()); + Assert.Equal("orcl", containerResource.Name); + + var manifestPublishing = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(manifestPublishing.Callback); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("latest", containerAnnotation.Tag); + Assert.Equal("database/free", containerAnnotation.Image); + Assert.Equal("container-registry.oracle.com", containerAnnotation.Registry); + + var serviceBinding = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(1521, serviceBinding.ContainerPort); + Assert.False(serviceBinding.IsExternal); + Assert.Equal("tcp", serviceBinding.Name); + Assert.Null(serviceBinding.Port); + Assert.Equal(ProtocolType.Tcp, serviceBinding.Protocol); + Assert.Equal("tcp", serviceBinding.Transport); + Assert.Equal("tcp", serviceBinding.UriScheme); + + var envAnnotations = containerResource.Annotations.OfType(); + + var config = new Dictionary(); + var context = new EnvironmentCallbackContext("dcp", config); + + foreach (var annotation in envAnnotations) + { + annotation.Callback(context); + } + + Assert.Collection(config, + env => + { + Assert.Equal("ORACLE_PWD", env.Key); + Assert.False(string.IsNullOrEmpty(env.Value)); + }); + } + + [Fact] + public void AddOracleDatabaseAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddOracleDatabaseContainer("orcl", 1234, "pass"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var containerResource = Assert.Single(appModel.GetContainerResources()); + Assert.Equal("orcl", containerResource.Name); + + var manifestPublishing = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(manifestPublishing.Callback); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("latest", containerAnnotation.Tag); + Assert.Equal("database/free", containerAnnotation.Image); + Assert.Equal("container-registry.oracle.com", containerAnnotation.Registry); + + var serviceBinding = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(1521, serviceBinding.ContainerPort); + Assert.False(serviceBinding.IsExternal); + Assert.Equal("tcp", serviceBinding.Name); + Assert.Equal(1234, serviceBinding.Port); + Assert.Equal(ProtocolType.Tcp, serviceBinding.Protocol); + Assert.Equal("tcp", serviceBinding.Transport); + Assert.Equal("tcp", serviceBinding.UriScheme); + + var envAnnotations = containerResource.Annotations.OfType(); + + var config = new Dictionary(); + var context = new EnvironmentCallbackContext("dcp", config); + + foreach (var annotation in envAnnotations) + { + annotation.Callback(context); + } + + Assert.Collection(config, + env => + { + Assert.Equal("ORACLE_PWD", env.Key); + Assert.Equal("pass", env.Value); + }); + } + + [Fact] + public void OracleCreatesConnectionString() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddOracleDatabaseContainer("orcl") + .WithAnnotation( + new AllocatedEndpointAnnotation("mybinding", + ProtocolType.Tcp, + "localhost", + 2000, + "https" + )); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var connectionStringResource = Assert.Single(appModel.Resources.OfType()); + var connectionString = connectionStringResource.GetConnectionString(); + + Assert.StartsWith("user id=system;password=", connectionString); + Assert.EndsWith(";data source=localhost:2000", connectionString); + } + + [Fact] + public void OracleCreatesConnectionStringWithDatabase() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddOracleDatabaseContainer("orcl") + .WithAnnotation( + new AllocatedEndpointAnnotation("mybinding", + ProtocolType.Tcp, + "localhost", + 2000, + "https" + )) + .AddDatabase("db"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + + var oracleResource = Assert.Single(appModel.Resources.OfType()); + var oracleConnectionString = oracleResource.GetConnectionString(); + var oracleDatabaseResource = Assert.Single(appModel.Resources.OfType()); + var dbConnectionString = oracleDatabaseResource.GetConnectionString(); + + Assert.Equal(oracleConnectionString + "/db", dbConnectionString); + } + + [Fact] + public void AddDatabaseToOracleDatabaseAddsAnnotationMetadata() + { + var appBuilder = DistributedApplication.CreateBuilder(); + appBuilder.AddOracleDatabaseContainer("oracle", 1234, "pass").AddDatabase("db"); + + var app = appBuilder.Build(); + + var appModel = app.Services.GetRequiredService(); + var containerResources = appModel.GetContainerResources(); + + var containerResource = Assert.Single(containerResources); + Assert.Equal("oracle", containerResource.Name); + + var manifestPublishing = Assert.Single(containerResource.Annotations.OfType()); + Assert.NotNull(manifestPublishing.Callback); + + var containerAnnotation = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal("latest", containerAnnotation.Tag); + Assert.Equal("database/free", containerAnnotation.Image); + Assert.Equal("container-registry.oracle.com", containerAnnotation.Registry); + + var serviceBinding = Assert.Single(containerResource.Annotations.OfType()); + Assert.Equal(1521, serviceBinding.ContainerPort); + Assert.False(serviceBinding.IsExternal); + Assert.Equal("tcp", serviceBinding.Name); + Assert.Equal(1234, serviceBinding.Port); + Assert.Equal(ProtocolType.Tcp, serviceBinding.Protocol); + Assert.Equal("tcp", serviceBinding.Transport); + Assert.Equal("tcp", serviceBinding.UriScheme); + + var envAnnotations = containerResource.Annotations.OfType(); + + var config = new Dictionary(); + var context = new EnvironmentCallbackContext("dcp", config); + + foreach (var annotation in envAnnotations) + { + annotation.Callback(context); + } + + Assert.Collection(config, + env => + { + Assert.Equal("ORACLE_PWD", env.Key); + Assert.Equal("pass", env.Value); + }); + } +} diff --git a/tests/Aspire.Hosting.Tests/Oracle/OracleContainerResourceTests.cs b/tests/Aspire.Hosting.Tests/Oracle/OracleContainerResourceTests.cs new file mode 100644 index 00000000000..f8a679291b5 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Oracle/OracleContainerResourceTests.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using Aspire.Hosting.Utils; +using Oracle.ManagedDataAccess.Client; +using Xunit; + +namespace Aspire.Hosting.Tests.Oracle; + +public class OracleContainerResourceTests +{ + [Theory()] + [InlineData(["password", "user id=system;password=\"password\";data source=myserver:1521;"])] + [InlineData(["mypasswordwitha\"inthemiddle", "user id=system;password=\"mypasswordwitha\"\"inthemiddle\";data source=myserver:1521;"])] + [InlineData(["mypasswordwitha\"attheend\"", "user id=system;password=\"mypasswordwitha\"\"attheend\"\"\";data source=myserver:1521;"])] + [InlineData(["\"mypasswordwitha\"atthestart", "user id=system;password=\"\"\"mypasswordwitha\"\"atthestart\";data source=myserver:1521;"])] + [InlineData(["mypasswordwitha'inthemiddle", "user id=system;password=\"mypasswordwitha'inthemiddle\";data source=myserver:1521;"])] + [InlineData(["mypasswordwitha'attheend'", "user id=system;password=\"mypasswordwitha'attheend'\";data source=myserver:1521;"])] + [InlineData(["'mypasswordwitha'atthestart", "user id=system;password=\"'mypasswordwitha'atthestart\";data source=myserver:1521;"])] + public void TestEscapeSequencesForPassword(string password, string expectedConnectionString) + { + var connectionStringTemplate = "user id=system;password=\"{0}\";data source=myserver:1521;"; + var escapedPassword = PasswordUtil.EscapePassword(password); + var actualConnectionString = string.Format(CultureInfo.InvariantCulture, connectionStringTemplate, escapedPassword); + + var builder = new OracleConnectionStringBuilder(actualConnectionString); + Assert.Equal(password, builder.Password); + Assert.Equal(expectedConnectionString, actualConnectionString); + } +} diff --git a/tests/Aspire.Hosting.Tests/Oracle/OracleDatabaseFunctionalTests.cs b/tests/Aspire.Hosting.Tests/Oracle/OracleDatabaseFunctionalTests.cs new file mode 100644 index 00000000000..570cbf805a7 --- /dev/null +++ b/tests/Aspire.Hosting.Tests/Oracle/OracleDatabaseFunctionalTests.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.Tests.Helpers; +using Xunit; + +namespace Aspire.Hosting.Tests.Oracle; + +[Collection("IntegrationServices")] +public class OracleDatabaseFunctionalTests +{ + private readonly IntegrationServicesFixture _integrationServicesFixture; + + public OracleDatabaseFunctionalTests(IntegrationServicesFixture integrationServicesFixture) + { + _integrationServicesFixture = integrationServicesFixture; + } + + [LocalOnlyFact()] + public async Task VerifyOracleDatabaseWorks() + { + var testProgram = _integrationServicesFixture.TestProgram; + var client = _integrationServicesFixture.HttpClient; + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + + var response = await testProgram.IntegrationServiceABuilder!.HttpGetAsync(client, "http", "/oracledatabase/verify", cts.Token); + var responseContent = await response.Content.ReadAsStringAsync(); + + Assert.True(response.IsSuccessStatusCode, responseContent); + } +} diff --git a/tests/Aspire.Oracle.EntityFrameworkCore.Tests/Aspire.Oracle.EntityFrameworkCore.Tests.csproj b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/Aspire.Oracle.EntityFrameworkCore.Tests.csproj new file mode 100644 index 00000000000..26e5a5b30ed --- /dev/null +++ b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/Aspire.Oracle.EntityFrameworkCore.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(NetCurrent) + + + + + + + + + + + + + diff --git a/tests/Aspire.Oracle.EntityFrameworkCore.Tests/AspireOracleEFCoreDatabaseExtensionsTests.cs b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/AspireOracleEFCoreDatabaseExtensionsTests.cs new file mode 100644 index 00000000000..c7756e07a66 --- /dev/null +++ b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/AspireOracleEFCoreDatabaseExtensionsTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Oracle.EntityFrameworkCore; +using Oracle.EntityFrameworkCore.Infrastructure.Internal; +using Xunit; + +namespace Aspire.Oracle.EntityFrameworkCore.Tests; + +public class AspireOracleEFCoreDatabaseExtensionsTests +{ + private const string ConnectionString = "Data Source=fake"; + + [Fact] + public void ReadsFromConnectionStringsCorrectly() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:orclconnection", ConnectionString) + ]); + + builder.AddOracleDatabaseDbContext("orclconnection"); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + + Assert.Equal(ConnectionString, context.Database.GetDbConnection().ConnectionString); + } + + [Fact] + public void ConnectionStringCanBeSetInCode() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:orclconnection", "unused") + ]); + + builder.AddOracleDatabaseDbContext("orclconnection", settings => settings.ConnectionString = ConnectionString); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + + var actualConnectionString = context.Database.GetDbConnection().ConnectionString; + Assert.Equal(ConnectionString, actualConnectionString); + // the connection string from config should not be used since code set it explicitly + Assert.DoesNotContain("unused", actualConnectionString); + } + + [Fact] + public void ConnectionNameWinsOverConfigSection() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:Oracle:EntityFrameworkCore:ConnectionString", "unused"), + new KeyValuePair("ConnectionStrings:orclconnection", ConnectionString) + ]); + + builder.AddOracleDatabaseDbContext("orclconnection"); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + + var actualConnectionString = context.Database.GetDbConnection().ConnectionString; + Assert.Equal(ConnectionString, actualConnectionString); + // the connection string from config should not be used since it was found in ConnectionStrings + Assert.DoesNotContain("unused", actualConnectionString); + } + + [Fact] + public void CanConfigureDbContextOptions() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:orclconnection", ConnectionString), + new KeyValuePair("Aspire:Oracle:EntityFrameworkCore:MaxRetryCount", "304"), + new KeyValuePair("Aspire:Oracle:EntityFrameworkCore:Timeout", "608") + ]); + + builder.AddOracleDatabaseDbContext("orclconnection", configureDbContextOptions: optionsBuilder => + { + optionsBuilder.UseOracle(orclBuilder => + { + orclBuilder.MinBatchSize(123); + }); + }); + + var host = builder.Build(); + var context = host.Services.GetRequiredService(); + +#pragma warning disable EF1001 // Internal EF Core API usage. + + var extension = context.Options.FindExtension(); + Assert.NotNull(extension); + + // ensure the min batch size was respected + Assert.Equal(123, extension.MinBatchSize); + + // ensure the connection string from config was respected + var actualConnectionString = context.Database.GetDbConnection().ConnectionString; + Assert.Equal(ConnectionString, actualConnectionString); + + // ensure the max retry count from config was respected + Assert.NotNull(extension.ExecutionStrategyFactory); + var executionStrategy = extension.ExecutionStrategyFactory(new ExecutionStrategyDependencies(new CurrentDbContext(context), context.Options, null!)); + var retryStrategy = Assert.IsType(executionStrategy); + Assert.Equal(304, retryStrategy.MaxRetryCount); + + // ensure the command timeout from config was respected + Assert.Equal(608, extension.CommandTimeout); + +#pragma warning restore EF1001 // Internal EF Core API usage. + } +} diff --git a/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_NoPooling.cs b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_NoPooling.cs new file mode 100644 index 00000000000..a26f7e14d98 --- /dev/null +++ b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_NoPooling.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Aspire.Oracle.EntityFrameworkCore.Tests; + +public class ConformanceTests_NoPooling : ConformanceTests_Pooling +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Scoped; + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + builder.AddOracleDatabaseDbContext("orclconnection", settings => + { + settings.DbContextPooling = false; + configure?.Invoke(settings); + }); + } +} diff --git a/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_NoPooling_TypeSpecificConfig.cs b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_NoPooling_TypeSpecificConfig.cs new file mode 100644 index 00000000000..f4191ebc303 --- /dev/null +++ b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_NoPooling_TypeSpecificConfig.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.Configuration; + +namespace Aspire.Oracle.EntityFrameworkCore.Tests; + +public class ConformanceTests_NoPooling_TypeSpecificConfig : ConformanceTests_NoPooling +{ + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[1] + { + new($"Aspire:Oracle:EntityFrameworkCore:{typeof(TestDbContext).Name}:ConnectionString", ConnectionString) + }); +} diff --git a/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_Pooling.cs b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_Pooling.cs new file mode 100644 index 00000000000..7136b51b65c --- /dev/null +++ b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_Pooling.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Aspire.Components.ConformanceTests; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Aspire.Oracle.EntityFrameworkCore.Tests; + +public class ConformanceTests_Pooling : ConformanceTests +{ + protected const string ConnectionString = "Data Source=fake;"; + + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Singleton; + + // https://github.com/open-telemetry/opentelemetry-dotnet-contrib/blob/cb5b2193ef9cacc0b9ef699e085022577551bf85/src/OpenTelemetry.Instrumentation.EntityFrameworkCore/Implementation/EntityFrameworkDiagnosticListener.cs#L38 + protected override string ActivitySourceName => "OpenTelemetry.Instrumentation.EntityFrameworkCore"; + + protected override string[] RequiredLogCategories => new string[] + { + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.ChangeTracking", + "Microsoft.EntityFrameworkCore.Infrastructure", + "Microsoft.EntityFrameworkCore.Database.Command", + "Microsoft.EntityFrameworkCore.Query", + "Microsoft.EntityFrameworkCore.Database.Transaction", + "Microsoft.EntityFrameworkCore.Database.Connection", + "Microsoft.EntityFrameworkCore.Model", + "Microsoft.EntityFrameworkCore.Model.Validation", + "Microsoft.EntityFrameworkCore.Update", + "Microsoft.EntityFrameworkCore.Migrations" + }; + + protected override string JsonSchemaPath => "src/Components/Aspire.Oracle.EntityFrameworkCore/ConfigurationSchema.json"; + + protected override string ValidJsonConfig => """ + { + "Aspire": { + "Oracle": { + "EntityFrameworkCore": { + "ConnectionString": "YOUR_CONNECTION_STRING", + "HealthChecks": false, + "DbContextPooling": true, + "Tracing": true, + "Metrics": true + } + } + } + } + """; + + protected override (string json, string error)[] InvalidJsonToErrorMessage => new[] + { + ("""{"Aspire": { "Oracle": { "EntityFrameworkCore":{ "MaxRetryCount": "5"}}}}""", "Value is \"string\" but should be \"integer\""), + ("""{"Aspire": { "Oracle": { "EntityFrameworkCore":{ "HealthChecks": "false"}}}}""", "Value is \"string\" but should be \"boolean\""), + ("""{"Aspire": { "Oracle": { "EntityFrameworkCore":{ "ConnectionString": "", "DbContextPooling": "Yes"}}}}""", "Value is \"string\" but should be \"boolean\"") + }; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[1] + { + new KeyValuePair("Aspire:Oracle:EntityFrameworkCore:ConnectionString", ConnectionString) + }); + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + => builder.AddOracleDatabaseDbContext("orclconnection", configure); + + protected override void SetHealthCheck(OracleEntityFrameworkCoreSettings options, bool enabled) + => options.HealthChecks = enabled; + + protected override void SetTracing(OracleEntityFrameworkCoreSettings options, bool enabled) + => options.Tracing = enabled; + + protected override void SetMetrics(OracleEntityFrameworkCoreSettings options, bool enabled) + => options.Metrics = enabled; + + protected override void TriggerActivity(TestDbContext service) + { + if (service.Database.CanConnect()) + { + service.Database.EnsureCreated(); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DbContextPoolingRegistersIDbContextPool(bool enabled) + { + using IHost host = CreateHostWithComponent(options => options.DbContextPooling = enabled); + +#pragma warning disable EF1001 // Internal EF Core API usage. + IDbContextPool? pool = host.Services.GetService>(); +#pragma warning restore EF1001 + + Assert.Equal(enabled, pool is not null); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DbContextCanBeAlwaysResolved(bool enabled) + { + using IHost host = CreateHostWithComponent(options => options.DbContextPooling = enabled); + + TestDbContext? dbContext = host.Services.GetService(); + + Assert.NotNull(dbContext); + } + + [ConditionalFact] + public void TracingEnablesTheRightActivitySource() + { + SkipIfCanNotConnectToServer(); + + RemoteExecutor.Invoke(() => ActivitySourceTest(key: null)).Dispose(); + } +} diff --git a/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_Pooling_TypeSpecificConfig.cs b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_Pooling_TypeSpecificConfig.cs new file mode 100644 index 00000000000..c75e7d7d9a6 --- /dev/null +++ b/tests/Aspire.Oracle.EntityFrameworkCore.Tests/ConformanceTests_Pooling_TypeSpecificConfig.cs @@ -0,0 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Components.Common.Tests; +using Microsoft.Extensions.Configuration; + +namespace Aspire.Oracle.EntityFrameworkCore.Tests; + +public class ConformanceTests_Pooling_TypeSpecificConfig : ConformanceTests_Pooling +{ + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + => configuration.AddInMemoryCollection(new KeyValuePair[1] + { + new($"Aspire:Oracle:EntityFrameworkCore:{typeof(TestDbContext).Name}:ConnectionString", ConnectionString) + }); +} diff --git a/tests/testproject/TestProject.AppHost/TestProgram.cs b/tests/testproject/TestProject.AppHost/TestProgram.cs index 5e7ef5945ec..c4902b06984 100644 --- a/tests/testproject/TestProject.AppHost/TestProgram.cs +++ b/tests/testproject/TestProject.AppHost/TestProgram.cs @@ -36,6 +36,7 @@ private TestProgram(string[] args, Assembly assembly, bool includeIntegrationSer var mysqlDbName = "mysqldb"; var postgresDbName = "postgresdb"; var mongoDbName = "mymongodb"; + var oracleDbName = "freepdb1"; var sqlserverContainer = AppBuilder.AddSqlServerContainer("sqlservercontainer") .AddDatabase(sqlserverDbName); @@ -49,6 +50,8 @@ private TestProgram(string[] args, Assembly assembly, bool includeIntegrationSer var rabbitmqContainer = AppBuilder.AddRabbitMQContainer("rabbitmqcontainer"); var mongodbContainer = AppBuilder.AddMongoDBContainer("mongodbcontainer") .AddDatabase(mongoDbName); + var oracleDatabaseContainer = AppBuilder.AddOracleDatabaseContainer("oracledatabasecontainer") + .AddDatabase(oracleDbName); var sqlserverAbstract = AppBuilder.AddSqlServerContainer("sqlserverabstract"); var mysqlAbstract = AppBuilder.AddMySqlContainer("mysqlabstract"); @@ -56,6 +59,7 @@ private TestProgram(string[] args, Assembly assembly, bool includeIntegrationSer var postgresAbstract = AppBuilder.AddPostgresContainer("postgresabstract"); var rabbitmqAbstract = AppBuilder.AddRabbitMQContainer("rabbitmqabstract"); var mongodbAbstract = AppBuilder.AddMongoDB("mongodbabstract"); + var oracleDatabaseAbstract = AppBuilder.AddOracleDatabaseContainer("oracledatabaseabstract"); IntegrationServiceABuilder = AppBuilder.AddProject("integrationservicea") .WithReference(sqlserverContainer) @@ -64,12 +68,14 @@ private TestProgram(string[] args, Assembly assembly, bool includeIntegrationSer .WithReference(postgresContainer) .WithReference(rabbitmqContainer) .WithReference(mongodbContainer) + .WithReference(oracleDatabaseContainer) .WithReference(sqlserverAbstract) .WithReference(mysqlAbstract) .WithReference(redisAbstract) .WithReference(postgresAbstract) .WithReference(rabbitmqAbstract) - .WithReference(mongodbAbstract); + .WithReference(mongodbAbstract) + .WithReference(oracleDatabaseAbstract); } } diff --git a/tests/testproject/TestProject.IntegrationServiceA/Oracle/MyDbContext.cs b/tests/testproject/TestProject.IntegrationServiceA/Oracle/MyDbContext.cs new file mode 100644 index 00000000000..0ff75373c8b --- /dev/null +++ b/tests/testproject/TestProject.IntegrationServiceA/Oracle/MyDbContext.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore; + +public class MyDbContext : DbContext +{ + public MyDbContext(DbContextOptions options) : base(options) + { + } +} diff --git a/tests/testproject/TestProject.IntegrationServiceA/Oracle/OracleDatabaseExtensions.cs b/tests/testproject/TestProject.IntegrationServiceA/Oracle/OracleDatabaseExtensions.cs new file mode 100644 index 00000000000..43fb2c3f731 --- /dev/null +++ b/tests/testproject/TestProject.IntegrationServiceA/Oracle/OracleDatabaseExtensions.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore; + +public static class OracleDatabaseExtensions +{ + public static void MapOracleDatabaseApi(this WebApplication app) + { + app.MapGet("/oracledatabase/verify", VerifyOracleDatabase); + } + + private static IResult VerifyOracleDatabase(MyDbContext context) + { + try + { + var results = context.Database.SqlQueryRaw("SELECT 1 FROM DUAL"); + return results.Any() ? Results.Ok("Success!") : Results.Problem("Failed"); + } + catch (Exception e) + { + return Results.Problem(e.ToString()); + } + } +} diff --git a/tests/testproject/TestProject.IntegrationServiceA/Program.cs b/tests/testproject/TestProject.IntegrationServiceA/Program.cs index b809f3e7d84..ce1f4803caa 100644 --- a/tests/testproject/TestProject.IntegrationServiceA/Program.cs +++ b/tests/testproject/TestProject.IntegrationServiceA/Program.cs @@ -8,6 +8,7 @@ builder.AddNpgsqlDataSource("postgresdb"); builder.AddRabbitMQ("rabbitmqcontainer"); builder.AddMongoDBClient("mymongodb"); +builder.AddOracleDatabaseDbContext("freepdb1"); builder.AddKeyedSqlServerClient("sqlserverabstract"); builder.AddKeyedMySqlDataSource("mysqlabstract"); @@ -36,4 +37,6 @@ app.MapRabbitMQApi(); +app.MapOracleDatabaseApi(); + app.Run(); diff --git a/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj b/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj index 45a3272abcc..4b8406eeada 100644 --- a/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj +++ b/tests/testproject/TestProject.IntegrationServiceA/TestProject.IntegrationServiceA.csproj @@ -14,6 +14,7 @@ +