fix(localdb): create the database file's parent directory (0.1.1)
SQLite creates the database FILE on demand but never its parent DIRECTORY, and
SqliteLocalDb opens the file eagerly in its constructor — so a configured path
under a not-yet-existing directory was a hard startup failure ("SQLite Error
14: unable to open database file") rather than a first run that just worked.
Relative defaults like ./data/app.db hit it routinely; a containerized consumer
escapes only when a volume mount happens to create the directory first.
ScadaBridge found this during LocalDb Phase 2 and worked around it app-side
(SiteLocalDbDirectory.Ensure). Every other consumer still had the gap, so the
guarantee belongs here.
The standing objection to a library that creates directories is that it can
mask a mis-typed path by silently starting an empty database. That does not
apply: SQLite ALREADY does exactly that for a mis-typed file NAME. Refusing to
create the directory protects nothing — it only makes the two halves of one
path behave inconsistently and turns the directory half into an opaque error.
A creation failure (permissions, read-only mount) now throws an
InvalidOperationException naming the directory and the cause, instead of
surfacing as SQLite error 14. That is a change of exception type, but only on a
path that already failed hard.
Both behaviours are pinned and verified to fail with the call removed.
147 tests pass, 0 warnings. Published to the Gitea feed at 0.1.1.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
This commit is contained in:
@@ -5,7 +5,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<LangVersion>latest</LangVersion>
|
<LangVersion>latest</LangVersion>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.1.1</Version>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<!-- NU1510 (package-pruning advice): the Replication and Tests projects carry a
|
<!-- NU1510 (package-pruning advice): the Replication and Tests projects carry a
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
|||||||
_busyTimeoutMs = options.BusyTimeoutMs;
|
_busyTimeoutMs = options.BusyTimeoutMs;
|
||||||
_connectionString = new SqliteConnectionStringBuilder { DataSource = options.Path }.ToString();
|
_connectionString = new SqliteConnectionStringBuilder { DataSource = options.Path }.ToString();
|
||||||
|
|
||||||
|
EnsureDirectoryExists(options.Path);
|
||||||
|
|
||||||
_master = new SqliteConnection(_connectionString);
|
_master = new SqliteConnection(_connectionString);
|
||||||
_master.Open();
|
_master.Open();
|
||||||
ExecuteRaw(_master, "PRAGMA journal_mode=WAL; " + PerConnectionPragmas());
|
ExecuteRaw(_master, "PRAGMA journal_mode=WAL; " + PerConnectionPragmas());
|
||||||
@@ -219,6 +221,62 @@ internal sealed class SqliteLocalDb : ILocalDb, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates the directory holding the database file when it does not already exist.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// SQLite creates the database FILE on demand but never its parent DIRECTORY, and this
|
||||||
|
/// constructor opens the file eagerly — so a configured path under a directory that does
|
||||||
|
/// not exist yet was a hard startup failure ("SQLite Error 14: unable to open database
|
||||||
|
/// file") rather than a first-run that simply worked. Relative defaults such as
|
||||||
|
/// <c>./data/app.db</c> hit it routinely; a containerized consumer escapes only when a
|
||||||
|
/// volume mount happens to create the directory first.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Creating it here is deliberate rather than merely convenient. The objection to a
|
||||||
|
/// library that creates directories is that it can mask a mis-typed path by silently
|
||||||
|
/// starting an empty database — but SQLite ALREADY does exactly that for a mis-typed
|
||||||
|
/// file NAME. Refusing to create the directory therefore protects nothing; it only makes
|
||||||
|
/// the two halves of the same path behave inconsistently, and turns the directory half
|
||||||
|
/// into an opaque error.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A creation failure (permissions, read-only mount) is reported here rather than left
|
||||||
|
/// to surface as SQLite error 14, because the error the caller actually needs names the
|
||||||
|
/// directory and the reason.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void EnsureDirectoryExists(string path)
|
||||||
|
{
|
||||||
|
string? directory;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
directory = Path.GetDirectoryName(Path.GetFullPath(path));
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||||
|
{
|
||||||
|
// An unresolvable path is not this method's error to report: opening it produces a
|
||||||
|
// message naming the data source, which is the better diagnostic.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(directory) || Directory.Exists(directory))
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"LocalDb could not create the directory '{directory}' for database file '{path}'. " +
|
||||||
|
"SQLite creates the database file but not its parent directory; either create the " +
|
||||||
|
"directory out of band or point LocalDb:Path somewhere writable.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IReadOnlyList<T>> ReadAllAsync<T>(SqliteCommand cmd, Func<SqliteDataReader, T> map, CancellationToken ct)
|
private static async Task<IReadOnlyList<T>> ReadAllAsync<T>(SqliteCommand cmd, Func<SqliteDataReader, T> map, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var results = new List<T>();
|
var results = new List<T>();
|
||||||
|
|||||||
@@ -16,6 +16,60 @@ public sealed class SqliteLocalDbTests : IDisposable
|
|||||||
|
|
||||||
private SqliteLocalDb Create() => new(new LocalDbOptions { Path = _path });
|
private SqliteLocalDb Create() => new(new LocalDbOptions { Path = _path });
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Open_CreatesTheParentDirectory_WhenItDoesNotExist()
|
||||||
|
{
|
||||||
|
// SQLite creates the database FILE on demand but never its parent DIRECTORY, and the
|
||||||
|
// constructor opens the file eagerly - so before this, a path under a missing
|
||||||
|
// directory was a hard startup failure ("SQLite Error 14"), not a first run. Relative
|
||||||
|
// defaults like ./data/app.db hit it routinely.
|
||||||
|
var root = Path.Combine(Path.GetTempPath(), "localdb-dir-" + Guid.NewGuid().ToString("N"));
|
||||||
|
var path = Path.Combine(root, "nested", "deeper", "app.db");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Assert.False(Directory.Exists(root));
|
||||||
|
|
||||||
|
using (var db = new SqliteLocalDb(new LocalDbOptions { Path = path }))
|
||||||
|
{
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
if (Directory.Exists(root))
|
||||||
|
Directory.Delete(root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Open_ReportsTheDirectory_WhenItCannotBeCreated()
|
||||||
|
{
|
||||||
|
// The failure that survives: an unwritable location. It must name the directory and
|
||||||
|
// the reason rather than surfacing as SQLite error 14, which was the whole complaint
|
||||||
|
// about the previous behaviour.
|
||||||
|
var blocker = Path.Combine(Path.GetTempPath(), "localdb-blocker-" + Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// A FILE where the directory needs to be. Creating a directory under it fails on
|
||||||
|
// every platform, without needing permission manipulation that varies by OS.
|
||||||
|
File.WriteAllText(blocker, string.Empty);
|
||||||
|
|
||||||
|
var ex = Assert.Throws<InvalidOperationException>(
|
||||||
|
() => new SqliteLocalDb(new LocalDbOptions { Path = Path.Combine(blocker, "sub", "app.db") }));
|
||||||
|
|
||||||
|
Assert.Contains("could not create the directory", ex.Message);
|
||||||
|
Assert.NotNull(ex.InnerException);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(blocker))
|
||||||
|
File.Delete(blocker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Open_CreatesDbFile_WalMode()
|
public void Open_CreatesDbFile_WalMode()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user