Skip to content

Commit a8d1717

Browse files
Adding ability to configure extensions for Sqlite (#428)
* Adding ability to configure extensions for Sqlite * Loading sqlite extensions from nuget packages * Fixing test * Have to clean up the connection string for the SqliteConnection to be created * DbConnectionStringBuilder lower-cases connection string keys? * Making it more flexible to work with extensions, but also marking the feature as experimental * Adding diagnostics info * Update src/CommunityToolkit.Aspire.Hosting.Sqlite/SqliteResourceBuilderExtensions.cs Co-authored-by: Alireza Baloochi <Dev.AlirezaBaloochi@gmail.com> * Turning metadata class into shared type * Adding test for extension loading * Moving where package is * Fixing test * Adding some logging * Bit more logging * Little more logging * Disabling the current test --------- Co-authored-by: Alireza Baloochi <Dev.AlirezaBaloochi@gmail.com>
1 parent 38897c3 commit a8d1717

File tree

21 files changed

+474
-15
lines changed

21 files changed

+474
-15
lines changed

Directory.Packages.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
</PackageVersion>
3131
<PackageVersion Include="Microsoft.Extensions.AI" Version="$(MEAIVersion)" />
3232
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="$(DotNetExtensionsVersion)" />
33+
<PackageVersion Include="Microsoft.DotNet.PlatformAbstractions" Version="3.1.6" />
34+
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="9.0.1" />
3335
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(DotNetExtensionsVersion)" />
3436
<PackageVersion Include="Swashbuckle.AspNetCore" Version="7.2.0" />
3537
<!-- .NET packages -->
@@ -54,6 +56,7 @@
5456
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
5557
<PackageVersion Include="xunit.extensibility.execution" Version="2.9.2" />
5658
<PackageVersion Include="Microsoft.DotNet.XUnitExtensions" Version=" 9.0.0-beta.24568.1" />
59+
<PackageVersion Include="mod_spatialite" Version="4.3.0.1" />
5760
<!-- External packages -->
5861
<PackageVersion Include="Azure.Provisioning.AppContainers" Version="1.0.0" />
5962
<PackageVersion Include="JsonSchema.Net" Version="7.3.0" />

docs/diagnostics.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ In these cases, refer to the `<remarks>` docs section of the API for more inform
1010

1111
Once a release of .NET Aspire with that API is available, the API in the .NET Aspire Community Toolkit will be marked as obsolete and will be removed in a future release.
1212

13+
## CTASPIRE002
14+
15+
Support for loading extensions into SQLite requires either a NuGet package or folder path to the library to be provided, and as a result there is some custom logic to load the extension based on the path or NuGet package. This logic will require some experimenting to figure out edge cases, so the feature for extension loading will be kept as experimental until it is proven to be stable.

nuget.config

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
</packageSources>
99
<packageSourceMapping>
1010
<packageSource key="dotnet-eng">
11-
<package pattern="Microsoft.DotNet.*" />
11+
<package pattern="Microsoft.DotNet.XUnitExtensions" />
1212
</packageSource>
1313
<packageSource key="nuget">
1414
<package pattern="*" />

src/CommunityToolkit.Aspire.Hosting.Sqlite/CommunityToolkit.Aspire.Hosting.Sqlite.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,8 @@
1111
<ItemGroup>
1212
<InternalsVisibleTo Include="CommunityToolkit.Aspire.Hosting.Sqlite.Tests"></InternalsVisibleTo>
1313
</ItemGroup>
14+
15+
<ItemGroup>
16+
<Compile Include="$(SharedDir)\Sqlite\SqliteExtensionMetadata.cs" Link="Utils\Sqlite\SqliteExtensionMetadata.cs" />
17+
</ItemGroup>
1418
</Project>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,24 @@
11
#nullable enable
22
Aspire.Hosting.ApplicationModel.SqliteResource
33
Aspire.Hosting.ApplicationModel.SqliteResource.ConnectionStringExpression.get -> Aspire.Hosting.ApplicationModel.ReferenceExpression!
4+
Aspire.Hosting.ApplicationModel.SqliteResource.Extensions.get -> System.Collections.Generic.IReadOnlyCollection<Microsoft.Extensions.Hosting.SqliteExtensionMetadata!>!
45
Aspire.Hosting.ApplicationModel.SqliteResource.SqliteResource(string! name, string! databasePath, string! databaseFileName) -> void
56
Aspire.Hosting.ApplicationModel.SqliteWebResource
67
Aspire.Hosting.ApplicationModel.SqliteWebResource.ConnectionStringExpression.get -> Aspire.Hosting.ApplicationModel.ReferenceExpression!
78
Aspire.Hosting.ApplicationModel.SqliteWebResource.PrimaryEndpoint.get -> Aspire.Hosting.ApplicationModel.EndpointReference!
89
Aspire.Hosting.ApplicationModel.SqliteWebResource.SqliteWebResource(string! name) -> void
910
Aspire.Hosting.SqliteResourceBuilderExtensions
1011
static Aspire.Hosting.SqliteResourceBuilderExtensions.AddSqlite(this Aspire.Hosting.IDistributedApplicationBuilder! builder, string! name, string? databasePath = null, string? databaseFileName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqliteResource!>!
12+
static Aspire.Hosting.SqliteResourceBuilderExtensions.WithLocalExtension(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqliteResource!>! builder, string! extension, string! extensionPath) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqliteResource!>!
13+
static Aspire.Hosting.SqliteResourceBuilderExtensions.WithNuGetExtension(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqliteResource!>! builder, string! extension, string? packageName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqliteResource!>!
1114
static Aspire.Hosting.SqliteResourceBuilderExtensions.WithSqliteWeb(this Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqliteResource!>! builder, string? containerName = null) -> Aspire.Hosting.ApplicationModel.IResourceBuilder<Aspire.Hosting.ApplicationModel.SqliteResource!>!
15+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata
16+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.Extension.get -> string!
17+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.Extension.init -> void
18+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.ExtensionFolder.get -> string?
19+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.ExtensionFolder.init -> void
20+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.IsNuGetPackage.get -> bool
21+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.IsNuGetPackage.init -> void
22+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.PackageName.get -> string?
23+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.PackageName.init -> void
24+
Microsoft.Extensions.Hosting.SqliteExtensionMetadata.SqliteExtensionMetadata(string! Extension, string? PackageName, bool IsNuGetPackage, string? ExtensionFolder) -> void

src/CommunityToolkit.Aspire.Hosting.Sqlite/SqliteResource.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using Microsoft.Extensions.Hosting;
2+
using System.Text.Json;
3+
14
namespace Aspire.Hosting.ApplicationModel;
25

36
/// <summary>
@@ -15,5 +18,18 @@ public class SqliteResource(string name, string databasePath, string databaseFil
1518
internal string DatabaseFilePath => Path.Combine(DatabasePath, DatabaseFileName);
1619

1720
/// <inheritdoc/>
18-
public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"Data Source={DatabaseFilePath};Cache=Shared;Mode=ReadWriteCreate;");
21+
public ReferenceExpression ConnectionStringExpression => ReferenceExpression.Create($"Data Source={DatabaseFilePath};Cache=Shared;Mode=ReadWriteCreate;Extensions={JsonSerializer.Serialize(Extensions)}");
22+
23+
private readonly List<SqliteExtensionMetadata> extensions = [];
24+
25+
/// <summary>
26+
/// Gets the extensions to be loaded into the database.
27+
/// </summary>
28+
/// <remarks>
29+
/// Extensions are not loaded by the hosting integration, the information is provided for the client to load the extensions.
30+
/// </remarks>
31+
public IReadOnlyCollection<SqliteExtensionMetadata> Extensions => extensions;
32+
33+
internal void AddExtension(SqliteExtensionMetadata extension) => extensions.Add(extension);
1934
}
35+

src/CommunityToolkit.Aspire.Hosting.Sqlite/SqliteResourceBuilderExtensions.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Aspire.Hosting.ApplicationModel;
2+
using System.Diagnostics.CodeAnalysis;
23

34
namespace Aspire.Hosting;
45

@@ -85,4 +86,51 @@ public static IResourceBuilder<SqliteResource> WithSqliteWeb(this IResourceBuild
8586

8687
return builder;
8788
}
89+
90+
/// <summary>
91+
/// Adds an extension to the Sqlite resource that will be loaded from a NuGet package.
92+
/// </summary>
93+
/// <param name="builder">The resource builder.</param>
94+
/// <param name="extension">The name of the extension file with to add, eg: vec0, without file extension.</param>
95+
/// <param name="packageName">The name of the NuGet package. If this is set to null, the value of <paramref name="extension"/> is used.</param>
96+
/// <returns>The resource builder.</returns>
97+
/// <remarks>
98+
/// Extensions are not loaded by the hosting integration, the information is provided for the client to load the extensions.
99+
///
100+
/// This extension is experimental while the final design of extension loading is decided.
101+
/// </remarks>
102+
[Experimental("CTASPIRE002", UrlFormat = "https://aka.ms/communitytoolkit/aspire/diagnostics#{0}")]
103+
public static IResourceBuilder<SqliteResource> WithNuGetExtension(this IResourceBuilder<SqliteResource> builder, string extension, string? packageName = null)
104+
{
105+
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
106+
ArgumentException.ThrowIfNullOrEmpty(extension, nameof(extension));
107+
108+
builder.Resource.AddExtension(new(extension, packageName ?? extension, IsNuGetPackage: true, ExtensionFolder: null));
109+
110+
return builder;
111+
}
112+
113+
/// <summary>
114+
/// Adds an extension to the Sqlite resource that will be loaded from a local path.
115+
/// </summary>
116+
/// <param name="builder">The resource builder.</param>
117+
/// <param name="extension">The name of the extension file with to add, eg: vec0, without file extension.</param>
118+
/// <param name="extensionPath">The path to the extension file.</param>
119+
/// <returns>The resource builder.</returns>
120+
/// <remarks>
121+
/// Extensions are not loaded by the hosting integration, the information is provided for the client to load the extensions.
122+
///
123+
/// This extension is experimental while the final design of extension loading is decided.
124+
/// </remarks>
125+
[Experimental("CTASPIRE002", UrlFormat = "https://aka.ms/communitytoolkit/aspire/diagnostics#{0}")]
126+
public static IResourceBuilder<SqliteResource> WithLocalExtension(this IResourceBuilder<SqliteResource> builder, string extension, string extensionPath)
127+
{
128+
ArgumentNullException.ThrowIfNull(builder, nameof(builder));
129+
ArgumentException.ThrowIfNullOrEmpty(extension, nameof(extension));
130+
ArgumentException.ThrowIfNullOrEmpty(extensionPath, nameof(extensionPath));
131+
132+
builder.Resource.AddExtension(new(extension, PackageName: null, IsNuGetPackage: false, extensionPath));
133+
134+
return builder;
135+
}
88136
}

src/CommunityToolkit.Aspire.Microsoft.Data.Sqlite/AspireSqliteExtensions.cs

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33
using Microsoft.Data.Sqlite;
44
using Microsoft.Extensions.Configuration;
55
using Microsoft.Extensions.DependencyInjection;
6+
using Microsoft.Extensions.DependencyModel;
67
using Microsoft.Extensions.Diagnostics.HealthChecks;
8+
using Microsoft.Extensions.Logging;
9+
using System.Data.Common;
10+
using System.Runtime.InteropServices;
11+
using System.Text.Json;
12+
using RuntimeEnvironment = Microsoft.DotNet.PlatformAbstractions.RuntimeEnvironment;
713

814
namespace Microsoft.Extensions.Hosting;
915

@@ -61,6 +67,15 @@ private static void AddSqliteClient(
6167
settings.ConnectionString = connectionString;
6268
}
6369

70+
if (!string.IsNullOrEmpty(settings.ConnectionString))
71+
{
72+
var cbs = new DbConnectionStringBuilder { ConnectionString = settings.ConnectionString };
73+
if (cbs.TryGetValue("Extensions", out var extensions))
74+
{
75+
settings.Extensions = JsonSerializer.Deserialize<IEnumerable<SqliteExtensionMetadata>>((string)extensions) ?? [];
76+
}
77+
}
78+
6479
configureSettings?.Invoke(settings);
6580

6681
builder.RegisterSqliteServices(settings, connectionName, serviceKey);
@@ -100,8 +115,181 @@ private static void RegisterSqliteServices(
100115

101116
SqliteConnection CreateConnection(IServiceProvider sp, object? key)
102117
{
118+
var logger = sp.GetRequiredService<ILogger<SqliteConnection>>();
103119
ConnectionStringValidation.ValidateConnectionString(settings.ConnectionString, connectionName, DefaultConfigSectionName);
104-
return new SqliteConnection(settings.ConnectionString);
120+
var csb = new DbConnectionStringBuilder { ConnectionString = settings.ConnectionString };
121+
if (csb.ContainsKey("Extensions"))
122+
{
123+
csb.Remove("Extensions");
124+
}
125+
var connection = new SqliteConnection(csb.ConnectionString);
126+
127+
foreach (var extension in settings.Extensions)
128+
{
129+
if (extension.IsNuGetPackage)
130+
{
131+
if (string.IsNullOrEmpty(extension.PackageName))
132+
{
133+
throw new InvalidOperationException("PackageName is required when loading an extension from a NuGet package.");
134+
}
135+
136+
EnsureLoadableFromNuGet(extension.Extension, extension.PackageName, logger);
137+
}
138+
else
139+
{
140+
if (string.IsNullOrEmpty(extension.ExtensionFolder))
141+
{
142+
throw new InvalidOperationException("ExtensionFolder is required when loading an extension from a folder.");
143+
}
144+
145+
EnsureLoadableFromLocalPath(extension.Extension, extension.ExtensionFolder);
146+
}
147+
connection.LoadExtension(extension.Extension);
148+
}
149+
150+
return connection;
151+
}
152+
}
153+
154+
// Adapted from https://github.com/dotnet/docs/blob/dbbeda13bf016a6ff76b0baab1488c927a64ff24/samples/snippets/standard/data/sqlite/ExtensionsSample/Program.cs#L40
155+
internal static void EnsureLoadableFromNuGet(string package, string library, ILogger<SqliteConnection> logger)
156+
{
157+
var runtimeLibrary = DependencyContext.Default?.RuntimeLibraries.FirstOrDefault(l => l.Name == package);
158+
if (runtimeLibrary is null)
159+
{
160+
logger.LogInformation("Could not find the runtime library for package {Package}", package);
161+
return;
162+
}
163+
164+
string sharedLibraryExtension;
165+
string pathVariableName = "PATH";
166+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
167+
{
168+
sharedLibraryExtension = ".dll";
169+
}
170+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
171+
{
172+
sharedLibraryExtension = ".so";
173+
pathVariableName = "LD_LIBRARY_PATH";
174+
}
175+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
176+
{
177+
sharedLibraryExtension = ".dylib";
178+
pathVariableName = "DYLD_LIBRARY_PATH";
179+
}
180+
else
181+
{
182+
throw new NotSupportedException("Unsupported OS platform");
183+
}
184+
185+
var candidateAssets = new Dictionary<(string? Package, string Asset), int>();
186+
var rid = RuntimeEnvironment.GetRuntimeIdentifier();
187+
var rids = DependencyContext.Default?.RuntimeGraph.First(g => g.Runtime == rid).Fallbacks.ToList() ?? [];
188+
rids.Insert(0, rid);
189+
190+
logger.LogInformation("Looking for {Library} in {Package} runtime assets", library, package);
191+
logger.LogInformation("Possible runtime identifiers: {Rids}", string.Join(", ", rids));
192+
193+
foreach (var group in runtimeLibrary.NativeLibraryGroups)
194+
{
195+
foreach (var file in group.RuntimeFiles)
196+
{
197+
if (string.Equals(
198+
Path.GetFileName(file.Path),
199+
library + sharedLibraryExtension,
200+
StringComparison.OrdinalIgnoreCase))
201+
{
202+
var fallbacks = rids.IndexOf(group.Runtime);
203+
if (fallbacks != -1)
204+
{
205+
logger.LogInformation("Found {Library} in {Package} runtime assets at {Path}", library, package, file.Path);
206+
candidateAssets.Add((runtimeLibrary.Path, file.Path), fallbacks);
207+
}
208+
}
209+
}
210+
}
211+
212+
var assetPath = candidateAssets
213+
.OrderBy(p => p.Value)
214+
.Select(p => p.Key)
215+
.FirstOrDefault();
216+
if (assetPath != default)
217+
{
218+
string? assetDirectory = null;
219+
if (File.Exists(Path.Combine(AppContext.BaseDirectory, assetPath.Asset)))
220+
{
221+
// NB: Framework-dependent deployments copy assets to the application base directory
222+
assetDirectory = Path.Combine(
223+
AppContext.BaseDirectory,
224+
Path.GetDirectoryName(assetPath.Asset.Replace('/', Path.DirectorySeparatorChar))!);
225+
226+
logger.LogInformation("Found {Library} in {Package} runtime assets at {Path}", library, package, assetPath.Asset);
227+
}
228+
else
229+
{
230+
string? assetFullPath = null;
231+
var probingDirectories = ((string?)AppDomain.CurrentDomain.GetData("PROBING_DIRECTORIES"))?
232+
.Split(Path.PathSeparator) ?? [];
233+
foreach (var directory in probingDirectories)
234+
{
235+
var candidateFullPath = Path.Combine(
236+
directory,
237+
assetPath.Package ?? "",
238+
assetPath.Asset);
239+
if (File.Exists(candidateFullPath))
240+
{
241+
assetFullPath = candidateFullPath;
242+
}
243+
}
244+
245+
assetDirectory = Path.GetDirectoryName(assetFullPath);
246+
logger.LogInformation("Found {Library} in {Package} runtime assets at {Path} (using PROBING_DIRECTORIES: {ProbingDirectories})", library, package, assetFullPath, string.Join(",", probingDirectories));
247+
}
248+
249+
var path = new HashSet<string>(Environment.GetEnvironmentVariable(pathVariableName)!.Split(Path.PathSeparator));
250+
251+
if (assetDirectory is not null && path.Add(assetDirectory))
252+
{
253+
logger.LogInformation("Adding {AssetDirectory} to {PathVariableName}", assetDirectory, pathVariableName);
254+
Environment.SetEnvironmentVariable(pathVariableName, string.Join(Path.PathSeparator, path));
255+
logger.LogInformation("Set {PathVariableName} to: {PathVariableValue}", pathVariableName, Environment.GetEnvironmentVariable(pathVariableName));
256+
}
257+
}
258+
else
259+
{
260+
logger.LogInformation("Could not find {Library} in {Package} runtime assets", library, package);
261+
}
262+
}
263+
264+
internal static void EnsureLoadableFromLocalPath(string library, string assetDirectory)
265+
{
266+
string sharedLibraryExtension;
267+
string pathVariableName = "PATH";
268+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
269+
{
270+
sharedLibraryExtension = ".dll";
271+
}
272+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
273+
{
274+
sharedLibraryExtension = ".so";
275+
pathVariableName = "LD_LIBRARY_PATH";
276+
}
277+
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
278+
{
279+
sharedLibraryExtension = ".dylib";
280+
pathVariableName = "DYLD_LIBRARY_PATH";
281+
}
282+
else
283+
{
284+
throw new NotSupportedException("Unsupported OS platform");
285+
}
286+
287+
if (File.Exists(Path.Combine(assetDirectory, library + sharedLibraryExtension)))
288+
{
289+
var path = new HashSet<string>(Environment.GetEnvironmentVariable(pathVariableName)!.Split(Path.PathSeparator));
290+
291+
if (assetDirectory is not null && path.Add(assetDirectory))
292+
Environment.SetEnvironmentVariable(pathVariableName, string.Join(Path.PathSeparator, path));
105293
}
106294
}
107295
}

src/CommunityToolkit.Aspire.Microsoft.Data.Sqlite/CommunityToolkit.Aspire.Microsoft.Data.Sqlite.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
<ItemGroup>
99
<PackageReference Include="AspNetCore.HealthChecks.Sqlite" />
1010
<PackageReference Include="Microsoft.Data.Sqlite" />
11+
<PackageReference Include="Microsoft.DotNet.PlatformAbstractions" />
12+
<PackageReference Include="Microsoft.Extensions.DependencyModel" />
1113
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" />
1214
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
1315
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
@@ -16,6 +18,7 @@
1618
<ItemGroup>
1719
<Compile Include="$(SharedDir)\HealthChecksExtensions.cs" Link="Utils\HealthChecksExtensions.cs" />
1820
<Compile Include="$(SharedDir)\ConnectionStringValidation.cs" Link="Utils\ConnectionStringValidation.cs" />
21+
<Compile Include="$(SharedDir)\Sqlite\SqliteExtensionMetadata.cs" Link="Utils\Sqlite\SqliteExtensionMetadata.cs" />
1922
</ItemGroup>
2023

2124
</Project>

0 commit comments

Comments
 (0)