12e6f0d2c5
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
254 lines
8.4 KiB
C#
254 lines
8.4 KiB
C#
using Microsoft.Data.Sqlite;
|
|
using ZB.MOM.WW.LocalDb.Internal;
|
|
|
|
namespace ZB.MOM.WW.LocalDb.Tests;
|
|
|
|
public sealed class SqliteLocalDbTests : IDisposable
|
|
{
|
|
private readonly string _path = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".db");
|
|
|
|
public void Dispose()
|
|
{
|
|
SqliteConnection.ClearAllPools();
|
|
if (File.Exists(_path))
|
|
File.Delete(_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]
|
|
public void Open_CreatesDbFile_WalMode()
|
|
{
|
|
using var db = Create();
|
|
|
|
Assert.True(File.Exists(_path));
|
|
|
|
using var conn = db.CreateConnection();
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = "PRAGMA journal_mode";
|
|
var mode = (string)cmd.ExecuteScalar()!;
|
|
Assert.Equal("wal", mode, ignoreCase: true);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_ParameterizedInsert_Works()
|
|
{
|
|
using var db = Create();
|
|
|
|
await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
|
await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", new { id = 1, name = "x" });
|
|
|
|
var rows = await db.QueryAsync("SELECT COUNT(*) FROM t", r => r.GetInt64(0));
|
|
Assert.Equal(1L, rows[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_MapsRows()
|
|
{
|
|
using var db = Create();
|
|
|
|
await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
|
await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", new { id = 1, name = "a" });
|
|
await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", new { id = 2, name = "b" });
|
|
|
|
var rows = await db.QueryAsync(
|
|
"SELECT id, name FROM t ORDER BY id",
|
|
r => (Id: r.GetInt64(0), Name: r.GetString(1)));
|
|
|
|
Assert.Equal(2, rows.Count);
|
|
Assert.Equal((1L, "a"), rows[0]);
|
|
Assert.Equal((2L, "b"), rows[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Transaction_CommitAndRollback()
|
|
{
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
|
|
|
await using (var tx = await db.BeginTransactionAsync())
|
|
{
|
|
await tx.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", new { id = 1 });
|
|
await tx.CommitAsync(default);
|
|
}
|
|
|
|
await using (var tx = await db.BeginTransactionAsync())
|
|
{
|
|
await tx.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", new { id = 2 });
|
|
await tx.RollbackAsync(default);
|
|
}
|
|
|
|
var ids = await db.QueryAsync("SELECT id FROM t ORDER BY id", r => r.GetInt64(0));
|
|
Assert.Equal(new[] { 1L }, ids);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Transaction_DisposeWithoutCommit_RollsBack()
|
|
{
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
|
|
|
await using (var tx = await db.BeginTransactionAsync())
|
|
{
|
|
await tx.ExecuteAsync("INSERT INTO t (id) VALUES (@id)", new { id = 1 });
|
|
}
|
|
|
|
var count = await db.QueryAsync("SELECT COUNT(*) FROM t", r => r.GetInt64(0));
|
|
Assert.Equal(0L, count[0]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bind_GuidByteArrayAndNull_RoundTrip()
|
|
{
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, g TEXT, blob BLOB, missing TEXT)");
|
|
|
|
var guid = Guid.NewGuid();
|
|
var bytes = new byte[] { 1, 2, 3 };
|
|
await db.ExecuteAsync(
|
|
"INSERT INTO t (id, g, blob, missing) VALUES (@id, @g, @blob, @missing)",
|
|
new { id = 1, g = guid, blob = bytes, missing = (string?)null });
|
|
|
|
var rows = await db.QueryAsync(
|
|
"SELECT g, blob, missing FROM t WHERE id = 1",
|
|
r => (Guid: r.GetGuid(0), Blob: (byte[])r.GetValue(1), MissingIsNull: r.IsDBNull(2)));
|
|
|
|
Assert.Equal(guid, rows[0].Guid);
|
|
Assert.Equal(bytes, rows[0].Blob);
|
|
Assert.True(rows[0].MissingIsNull);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bind_DictionaryParameters_BindByKey()
|
|
{
|
|
using var db = Create();
|
|
await db.ExecuteAsync("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
|
|
|
var parameters = new Dictionary<string, object?> { ["id"] = 7, ["name"] = null };
|
|
await db.ExecuteAsync("INSERT INTO t (id, name) VALUES (@id, @name)", parameters);
|
|
|
|
var rows = await db.QueryAsync(
|
|
"SELECT id, name FROM t",
|
|
r => (Id: r.GetInt64(0), NameIsNull: r.IsDBNull(1)));
|
|
|
|
Assert.Equal(7L, rows[0].Id);
|
|
Assert.True(rows[0].NameIsNull);
|
|
}
|
|
|
|
[Fact]
|
|
public void Ctor_EmptyPath_Throws()
|
|
{
|
|
Assert.Throws<ArgumentException>(() => new SqliteLocalDb(new LocalDbOptions { Path = " " }));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ctor_NonPositiveBusyTimeout_Throws()
|
|
{
|
|
Assert.Throws<ArgumentException>(() =>
|
|
new SqliteLocalDb(new LocalDbOptions { Path = _path, BusyTimeoutMs = 0 }));
|
|
}
|
|
|
|
[Fact]
|
|
public void Ctor_InvalidSynchronous_Throws()
|
|
{
|
|
Assert.Throws<ArgumentException>(() =>
|
|
new SqliteLocalDb(new LocalDbOptions { Path = _path, Synchronous = "NORMAL; PRAGMA foreign_keys=OFF" }));
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_IsIdempotent()
|
|
{
|
|
var db = Create();
|
|
db.Dispose();
|
|
db.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HlcUdf_RegisteredOnConnection()
|
|
{
|
|
using var db = Create();
|
|
|
|
var values = await db.QueryAsync(
|
|
"SELECT zb_hlc_next() UNION ALL SELECT zb_hlc_next()",
|
|
r => r.GetInt64(0));
|
|
|
|
Assert.Equal(2, values.Count);
|
|
Assert.True(values[1] > values[0], $"{values[1]} should be greater than {values[0]}");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Hlc_ResumesFromDurableMax_OnReopen()
|
|
{
|
|
const long huge = 9_000_000_000_000_000L;
|
|
|
|
using (var db = Create())
|
|
{
|
|
await db.ExecuteAsync(
|
|
"INSERT INTO __localdb_oplog (table_name, pk_json, hlc, node_id) VALUES ('t', '{}', @hlc, 'n')",
|
|
new { hlc = huge });
|
|
}
|
|
|
|
SqliteConnection.ClearAllPools();
|
|
|
|
using (var db = Create())
|
|
{
|
|
var next = await db.QueryAsync("SELECT zb_hlc_next()", r => r.GetInt64(0));
|
|
Assert.True(next[0] > huge, $"{next[0]} should be greater than {huge}");
|
|
}
|
|
}
|
|
}
|