diff --git a/Datasync.Toolkit.sln b/Datasync.Toolkit.sln index f943bc3..ecba367 100644 --- a/Datasync.Toolkit.sln +++ b/Datasync.Toolkit.sln @@ -60,6 +60,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.C EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommunityToolkit.Datasync.Client.Test", "tests\CommunityToolkit.Datasync.Client.Test\CommunityToolkit.Datasync.Client.Test.csproj", "{2889E6B2-9CD1-437C-A43C-98CFAFF68B99}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{75F709FD-8CC2-4558-A802-FE57086167C2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sample.Datasync.Server", "samples\datasync-server\src\Sample.Datasync.Server\Sample.Datasync.Server.csproj", "{A9967817-2A2C-4C6D-A133-967A6062E9B3}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -146,6 +150,10 @@ Global {2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Debug|Any CPU.Build.0 = Debug|Any CPU {2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Release|Any CPU.ActiveCfg = Release|Any CPU {2889E6B2-9CD1-437C-A43C-98CFAFF68B99}.Release|Any CPU.Build.0 = Release|Any CPU + {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A9967817-2A2C-4C6D-A133-967A6062E9B3}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -171,6 +179,7 @@ Global {45D47A4E-AD58-40C8-B4CC-95BC888C47A7} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {D3B72031-D4BD-44D3-973C-2752AB1570F6} = {84AD662A-4B9E-4E64-834D-72529FB7FCE5} {2889E6B2-9CD1-437C-A43C-98CFAFF68B99} = {D59F1489-5D74-4F52-B78B-88037EAB2838} + {A9967817-2A2C-4C6D-A133-967A6062E9B3} = {75F709FD-8CC2-4558-A802-FE57086167C2} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {78A935E9-8F14-448A-BEDF-360FB742F14E} diff --git a/infra/scripts/write-runsettings.ps1 b/infra/scripts/write-runsettings.ps1 index a10aa6a..fc07d32 100644 --- a/infra/scripts/write-runsettings.ps1 +++ b/infra/scripts/write-runsettings.ps1 @@ -14,6 +14,7 @@ $fileContents = @" $($outputs.AZSQL_CONNECTION_STRING) $($outputs.COSMOS_CONNECTION_STRING) $($outputs.PGSQL_CONNECTION_STRING) + $($outputs.SERVICE_ENDPOINT) true diff --git a/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs b/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs index cc6a61e..af1d07c 100644 --- a/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs +++ b/samples/datasync-server/src/Sample.Datasync.Server/Db/AppDbContext.cs @@ -3,16 +3,13 @@ // See the LICENSE file in the project root for more information. using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Diagnostics.CodeAnalysis; namespace Sample.Datasync.Server.Db; -public class AppDbContext : DbContext +public class AppDbContext(DbContextOptions options) : DbContext(options) { - public AppDbContext(DbContextOptions options) : base(options) - { - - } - public DbSet TodoItems => Set(); public DbSet TodoLists => Set(); @@ -20,5 +17,40 @@ public AppDbContext(DbContextOptions options) : base(options) public async Task InitializeDatabaseAsync() { _ = await Database.EnsureCreatedAsync(); + + const string datasyncTrigger = @" + CREATE OR ALTER TRIGGER [dbo].[{0}_datasync] ON [dbo].[{0}] AFTER INSERT, UPDATE AS + BEGIN + SET NOCOUNT ON; + UPDATE + [dbo].[{0}] + SET + [UpdatedAt] = SYSUTCDATETIME() + WHERE + [Id] IN (SELECT [Id] FROM INSERTED); + END + " + ; + + // Install the above trigger to set the UpdatedAt field automatically on insert or update. + foreach (IEntityType table in Model.GetEntityTypes()) + { + string sql = string.Format(datasyncTrigger, table.GetTableName()); + _ = await Database.ExecuteSqlRawAsync(sql); + } + } + + [SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "Model builder ignores return value.")] + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Tells EF Core that the TodoItem entity has a trigger. + modelBuilder.Entity() + .ToTable(tb => tb.HasTrigger("TodoItem_datasync")); + + // Tells EF Core that the TodoList entity has a trigger. + modelBuilder.Entity() + .ToTable(tb => tb.HasTrigger("TodoList_datasync")); + + base.OnModelCreating(modelBuilder); } } diff --git a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs index 1105607..911174c 100644 --- a/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs +++ b/src/CommunityToolkit.Datasync.Client/Offline/OperationsQueue/OperationsQueueManager.cs @@ -110,7 +110,7 @@ internal Dictionary GetEntityMap(OfflineDbContext context) /// /// Retrieves the existing operation that matches an operation for the provided entity. /// - /// The entity being processed. + /// The entity entry being processed. /// A to observe. /// The operation entity or null if one does not exist. /// Thrown if the entity ID of the provided entity is invalid. diff --git a/tests/CommunityToolkit.Datasync.Client.Test/Live/SampleServerTests.cs b/tests/CommunityToolkit.Datasync.Client.Test/Live/SampleServerTests.cs new file mode 100644 index 0000000..a0ac1b2 --- /dev/null +++ b/tests/CommunityToolkit.Datasync.Client.Test/Live/SampleServerTests.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Net; +using System.Net.Http.Json; + +namespace CommunityToolkit.Datasync.Client.Test.Live; + +[ExcludeFromCodeCoverage] +public class SampleServerTests +{ + private readonly bool liveTestsAreEnabled = Environment.GetEnvironmentVariable("DATASYNC_SERVICE_ENDPOINT") is not null; + private readonly string serviceEndpoint = Environment.GetEnvironmentVariable("DATASYNC_SERVICE_ENDPOINT"); + + [SkippableFact] + public async Task Metadata_GetsSetByServer() + { + Skip.IfNot(this.liveTestsAreEnabled); + + DateTimeOffset now = DateTimeOffset.UtcNow; + HttpClient client = new(); + TodoItem source = new() { Title = "Test item" }; + HttpResponseMessage response = await client.PostAsJsonAsync($"{this.serviceEndpoint}/tables/TodoItem", source); + + response.Should().HaveHttpStatusCode(HttpStatusCode.Created); + + TodoItem result = await response.Content.ReadFromJsonAsync(); + result.Id.Should().NotBeNullOrEmpty(); + result.UpdatedAt.Should().NotBeNull().And.BeAfter(now); + result.Version.Should().NotBeNullOrEmpty(); + result.Deleted.Should().BeFalse(); + result.Title.Should().Be("Test item"); + result.IsComplete.Should().BeFalse(); + + response.Headers.Location.Should().NotBeNull().And.BeEquivalentTo(new Uri($"{this.serviceEndpoint}/tables/TodoItem/{result.Id}")); + response.Headers.ETag.ToString().Should().Be($"\"{result.Version}\""); + } + + // This must match the TodoItem class in the server project. + public class TodoItem + { + public string Id { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public string Version { get; set; } + public bool Deleted { get; set; } + public string Title { get; set; } + public bool IsComplete { get; set; } + } +} diff --git a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs index 49d7ac9..6fced11 100644 --- a/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs +++ b/tests/CommunityToolkit.Datasync.TestCommon/Databases/AzureSql/AzureSqlDbContext.cs @@ -37,7 +37,7 @@ internal void InitializeDatabase(bool clearEntities) UPDATE [dbo].[{0}] SET - [UpdatedAt] = GETUTCDATE() + [UpdatedAt] = SYSUTCDATETIME() WHERE [Id] IN (SELECT [Id] FROM INSERTED); END