diff --git a/src/CoreSync.Sqlite/SqliteSyncProvider.cs b/src/CoreSync.Sqlite/SqliteSyncProvider.cs index 8874d57..fcb608c 100644 --- a/src/CoreSync.Sqlite/SqliteSyncProvider.cs +++ b/src/CoreSync.Sqlite/SqliteSyncProvider.cs @@ -848,49 +848,27 @@ public async Task RemoveProvisionAsync(CancellationToken cancellationToken = def using var connection = new SqliteConnection(Configuration.ConnectionString); await connection.OpenAsync(cancellationToken); - //1. discover tables using var cmd = connection.CreateCommand(); - var listOfTables = new List(); - foreach (SqliteSyncTable table in Configuration.Tables) - { - cmd.CommandText = $"PRAGMA table_info('{table.Name}')"; - using var reader = await cmd.ExecuteReaderAsync(cancellationToken); - /* - cid name type notnull dflt_value pk - ---------- ---------- ---------- ---------- ---------- ---------- - */ - while (await reader.ReadAsync(cancellationToken)) - { - var colName = reader.GetString(1); - - if (string.CompareOrdinal(colName, "__OP") == 0) - { - continue; - } - listOfTables.Add(colName); - } - } - - //2. drop ct table + //1. drop ct table cmd.CommandText = $"DROP TABLE IF EXISTS __CORE_SYNC_CT"; await cmd.ExecuteNonQueryAsync(); - //3. drop remote anchor table + //2. drop remote anchor table cmd.CommandText = $"DROP TABLE IF EXISTS __CORE_SYNC_REMOTE_ANCHOR"; await cmd.ExecuteNonQueryAsync(); - //4. drop local anchor table + //3. drop local anchor table cmd.CommandText = $"DROP TABLE IF EXISTS __CORE_SYNC_LOCAL_ID"; await cmd.ExecuteNonQueryAsync(); - //5. drop triggers - foreach (var tableName in listOfTables) + //4. drop triggers — iterate table names directly; DROP TRIGGER IF EXISTS is safe even if never provisioned + foreach (SqliteSyncTable table in Configuration.Tables) { - await DisableChangeTrackingForTable(cmd, tableName, cancellationToken); + await DisableChangeTrackingForTable(cmd, table.Name, cancellationToken); } } - + public async Task GetSyncVersionAsync(CancellationToken cancellationToken = default) { await InitializeStoreAsync(cancellationToken); @@ -992,7 +970,7 @@ public async Task EnableChangeTrackingForTable(string name, CancellationToken ca await SetupTableForUpdatesOrDeletesOnly(table, cmd, cancellationToken); } } - + public async Task DisableChangeTrackingForTable(string name, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(name)) @@ -1009,7 +987,7 @@ public async Task DisableChangeTrackingForTable(string name, CancellationToken c } private async Task DisableChangeTrackingForTable(SqliteCommand cmd, string tableName, CancellationToken cancellationToken) - { + { var commandTextBase = new Func((op) => $@"DROP TRIGGER IF EXISTS [__{tableName}_ct-{op}__]"); cmd.CommandText = commandTextBase("INSERT"); await cmd.ExecuteNonQueryAsync(); @@ -1018,7 +996,7 @@ private async Task DisableChangeTrackingForTable(SqliteCommand cmd, string table await cmd.ExecuteNonQueryAsync(); cmd.CommandText = commandTextBase("DELETE"); - await cmd.ExecuteNonQueryAsync(); + await cmd.ExecuteNonQueryAsync(); } } diff --git a/src/CoreSync.Tests/SqliteRemoveProvisionTests.cs b/src/CoreSync.Tests/SqliteRemoveProvisionTests.cs new file mode 100644 index 0000000..ed466e6 --- /dev/null +++ b/src/CoreSync.Tests/SqliteRemoveProvisionTests.cs @@ -0,0 +1,147 @@ +using CoreSync.Sqlite; +using CoreSync.Tests.Data; +using Microsoft.Data.Sqlite; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Shouldly; +using SQLite; +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace CoreSync.Tests; + +/// +/// Covers : after a de-provision the database +/// must be left exactly as it was before ran, +/// i.e. no change-tracking triggers and no __CORE_SYNC_* tables. +/// +[TestClass] +public class SqliteRemoveProvisionTests +{ + private static string CreateDatabase(string testName) + { + var dbFile = Path.Combine(Path.GetTempPath(), $"{testName}_{Guid.NewGuid():N}.sqlite"); + + using (var db = new SQLiteConnection(dbFile)) + { + db.CreateTable(); + db.CreateTable(); + } + + SqliteConnection.ClearAllPools(); + + return dbFile; + } + + private static SqliteSyncProvider CreateProvider(string dbFile) + => new(new SqliteSyncConfigurationBuilder($"Data Source={dbFile}") + .Table() + .Table() + .Build(), logger: new ConsoleLogger("LOC")); + + private static List GetObjectNames(string dbFile, string type) + { + var names = new List(); + + using var connection = new SqliteConnection($"Data Source={dbFile}"); + connection.Open(); + + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"SELECT name FROM sqlite_master WHERE type = '{type}' ORDER BY name"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + names.Add(reader.GetString(0)); + } + + return names; + } + + [TestMethod] + public async Task RemoveProvisionAsync_RemovesChangeTrackingTriggersAndTables() + { + var dbFile = CreateDatabase(nameof(RemoveProvisionAsync_RemovesChangeTrackingTriggersAndTables)); + var syncProvider = CreateProvider(dbFile); + + await syncProvider.ApplyProvisionAsync(); + + //provisioning creates 3 triggers per table (INSERT/UPDATE/DELETE) plus the sync tables + GetObjectNames(dbFile, "trigger").ShouldBe( + [ + "__Stock_ct-DELETE__", + "__Stock_ct-INSERT__", + "__Stock_ct-UPDATE__", + "__Valuation_ct-DELETE__", + "__Valuation_ct-INSERT__", + "__Valuation_ct-UPDATE__", + ]); + GetObjectNames(dbFile, "table").ShouldContain("__CORE_SYNC_CT"); + + await syncProvider.RemoveProvisionAsync(); + + GetObjectNames(dbFile, "trigger").ShouldBeEmpty(); + + var tables = GetObjectNames(dbFile, "table"); + tables.ShouldNotContain("__CORE_SYNC_CT"); + tables.ShouldNotContain("__CORE_SYNC_REMOTE_ANCHOR"); + tables.ShouldNotContain("__CORE_SYNC_LOCAL_ID"); + + //user tables must survive the de-provision + tables.ShouldContain("Stock"); + tables.ShouldContain("Valuation"); + } + + [TestMethod] + public async Task RemoveProvisionAsync_LeavesDatabaseWritable() + { + var dbFile = CreateDatabase(nameof(RemoveProvisionAsync_LeavesDatabaseWritable)); + var syncProvider = CreateProvider(dbFile); + + await syncProvider.ApplyProvisionAsync(); + await syncProvider.RemoveProvisionAsync(); + + //triggers left behind by a partial de-provision still target the (now dropped) __CORE_SYNC_CT + //table, so any write to a tracked table would fail with "no such table: main.__CORE_SYNC_CT" + using var db = new SQLiteConnection(dbFile); + db.Insert(new Stock { Id = Guid.NewGuid(), Symbol = "MY_SYMBOL" }); + + db.Table().Count().ShouldBe(1); + } + + [TestMethod] + public async Task RemoveProvisionAsync_CanBeCalledOnANotProvisionedDatabase() + { + var dbFile = CreateDatabase(nameof(RemoveProvisionAsync_CanBeCalledOnANotProvisionedDatabase)); + var syncProvider = CreateProvider(dbFile); + + await syncProvider.RemoveProvisionAsync(); + + GetObjectNames(dbFile, "trigger").ShouldBeEmpty(); + } + + [TestMethod] + public async Task RemoveProvisionAsync_RemovesTriggersOfATableMissingFromTheDatabase() + { + var dbFile = CreateDatabase(nameof(RemoveProvisionAsync_RemovesTriggersOfATableMissingFromTheDatabase)); + var syncProvider = CreateProvider(dbFile); + + await syncProvider.ApplyProvisionAsync(); + + //simulate a table dropped outside of CoreSync: its triggers go away with it, but the + //de-provision must not choke on the missing table nor skip the remaining ones + using (var connection = new SqliteConnection($"Data Source={dbFile}")) + { + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "DROP TABLE [Valuation]"; + cmd.ExecuteNonQuery(); + } + + SqliteConnection.ClearAllPools(); + + await syncProvider.RemoveProvisionAsync(); + + GetObjectNames(dbFile, "trigger").ShouldBeEmpty(); + } +}