diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f95435e03..ee429eeef 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -30,6 +30,7 @@ jobs: Hosting.Dapr.Tests, Hosting.DbGate.Tests, Hosting.Deno.Tests, + Hosting.DuckDB.Tests, Hosting.Elasticsearch.Extensions.Tests, Hosting.Flagd.Tests, Hosting.Flyway.Tests, @@ -68,6 +69,7 @@ jobs: Hosting.Zitadel.Tests, # Client integration tests + DuckDB.NET.Data.Tests, GoFeatureFlag.Tests, KurrentDB.Tests, MassTransit.RabbitMQ.Tests, diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index 73ed3e46d..39a8a0494 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -33,6 +33,11 @@ + + + + + @@ -204,6 +209,7 @@ + @@ -238,6 +244,7 @@ + @@ -265,6 +272,7 @@ + @@ -299,6 +307,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 668a4938a..e7fc092dd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -75,6 +75,7 @@ + diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/CommunityToolkit.Aspire.DuckDB.Api.csproj b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/CommunityToolkit.Aspire.DuckDB.Api.csproj new file mode 100644 index 000000000..849acf26d --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/CommunityToolkit.Aspire.DuckDB.Api.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/Program.cs b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/Program.cs new file mode 100644 index 000000000..1c9a56ad2 --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/Program.cs @@ -0,0 +1,123 @@ +using DuckDB.NET.Data; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); +builder.Services.AddProblemDetails(); + +builder.AddDuckDBConnection("analytics"); + +var app = builder.Build(); +app.UseExceptionHandler(); +app.MapDefaultEndpoints(); + +await SeedDatabase(app); + +app.MapGet("/", () => "DuckDB Analytics API"); + +var analyticsGroup = app.MapGroup("/analytics"); + +analyticsGroup.MapGet("/summary", async (DuckDBConnection db) => +{ + await db.OpenAsync(); + using var command = db.CreateCommand(); + command.CommandText = """ + SELECT + category, + COUNT(*) as total_orders, + CAST(SUM(amount) AS DOUBLE) as total_revenue, + CAST(AVG(amount) AS DOUBLE) as avg_order_value + FROM orders + GROUP BY category + ORDER BY total_revenue DESC + """; + using var reader = await command.ExecuteReaderAsync(); + + var results = new List(); + while (await reader.ReadAsync()) + { + results.Add(new + { + Category = reader.GetString(0), + TotalOrders = reader.GetInt64(1), + TotalRevenue = reader.GetDouble(2), + AvgOrderValue = reader.GetDouble(3) + }); + } + return Results.Ok(results); +}); + +analyticsGroup.MapPost("/orders", async (DuckDBConnection db) => +{ + await db.OpenAsync(); + using var command = db.CreateCommand(); + command.CommandText = """ + INSERT INTO orders (category, product, amount, order_date) VALUES + ('Electronics', 'Laptop', 999.99, CURRENT_TIMESTAMP) + """; + await command.ExecuteNonQueryAsync(); + return Results.Created("/analytics/summary", null); +}); + +analyticsGroup.MapGet("/orders", async (DuckDBConnection db) => +{ + await db.OpenAsync(); + using var command = db.CreateCommand(); + command.CommandText = "SELECT id, category, product, amount, order_date FROM orders ORDER BY order_date DESC LIMIT 100"; + using var reader = await command.ExecuteReaderAsync(); + + var results = new List(); + while (await reader.ReadAsync()) + { + results.Add(new + { + Id = reader.GetInt32(0), + Category = reader.GetString(1), + Product = reader.GetString(2), + Amount = reader.GetDouble(3), + OrderDate = reader.GetDateTime(4) + }); + } + return Results.Ok(results); +}); + +app.Run(); + +static async Task SeedDatabase(WebApplication app) +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.OpenAsync(); + + using var createCommand = db.CreateCommand(); + createCommand.CommandText = """ + CREATE SEQUENCE IF NOT EXISTS orders_id_seq START 1; + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER DEFAULT nextval('orders_id_seq'), + category VARCHAR, + product VARCHAR, + amount DOUBLE, + order_date TIMESTAMP + ) + """; + await createCommand.ExecuteNonQueryAsync(); + + // Check if data already exists + using var countCommand = db.CreateCommand(); + countCommand.CommandText = "SELECT COUNT(*) FROM orders"; + var count = (long)(await countCommand.ExecuteScalarAsync())!; + + if (count == 0) + { + using var seedCommand = db.CreateCommand(); + seedCommand.CommandText = """ + INSERT INTO orders (id, category, product, amount, order_date) VALUES + (1, 'Electronics', 'Laptop', 1299.99, '2025-01-15 10:30:00'), + (2, 'Electronics', 'Phone', 899.99, '2025-01-16 14:20:00'), + (3, 'Books', 'Programming Guide', 49.99, '2025-01-17 09:15:00'), + (4, 'Books', 'Data Science Manual', 59.99, '2025-01-18 11:45:00'), + (5, 'Clothing', 'T-Shirt', 29.99, '2025-01-19 16:00:00') + """; + await seedCommand.ExecuteNonQueryAsync(); + } +} diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/Properties/launchSettings.json b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/Properties/launchSettings.json new file mode 100644 index 000000000..08a980dea --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5301", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7301;http://localhost:5301", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/CommunityToolkit.Aspire.DuckDB.AppHost.csproj b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/CommunityToolkit.Aspire.DuckDB.AppHost.csproj new file mode 100644 index 000000000..40c3d14cb --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/CommunityToolkit.Aspire.DuckDB.AppHost.csproj @@ -0,0 +1,14 @@ + + + + Exe + true + duckdb-apphost-secrets + + + + + + + + diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/Program.cs b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/Program.cs new file mode 100644 index 000000000..0ffdbd923 --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/Program.cs @@ -0,0 +1,8 @@ +var builder = DistributedApplication.CreateBuilder(args); + +var duckdb = builder.AddDuckDB("analytics"); + +var api = builder.AddProject("api") + .WithReference(duckdb); + +builder.Build().Run(); diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/Properties/launchSettings.json b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..60b393d7c --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/Properties/launchSettings.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:17280;http://localhost:15176", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21290", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22189" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:15176", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19099", + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20223" + } + } + } +} diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/appsettings.json b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/appsettings.json new file mode 100644 index 000000000..31c092aa4 --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.ServiceDefaults/CommunityToolkit.Aspire.DuckDB.ServiceDefaults.csproj b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.ServiceDefaults/CommunityToolkit.Aspire.DuckDB.ServiceDefaults.csproj new file mode 100644 index 000000000..c567fa5de --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.ServiceDefaults/CommunityToolkit.Aspire.DuckDB.ServiceDefaults.csproj @@ -0,0 +1,19 @@ + + + + true + + + + + + + + + + + + + + + diff --git a/examples/duckdb/CommunityToolkit.Aspire.DuckDB.ServiceDefaults/Extensions.cs b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.ServiceDefaults/Extensions.cs new file mode 100644 index 000000000..34d240061 --- /dev/null +++ b/examples/duckdb/CommunityToolkit.Aspire.DuckDB.ServiceDefaults/Extensions.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.ServiceDiscovery; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +public static class Extensions +{ + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + http.AddStandardResilienceHandler(); + + http.AddServiceDiscovery(); + }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + if (app.Environment.IsDevelopment()) + { + app.MapHealthChecks("/health"); + + app.MapHealthChecks("/alive", new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/src/CommunityToolkit.Aspire.DuckDB.NET.Data/AspireDuckDBExtensions.cs b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/AspireDuckDBExtensions.cs new file mode 100644 index 000000000..87d64e7f9 --- /dev/null +++ b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/AspireDuckDBExtensions.cs @@ -0,0 +1,120 @@ +using Aspire; +using DuckDB.NET.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Provides extension methods for registering DuckDB-related services in an . +/// +public static class AspireDuckDBExtensions +{ + private const string DefaultConfigSectionName = "Aspire:DuckDB:Client"; + + /// + /// Registers as scoped in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:DuckDB:Client" section. + /// If required ConnectionString is not provided in configuration section. + public static void AddDuckDBConnection( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) => + AddDuckDBClient(builder, DefaultConfigSectionName, configureSettings, name, serviceKey: null); + + /// + /// Registers as keyed scoped for the given in the services provided by the . + /// + /// The to read config from and add services to. + /// The connection name to use to find a connection string. + /// An optional method that can be used for customizing the . It's invoked after the settings are read from the configuration. + /// Reads the configuration from "Aspire:DuckDB:Client" section. + /// If required ConnectionString is not provided in configuration section. + public static void AddKeyedDuckDBConnection( + this IHostApplicationBuilder builder, + string name, + Action? configureSettings = null) => + AddDuckDBClient(builder, $"{DefaultConfigSectionName}:{name}", configureSettings, connectionName: name, serviceKey: name); + + private static void AddDuckDBClient( + this IHostApplicationBuilder builder, + string configurationSectionName, + Action? configureSettings, + string connectionName, + object? serviceKey) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrEmpty(connectionName); + + DuckDBConnectionSettings settings = new(); + var configSection = builder.Configuration.GetSection(configurationSectionName); + configSection.Bind(settings); + + if (builder.Configuration.GetConnectionString(connectionName) is string connectionString) + { + settings.ConnectionString = connectionString; + } + + configureSettings?.Invoke(settings); + + builder.RegisterDuckDBServices(settings, connectionName, serviceKey); + + if (!settings.DisableHealthChecks) + { + builder.TryAddHealthCheck(new HealthCheckRegistration( + serviceKey is null ? "DuckDB" : $"DuckDB_{connectionName}", + sp => new DuckDBHealthCheck(settings.ConnectionString), + failureStatus: default, + tags: default, + timeout: default)); + } + } + + private static void RegisterDuckDBServices( + this IHostApplicationBuilder builder, + DuckDBConnectionSettings settings, + string connectionName, + object? serviceKey) + { + if (serviceKey is null) + { + builder.Services.AddScoped(sp => CreateConnection(sp, null)); + } + else + { + builder.Services.AddKeyedScoped(serviceKey, CreateConnection); + } + + DuckDBConnection CreateConnection(IServiceProvider sp, object? key) + { + ConnectionStringValidation.ValidateConnectionString(settings.ConnectionString, connectionName, DefaultConfigSectionName); + return new DuckDBConnection(settings.ConnectionString!); + } + } + + private sealed class DuckDBHealthCheck(string? connectionString) : IHealthCheck + { + public async Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + using var connection = new DuckDBConnection(connectionString ?? string.Empty); + await connection.OpenAsync(cancellationToken); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1"; + await command.ExecuteScalarAsync(cancellationToken); + return HealthCheckResult.Healthy(); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy(exception: ex); + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.DuckDB.NET.Data/CommunityToolkit.Aspire.DuckDB.NET.Data.csproj b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/CommunityToolkit.Aspire.DuckDB.NET.Data.csproj new file mode 100644 index 000000000..6471a7806 --- /dev/null +++ b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/CommunityToolkit.Aspire.DuckDB.NET.Data.csproj @@ -0,0 +1,20 @@ + + + + client duckdb analytics olap ado.net + An Aspire client integration for the DuckDB.NET.Data package. + + + + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.DuckDB.NET.Data/DuckDBConnectionSettings.cs b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/DuckDBConnectionSettings.cs new file mode 100644 index 000000000..86f391ec8 --- /dev/null +++ b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/DuckDBConnectionSettings.cs @@ -0,0 +1,20 @@ +namespace Microsoft.Extensions.Hosting; + +/// +/// Represents the settings for the DuckDB client. +/// +public sealed class DuckDBConnectionSettings +{ + /// + /// The connection string of the DuckDB database to connect to. + /// + public string? ConnectionString { get; set; } + + /// + /// Gets or sets a boolean value that indicates whether the database health check is disabled or not. + /// + /// + /// The default value is . + /// + public bool DisableHealthChecks { get; set; } +} diff --git a/src/CommunityToolkit.Aspire.DuckDB.NET.Data/README.md b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/README.md new file mode 100644 index 000000000..ad65ad5e3 --- /dev/null +++ b/src/CommunityToolkit.Aspire.DuckDB.NET.Data/README.md @@ -0,0 +1,45 @@ +# CommunityToolkit.Aspire.DuckDB.NET.Data library + +Register a `DuckDBConnection` in the DI container to interact with a DuckDB database using ADO.NET. + +DuckDB is an in-process analytical (OLAP) database, ideal for analytics workloads, Parquet/CSV querying, and data processing. + +## Getting Started + +### Prerequisites + +- A DuckDB database + +### Install the package + +Install the Aspire DuckDB client library using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.DuckDB.NET.Data +``` + +### Example usage + +In the _Program.cs_ file of your project, call the `AddDuckDBConnection` extension method to register the `DuckDBConnection` implementation in the DI container. This method takes the connection name as a parameter: + +```csharp +builder.AddDuckDBConnection("analytics"); +``` + +Then, in your service, inject `DuckDBConnection` and use it to interact with the database: + +```csharp +public class AnalyticsService(DuckDBConnection db) +{ + // ... +} +``` + +## Additional documentation + +- https://duckdb.org +- https://github.com/Giorgi/DuckDB.NET + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/src/CommunityToolkit.Aspire.Hosting.DuckDB/CommunityToolkit.Aspire.Hosting.DuckDB.csproj b/src/CommunityToolkit.Aspire.Hosting.DuckDB/CommunityToolkit.Aspire.Hosting.DuckDB.csproj new file mode 100644 index 000000000..832099669 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.DuckDB/CommunityToolkit.Aspire.Hosting.DuckDB.csproj @@ -0,0 +1,14 @@ + + + An Aspire hosting integration for providing a DuckDB database connection. + hosting duckdb analytics olap + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.DuckDB/DuckDBResource.cs b/src/CommunityToolkit.Aspire.Hosting.DuckDB/DuckDBResource.cs new file mode 100644 index 000000000..f200ee8b7 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.DuckDB/DuckDBResource.cs @@ -0,0 +1,29 @@ +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a resource for a DuckDB database with a specified name and database path. +/// +/// The name of the resource. +/// The path to the database directory. +/// The filename of the database file. Must include extension. +public class DuckDBResource(string name, string databasePath, string databaseFileName) : Resource(name), IResourceWithConnectionString +{ + internal string DatabasePath { get; set; } = databasePath; + + internal string DatabaseFileName { get; set; } = databaseFileName; + + internal string DatabaseFilePath => Path.Combine(DatabasePath, DatabaseFileName); + + internal bool IsReadOnly { get; set; } + + /// + public ReferenceExpression ConnectionStringExpression => + IsReadOnly + ? ReferenceExpression.Create($"DataSource={DatabaseFilePath};Access Mode=ReadOnly") + : ReferenceExpression.Create($"DataSource={DatabaseFilePath}"); + + IEnumerable> IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("DataSource", ReferenceExpression.Create($"{DatabaseFilePath}")); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.DuckDB/DuckDBResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.DuckDB/DuckDBResourceBuilderExtensions.cs new file mode 100644 index 000000000..e6223c6b6 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.DuckDB/DuckDBResourceBuilderExtensions.cs @@ -0,0 +1,69 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding DuckDB resources to an application builder. +/// +public static class DuckDBResourceBuilderExtensions +{ + /// + /// Adds a DuckDB resource to the application builder. + /// + /// The application builder. + /// The name of the resource. + /// The optional path to the database file. If no path is provided the database is stored in a temporary location. + /// The filename of the database file. Must include extension. If no file name is provided, a randomly generated file name is used. + /// A resource builder for the DuckDB resource. + public static IResourceBuilder AddDuckDB(this IDistributedApplicationBuilder builder, [ResourceName] string name, string? databasePath = null, string? databaseFileName = null) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(name, nameof(name)); + + var resource = new DuckDBResource(name, databasePath ?? Path.GetTempPath(), databaseFileName ?? $"{Path.GetFileName(Path.GetRandomFileName())}.duckdb"); + + builder.Eventing.Subscribe((_, ct) => + { + // Ensure the directory exists; DuckDB creates the database file on first connection. + Directory.CreateDirectory(resource.DatabasePath); + + if (!OperatingSystem.IsWindows() && File.Exists(resource.DatabaseFilePath)) + { + const UnixFileMode OwnershipPermissions = + UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite; + + File.SetUnixFileMode(resource.DatabaseFilePath, OwnershipPermissions); + } + + return Task.CompletedTask; + }); + + var state = new CustomResourceSnapshot() + { + State = new(KnownResourceStates.Running, KnownResourceStateStyles.Success), + ResourceType = "DuckDB", + Properties = [ + new("DatabasePath", resource.DatabasePath), + new("DatabaseFileName", resource.DatabaseFileName) + ] + }; + return builder.AddResource(resource) + .WithInitialState(state); + } + + /// + /// Configures the DuckDB resource to open the database in read-only mode. + /// + /// The resource builder. + /// A resource builder for the DuckDB resource. + public static IResourceBuilder WithReadOnly(this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder, nameof(builder)); + + builder.Resource.IsReadOnly = true; + + return builder; + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.DuckDB/README.md b/src/CommunityToolkit.Aspire.Hosting.DuckDB/README.md new file mode 100644 index 000000000..3ab395b46 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.DuckDB/README.md @@ -0,0 +1,40 @@ +# CommunityToolkit.Aspire.Hosting.DuckDB library + +Provides extension methods and resource definitions for the Aspire AppHost to support creating and running DuckDB databases. + +DuckDB is an in-process analytical (OLAP) database, ideal for analytics workloads, Parquet/CSV querying, and data processing. + +By default, the DuckDB resource will create a new DuckDB database in a temporary location. You can also specify a path to an existing DuckDB database file. + +## Getting Started + +### Install the package + +In your AppHost project, install the package using the following command: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.DuckDB +``` + +### Example usage + +Then, in the _Program.cs_ file of `AppHost`, define a DuckDB resource, then call `AddDuckDB`: + +```csharp +var duckdb = builder.AddDuckDB("analytics"); +``` + +To use a read-only database for analytics: + +```csharp +var duckdb = builder.AddDuckDB("warehouse") + .WithReadOnly(); +``` + +## Additional Information + +https://duckdb.org + +## Feedback & contributing + +https://github.com/CommunityToolkit/Aspire diff --git a/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests.csproj b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests.csproj new file mode 100644 index 000000000..39bbfb159 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/ConfigurationTests.cs b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/ConfigurationTests.cs new file mode 100644 index 000000000..81c946736 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/ConfigurationTests.cs @@ -0,0 +1,38 @@ +using DuckDB.NET.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.DuckDB.NET.Data.Tests; + +public class ConfigurationTests +{ + [Fact] + public void ConnectionStringFromConfiguration() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("Aspire:DuckDB:Client:ConnectionString", "DataSource=:memory:") + ]); + + builder.AddDuckDBConnection("duckdb"); + + using var host = builder.Build(); + var connection = host.Services.GetRequiredService(); + + Assert.Equal("DataSource=:memory:", connection.ConnectionString); + } + + [Fact] + public void ThrowsWhenNoConnectionString() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + + builder.AddDuckDBConnection("duckdb"); + + using var host = builder.Build(); + + // Should throw when trying to resolve the connection because no connection string is configured + Assert.Throws(() => + host.Services.GetRequiredService()); + } +} diff --git a/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/ConformanceTests.cs b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/ConformanceTests.cs new file mode 100644 index 000000000..4456791fd --- /dev/null +++ b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/ConformanceTests.cs @@ -0,0 +1,67 @@ +using Aspire.Components.ConformanceTests; +using DuckDB.NET.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.DuckDB.NET.Data.Tests; + +public class ConformanceTests : ConformanceTests +{ + protected override ServiceLifetime ServiceLifetime => ServiceLifetime.Scoped; + + protected override string ActivitySourceName => string.Empty; + + protected override string[] RequiredLogCategories => []; + + protected override void PopulateConfiguration(ConfigurationManager configuration, string? key = null) + { + configuration.AddInMemoryCollection( + [ + new(CreateConfigKey("Aspire:DuckDB:Client", key, "ConnectionString"), "DataSource=:memory:"), + new("ConnectionStrings:duckdb", "DataSource=:memory:") + ]); + } + + protected override void RegisterComponent(HostApplicationBuilder builder, Action? configure = null, string? key = null) + { + if (key is null) + { + builder.AddDuckDBConnection("duckdb", configure); + } + else + { + builder.AddKeyedDuckDBConnection(key, configure); + } + } + + protected override void SetHealthCheck(DuckDBConnectionSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void SetMetrics(DuckDBConnectionSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void SetTracing(DuckDBConnectionSettings options, bool enabled) + { + throw new NotImplementedException(); + } + + protected override void TriggerActivity(DuckDBConnection service) + { + service.Open(); + } + + protected override string ValidJsonConfig => + """ + { + "Aspire": { + "DuckDB": { + "ConnectionString": "DataSource=:memory:" + } + } + } + """; +} diff --git a/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/DuckDBConnectionTests.cs b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/DuckDBConnectionTests.cs new file mode 100644 index 000000000..21c7ec0da --- /dev/null +++ b/tests/CommunityToolkit.Aspire.DuckDB.NET.Data.Tests/DuckDBConnectionTests.cs @@ -0,0 +1,89 @@ +using DuckDB.NET.Data; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +namespace CommunityToolkit.Aspire.DuckDB.NET.Data.Tests; + +public class DuckDBConnectionTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadsFromConnectionStringCorrectly(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:duckdb", "DataSource=:memory:") + ]); + + if (useKeyed) + { + builder.AddKeyedDuckDBConnection("duckdb"); + } + else + { + builder.AddDuckDBConnection("duckdb"); + } + + using var host = builder.Build(); + + var client = useKeyed ? + host.Services.GetRequiredKeyedService("duckdb") : + host.Services.GetRequiredService(); + + Assert.NotNull(client.ConnectionString); + Assert.Contains("DataSource=:memory:", client.ConnectionString); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CanSetConnectionStringInCode(bool useKeyed) + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:duckdb", "DataSource=/tmp/not-used.duckdb") + ]); + + if (useKeyed) + { + builder.AddKeyedDuckDBConnection("duckdb", settings => settings.ConnectionString = "DataSource=:memory:"); + } + else + { + builder.AddDuckDBConnection("duckdb", settings => settings.ConnectionString = "DataSource=:memory:"); + } + + using var host = builder.Build(); + var client = useKeyed ? + host.Services.GetRequiredKeyedService("duckdb") : + host.Services.GetRequiredService(); + + Assert.NotNull(client.ConnectionString); + Assert.Contains("DataSource=:memory:", client.ConnectionString); + } + + [Fact] + public void CanSetMultipleKeyedConnections() + { + var builder = Host.CreateEmptyApplicationBuilder(null); + builder.Configuration.AddInMemoryCollection([ + new KeyValuePair("ConnectionStrings:duckdb1", "DataSource=/tmp/duckdb1.duckdb"), + new KeyValuePair("ConnectionStrings:duckdb2", "DataSource=/tmp/duckdb2.duckdb") + ]); + + builder.AddKeyedDuckDBConnection("duckdb1"); + builder.AddKeyedDuckDBConnection("duckdb2"); + + using var host = builder.Build(); + + var client1 = host.Services.GetRequiredKeyedService("duckdb1"); + var client2 = host.Services.GetRequiredKeyedService("duckdb2"); + + Assert.NotNull(client1.ConnectionString); + Assert.Contains("duckdb1", client1.ConnectionString); + + Assert.NotNull(client2.ConnectionString); + Assert.Contains("duckdb2", client2.ConnectionString); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/AddDuckDBTests.cs b/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/AddDuckDBTests.cs new file mode 100644 index 000000000..a6bada43c --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/AddDuckDBTests.cs @@ -0,0 +1,126 @@ +using Aspire.Hosting; +using CommunityToolkit.Aspire.Testing; + +namespace CommunityToolkit.Aspire.Hosting.DuckDB; + +public class AddDuckDBTests +{ + [Fact] + public void DistributedApplicationBuilderCannotBeNull() + { + Assert.Throws(() => DistributedApplication.CreateBuilder().AddDuckDB(null!)); + } + + [Fact] + public void ResourceNameCannotBeOmitted() + { + string name = ""; + Assert.Throws(() => DistributedApplication.CreateBuilder().AddDuckDB(name)); + + name = " "; + Assert.Throws(() => DistributedApplication.CreateBuilder().AddDuckDB(name)); + + name = null!; + Assert.Throws(() => DistributedApplication.CreateBuilder().AddDuckDB(name)); + } + + [Fact] + public void EachResourceHasUniqueFile() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb1 = builder.AddDuckDB("duckdb"); + var duckdb2 = builder.AddDuckDB("duckdb2"); + Assert.NotEqual(duckdb1.Resource.DatabaseFileName, duckdb2.Resource.DatabaseFileName); + } + + [Fact] + public void ResourceIsRunningState() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb"); + + Assert.True(duckdb.Resource.TryGetAnnotationsOfType(out var annotations)); + var annotation = Assert.Single(annotations); + + Assert.Equal(KnownResourceStates.Running, annotation.InitialSnapshot.State?.Text); + } + + [Fact] + public void ResourceIncludedInManifestByDefault() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb"); + + Assert.False(duckdb.Resource.TryGetAnnotationsOfType(out var annotations)); + } + + [Fact] + public void ResourceUsesTempPathWhenNoPathProvided() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb"); + + Assert.Equal(Path.GetTempPath(), duckdb.Resource.DatabasePath); + } + + [Fact] + public void ResourceUsesRandomFileNameWhenNoFileNameProvided() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb"); + + Assert.NotNull(duckdb.Resource.DatabaseFileName); + } + + [Fact] + public void ResourceUsesProvidedPath() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb", "/path/to/db"); + + Assert.Equal("/path/to/db", duckdb.Resource.DatabasePath); + } + + [Fact] + public void ResourceUsesProvidedFileName() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb", null, "mydb.duckdb"); + + Assert.Equal("mydb.duckdb", duckdb.Resource.DatabaseFileName); + } + + [Theory] + [InlineData(null, null)] + [InlineData("/path/to/db", "mydb.duckdb")] + public async Task ResourceUsesProvidedPathAndFileName(string? path, string? fileName) + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb", path, fileName); + + var connectionString = await duckdb.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + + Assert.Equal($"DataSource={duckdb.Resource.DatabaseFilePath}", connectionString); + } + + [Fact] + public async Task WithReadOnlyAppendsAccessMode() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb") + .WithReadOnly(); + + var connectionString = await duckdb.Resource.ConnectionStringExpression.GetValueAsync(CancellationToken.None); + + Assert.Contains("Access Mode=ReadOnly", connectionString); + } + + [Fact] + public void ResourceFileNameHasDuckDBExtension() + { + var builder = DistributedApplication.CreateBuilder(); + var duckdb = builder.AddDuckDB("duckdb"); + + Assert.EndsWith(".duckdb", duckdb.Resource.DatabaseFileName); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/AppHostTests.cs b/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/AppHostTests.cs new file mode 100644 index 000000000..3ab4f32d4 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/AppHostTests.cs @@ -0,0 +1,56 @@ +using CommunityToolkit.Aspire.Testing; +using System.Data.Common; + +namespace CommunityToolkit.Aspire.Hosting.DuckDB; + +public class AppHostTests(AspireIntegrationTestFixture fixture) : IClassFixture> +{ + [Fact] + public async Task ResourceStartsAndConnectionStringIsValid() + { + var resourceName = "analytics"; + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + var connectionString = await fixture.GetConnectionString(resourceName); + + Assert.NotNull(connectionString); + + var csb = new DbConnectionStringBuilder + { + ConnectionString = connectionString + }; + Assert.True(csb.TryGetValue("DataSource", out var dataSource)); + Assert.NotNull(dataSource); + Assert.EndsWith(".duckdb", dataSource.ToString()!); + } + + [Fact] + public async Task ApiServiceGetsSummary() + { + var resourceName = "api"; + + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + var httpClient = fixture.CreateHttpClient(resourceName); + + var response = await httpClient.GetAsync("/analytics/summary"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var data = await response.Content.ReadAsStringAsync(); + Assert.NotNull(data); + Assert.NotEmpty(data); + } + + [Fact] + public async Task ApiServiceCanCreateOrder() + { + var resourceName = "api"; + + await fixture.ResourceNotificationService.WaitForResourceHealthyAsync(resourceName).WaitAsync(TimeSpan.FromMinutes(5)); + var httpClient = fixture.CreateHttpClient(resourceName); + + var createResponse = await httpClient.PostAsync("/analytics/orders", null); + Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode); + + var getResponse = await httpClient.GetAsync("/analytics/orders"); + Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); + } +} diff --git a/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests.csproj new file mode 100644 index 000000000..101a453a3 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests/CommunityToolkit.Aspire.Hosting.DuckDB.Tests.csproj @@ -0,0 +1,8 @@ + + + + + + + +