diff --git a/src/SquidStd.Database/Connection/ConnectionStringParser.cs b/src/SquidStd.Database/Connection/ConnectionStringParser.cs index 1559f0c0..72b7943d 100644 --- a/src/SquidStd.Database/Connection/ConnectionStringParser.cs +++ b/src/SquidStd.Database/Connection/ConnectionStringParser.cs @@ -12,8 +12,9 @@ public static class ConnectionStringParser /// Parses the given URI connection string. /// /// The URI connection string. + /// Base directory used to resolve relative SQLite file paths; null keeps them verbatim. /// The parsed provider and native connection string. - public static ParsedConnection Parse(string connectionString) + public static ParsedConnection Parse(string connectionString, string? sqliteBaseDirectory = null) { ArgumentException.ThrowIfNullOrWhiteSpace(connectionString); @@ -28,11 +29,14 @@ public static ParsedConnection Parse(string connectionString) var remainder = connectionString[(schemeEnd + 3)..]; var provider = ResolveProvider(scheme); - var native = provider == DatabaseProviderType.Sqlite - ? BuildSqlite(remainder) - : BuildServer(provider, remainder); + if (provider == DatabaseProviderType.Sqlite) + { + var filePath = ResolveSqlitePath(remainder, sqliteBaseDirectory); + + return new(provider, $"Data Source={filePath ?? remainder}", filePath); + } - return new(provider, native); + return new(provider, BuildServer(provider, remainder)); } private static string BuildServer(DatabaseProviderType provider, string remainder) @@ -76,14 +80,24 @@ private static string BuildServer(DatabaseProviderType provider, string remainde }; } - private static string BuildSqlite(string remainder) + private static string? ResolveSqlitePath(string remainder, string? baseDirectory) { if (string.IsNullOrWhiteSpace(remainder)) { throw new FormatException("SQLite connection string requires a file path or ':memory:'."); } - return $"Data Source={remainder}"; + if (string.Equals(remainder, ":memory:", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + if (!Path.IsPathRooted(remainder) && !string.IsNullOrWhiteSpace(baseDirectory)) + { + return Path.GetFullPath(Path.Combine(baseDirectory, remainder)); + } + + return remainder; } private static DatabaseProviderType ResolveProvider(string scheme) diff --git a/src/SquidStd.Database/Connection/ParsedConnection.cs b/src/SquidStd.Database/Connection/ParsedConnection.cs index b03c0d84..b5a6d1c3 100644 --- a/src/SquidStd.Database/Connection/ParsedConnection.cs +++ b/src/SquidStd.Database/Connection/ParsedConnection.cs @@ -7,4 +7,9 @@ namespace SquidStd.Database.Connection; /// /// The resolved database provider. /// The provider-native connection string for FreeSql. -public sealed record ParsedConnection(DatabaseProviderType Provider, string NativeConnectionString); +/// The resolved on-disk SQLite file path, or null for :memory: and server providers. +public sealed record ParsedConnection( + DatabaseProviderType Provider, + string NativeConnectionString, + string? SqliteFilePath = null +); diff --git a/src/SquidStd.Database/Services/DatabaseService.cs b/src/SquidStd.Database/Services/DatabaseService.cs index 7b606ea2..9d5d0c78 100644 --- a/src/SquidStd.Database/Services/DatabaseService.cs +++ b/src/SquidStd.Database/Services/DatabaseService.cs @@ -2,6 +2,7 @@ using Serilog; using SquidStd.Database.Abstractions.Data.Database; using SquidStd.Database.Abstractions.Types.Data; +using SquidStd.Core.Directories; using SquidStd.Database.Connection; using SquidStd.Database.Interfaces.Services; @@ -15,6 +16,7 @@ public sealed class DatabaseService : IDatabaseService private static readonly ILogger Logger = Log.ForContext(); private readonly DatabaseConfig _config; + private readonly DirectoriesConfig _directories; private IFreeSql? _orm; private int _started; @@ -25,9 +27,11 @@ public sealed class DatabaseService : IDatabaseService /// Initializes the database service. /// /// The database configuration section. - public DatabaseService(DatabaseConfig config) + /// The directories configuration, providing the root directory. + public DatabaseService(DatabaseConfig config, DirectoriesConfig directories) { _config = config; + _directories = directories; } /// @@ -40,7 +44,18 @@ public ValueTask StartAsync(CancellationToken cancellationToken = default) return ValueTask.CompletedTask; } - var parsed = ConnectionStringParser.Parse(_config.ConnectionString); + var parsed = ConnectionStringParser.Parse(_config.ConnectionString, _directories.Root); + + if (parsed.SqliteFilePath is { } sqliteFile) + { + var directory = Path.GetDirectoryName(sqliteFile); + + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + } + Logger.Verbose("Building FreeSql for provider {Provider}", parsed.Provider); var builder = new FreeSqlBuilder() diff --git a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs index 73ee8d68..43673794 100644 --- a/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs +++ b/tests/SquidStd.Tests/Database/ConnectionStringParserTests.cs @@ -77,4 +77,51 @@ public void Parse_SqlServer_BuildsNativeString() [Fact] public void Parse_UnknownScheme_Throws() => Assert.Throws(() => ConnectionStringParser.Parse("oracle://h/db")); + + [Fact] + public void Parse_SqliteRelativePath_WithBaseDirectory_ResolvesAgainstBase() + { + var baseDir = Path.Combine(Path.GetTempPath(), "squidstd-base"); + var result = ConnectionStringParser.Parse("sqlite://app.db", baseDir); + + var expected = Path.GetFullPath(Path.Combine(baseDir, "app.db")); + Assert.Equal(DatabaseProviderType.Sqlite, result.Provider); + Assert.Equal($"Data Source={expected}", result.NativeConnectionString); + Assert.Equal(expected, result.SqliteFilePath); + } + + [Fact] + public void Parse_SqliteRelativePath_WithoutBaseDirectory_StaysVerbatim() + { + var result = ConnectionStringParser.Parse("sqlite://app.db"); + + Assert.Equal("Data Source=app.db", result.NativeConnectionString); + Assert.Equal("app.db", result.SqliteFilePath); + } + + [Fact] + public void Parse_SqliteAbsolutePath_WithBaseDirectory_Unchanged() + { + var result = ConnectionStringParser.Parse("sqlite:///var/data/app.db", "/tmp/base"); + + Assert.Equal("Data Source=/var/data/app.db", result.NativeConnectionString); + Assert.Equal("/var/data/app.db", result.SqliteFilePath); + } + + [Fact] + public void Parse_SqliteInMemory_WithBaseDirectory_HasNullFilePath() + { + var result = ConnectionStringParser.Parse("sqlite://:memory:", "/tmp/base"); + + Assert.Equal("Data Source=:memory:", result.NativeConnectionString); + Assert.Null(result.SqliteFilePath); + } + + [Fact] + public void Parse_ServerProvider_HasNullSqliteFilePath() + { + var result = ConnectionStringParser.Parse("postgres://user:pass@db.host:5433/appdb"); + + Assert.Null(result.SqliteFilePath); + } } diff --git a/tests/SquidStd.Tests/Database/DatabaseServiceTests.cs b/tests/SquidStd.Tests/Database/DatabaseServiceTests.cs new file mode 100644 index 00000000..8b94d01d --- /dev/null +++ b/tests/SquidStd.Tests/Database/DatabaseServiceTests.cs @@ -0,0 +1,34 @@ +using SquidStd.Core.Directories; +using SquidStd.Database.Abstractions.Data.Database; +using SquidStd.Database.Services; + +namespace SquidStd.Tests.Database; + +public class DatabaseServiceTests +{ + [Fact] + public async Task StartAsync_RelativeSqlitePathWithSubdirectory_CreatesDirectoryUnderRoot() + { + var root = Path.Combine(Path.GetTempPath(), "squidstd-root-" + Guid.NewGuid().ToString("N")); + var service = new DatabaseService( + new DatabaseConfig { ConnectionString = "sqlite://sub/app.db", AutoMigrate = true }, + new DirectoriesConfig(root, []) + ); + + try + { + await service.StartAsync(); + + Assert.True(Directory.Exists(Path.Combine(root, "sub"))); + } + finally + { + await service.StopAsync(); + + if (Directory.Exists(root)) + { + Directory.Delete(root, true); + } + } + } +} diff --git a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs index 4ecec4bd..c479456d 100644 --- a/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs +++ b/tests/SquidStd.Tests/Database/FreeSqlDataAccessTests.cs @@ -1,3 +1,4 @@ +using SquidStd.Core.Directories; using SquidStd.Database.Abstractions.Data.Entities; using SquidStd.Database.Data; using SquidStd.Database.Services; @@ -33,7 +34,8 @@ public async Task InitializeAsync() { ConnectionString = $"sqlite://{_dbPath}", AutoMigrate = true - } + }, + new DirectoriesConfig(Path.GetTempPath(), []) ); await _service.StartAsync(); } diff --git a/tests/SquidStd.Tests/Vfs/Database/DatabaseFileSystemTests.cs b/tests/SquidStd.Tests/Vfs/Database/DatabaseFileSystemTests.cs index 993ca6c4..3592f46c 100644 --- a/tests/SquidStd.Tests/Vfs/Database/DatabaseFileSystemTests.cs +++ b/tests/SquidStd.Tests/Vfs/Database/DatabaseFileSystemTests.cs @@ -1,4 +1,5 @@ using System.Text; +using SquidStd.Core.Directories; using SquidStd.Database.Abstractions.Data.Database; using SquidStd.Database.Data; using SquidStd.Database.Services; @@ -21,7 +22,8 @@ public async Task InitializeAsync() { ConnectionString = $"sqlite://{_dbPath}", AutoMigrate = true - } + }, + new DirectoriesConfig(Path.GetTempPath(), []) ); await _service.StartAsync(); _fs = new DatabaseFileSystem(NewAccess());