Skip to content

Commit

Permalink
(#125) Support for alternative Id and Version generators to support U…
Browse files Browse the repository at this point in the history
…LID, Snowflake, and other customizations. (#127)
  • Loading branch information
adrianhall authored Oct 8, 2024
1 parent fb3018d commit db1a7e2
Show file tree
Hide file tree
Showing 14 changed files with 225 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,14 @@ public EntityTableRepository(DbContext context)
}

/// <summary>
/// Creates a new ID for an entity when one is not provided.
/// The mechanism by which an Id is generated when one is not provided.
/// </summary>
/// <returns>A globally unique identifier for the entity.</returns>
protected string CreateId() => Guid.NewGuid().ToString();
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");

/// <summary>
/// The mechanism by which a new version byte array is generated.
/// </summary>
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();

/// <summary>
/// Retrieves an untracked version of an entity from the database.
Expand All @@ -87,7 +91,7 @@ internal void UpdateManagedProperties(TEntity entity)

if (this.shouldUpdateVersion)
{
entity.Version = Guid.NewGuid().ToByteArray();
entity.Version = VersionGenerator.Invoke();
}
}

Expand Down Expand Up @@ -128,7 +132,7 @@ public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken can
{
if (string.IsNullOrEmpty(entity.Id))
{
entity.Id = CreateId();
entity.Id = IdGenerator.Invoke(entity);
}

await WrapExceptionAsync(entity.Id, async () =>
Expand Down Expand Up @@ -192,7 +196,7 @@ public virtual async ValueTask ReplaceAsync(TEntity entity, byte[]? version = nu

await WrapExceptionAsync(entity.Id, async () =>
{
TEntity storedEntity = await DataSet.FindAsync(new object[] { entity.Id }, cancellationToken).ConfigureAwait(false)
TEntity storedEntity = await DataSet.FindAsync([entity.Id], cancellationToken).ConfigureAwait(false)
?? throw new HttpException((int)HttpStatusCode.NotFound);

if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,21 @@ public InMemoryRepository(IEnumerable<TEntity> entities)
{
foreach (TEntity entity in entities)
{
entity.Id ??= Guid.NewGuid().ToString();
entity.Id ??= IdGenerator.Invoke(entity);
StoreEntity(entity);
}
}

/// <summary>
/// The mechanism by which an Id is generated when one is not provided.
/// </summary>
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");

/// <summary>
/// The mechanism by which a new version byte array is generated.
/// </summary>
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();

#region Internal properties and methods for testing.
/// <summary>
/// If set, the repository will throw this exception when any method is called.
Expand Down Expand Up @@ -95,7 +105,7 @@ internal void RemoveEntity(string id)
internal void StoreEntity(TEntity entity)
{
entity.UpdatedAt = DateTimeOffset.UtcNow;
entity.Version = Guid.NewGuid().ToByteArray();
entity.Version = VersionGenerator.Invoke();
this._entities[entity.Id] = Disconnect(entity);
}

Expand Down Expand Up @@ -124,7 +134,7 @@ public virtual ValueTask CreateAsync(TEntity entity, CancellationToken cancellat
ThrowExceptionIfSet();
if (string.IsNullOrEmpty(entity.Id))
{
entity.Id = Guid.NewGuid().ToString();
entity.Id = IdGenerator.Invoke(entity);
}

if (this._entities.TryGetValue(entity.Id, out TEntity? storedEntity))
Expand Down
16 changes: 13 additions & 3 deletions src/CommunityToolkit.Datasync.Server.LiteDb/LiteDbRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,24 @@ public LiteDbRepository(LiteDatabase dbConnection, string collectionName)
/// </summary>
public virtual ILiteCollection<TEntity> Collection { get; }

/// <summary>
/// The mechanism by which an Id is generated when one is not provided.
/// </summary>
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");

/// <summary>
/// The mechanism by which a new version byte array is generated.
/// </summary>
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();

/// <summary>
/// Updates the system properties for the provided entity on write.
/// </summary>
/// <param name="entity">The entity to update.</param>
protected static void UpdateEntity(TEntity entity)
protected void UpdateEntity(TEntity entity)
{
entity.UpdatedAt = DateTimeOffset.UtcNow;
entity.Version = Guid.NewGuid().ToByteArray();
entity.Version = VersionGenerator.Invoke();
}

/// <summary>
Expand Down Expand Up @@ -101,7 +111,7 @@ public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken can
{
if (string.IsNullOrEmpty(entity.Id))
{
entity.Id = Guid.NewGuid().ToString();
entity.Id = IdGenerator.Invoke(entity);
}

await ExecuteOnLockedCollectionAsync(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ private static void ProcessDatasyncOperation(OperationProcessorContext context)
}
}

private static void AddMissingSchemaProperties(JsonSchema? schema)
internal static void AddMissingSchemaProperties(JsonSchema? schema)
{
if (schema is null)
{
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Authors>Microsoft.Toolkit,dotnetfoundation,Community Toolkit</Authors>
<Authors>Microsoft.Toolkit,dotnetfoundation</Authors>
<Company>.NET Foundation</Company>
<Copyright>(c) .NET Foundation and Contributors. All rights reserved.</Copyright>
<LicenseUrl>https://github.com/CommunityToolkit/Datasync/blob/main/LICENSE.md</LicenseUrl>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Ulid" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.EntityFrameworkCore\CommunityToolkit.Datasync.Server.EntityFrameworkCore.csproj" />
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

using CommunityToolkit.Datasync.TestCommon;
using CommunityToolkit.Datasync.TestCommon.Databases;
using CommunityToolkit.Datasync.TestCommon.Models;
using Microsoft.EntityFrameworkCore;
using Xunit.Abstractions;

using TestData = CommunityToolkit.Datasync.TestCommon.TestData;

namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test;

[ExcludeFromCodeCoverage]
Expand Down Expand Up @@ -37,4 +40,51 @@ protected override Task<IRepository<RepositoryControlledEntityMovie>> GetPopulat
protected override Task<string> GetRandomEntityIdAsync(bool exists)
=> Task.FromResult(exists ? this.movies[this.random.Next(Context.Movies.Count())].Id : Guid.NewGuid().ToString());
#endregion

[SkippableFact]
public async Task IdGenerator_Ulid_CanCreate()
{
Skip.IfNot(CanRunLiveTests());

IRepository<RepositoryControlledEntityMovie> repository = await GetPopulatedRepositoryAsync();
string generatedId = string.Empty;
((EntityTableRepository<RepositoryControlledEntityMovie>)repository).IdGenerator = _ => { generatedId = Ulid.NewUlid().ToString(); return generatedId; };

RepositoryControlledEntityMovie addition = TestData.Movies.OfType<RepositoryControlledEntityMovie>(TestData.Movies.BlackPanther);
addition.Id = null;
RepositoryControlledEntityMovie sut = addition.Clone();
await repository.CreateAsync(sut);
RepositoryControlledEntityMovie actual = await GetEntityAsync(sut.Id);

actual.Should().BeEquivalentTo<IMovie>(addition);
actual.UpdatedAt.Should().BeAfter(StartTime);
generatedId.Should().NotBeNullOrEmpty();
actual.Id.Should().Be(generatedId);
}

[SkippableFact]
public async Task VersionGenerator_Ticks_CanCreate()
{
Skip.IfNot(CanRunLiveTests());

IRepository<RepositoryControlledEntityMovie> repository = await GetPopulatedRepositoryAsync();
byte[] generatedVersion = [];
((EntityTableRepository<RepositoryControlledEntityMovie>)repository).VersionGenerator = () =>
{
DateTimeOffset offset = DateTimeOffset.UtcNow;
generatedVersion = BitConverter.GetBytes(offset.Ticks);
return generatedVersion;
};

RepositoryControlledEntityMovie addition = TestData.Movies.OfType<RepositoryControlledEntityMovie>(TestData.Movies.BlackPanther);
addition.Id = null;
RepositoryControlledEntityMovie sut = addition.Clone();
await repository.CreateAsync(sut);
RepositoryControlledEntityMovie actual = await GetEntityAsync(sut.Id);

actual.Should().BeEquivalentTo<IMovie>(addition);
actual.UpdatedAt.Should().BeAfter(StartTime);
generatedVersion.Should().NotBeNullOrEmpty();
actual.Version.Should().BeEquivalentTo(generatedVersion);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

using CommunityToolkit.Datasync.TestCommon;
using CommunityToolkit.Datasync.TestCommon.Databases;
using CommunityToolkit.Datasync.TestCommon.Models;
using Microsoft.EntityFrameworkCore;
using Xunit.Abstractions;

using TestData = CommunityToolkit.Datasync.TestCommon.TestData;

namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test;

[ExcludeFromCodeCoverage]
Expand Down Expand Up @@ -38,6 +41,27 @@ protected override Task<string> GetRandomEntityIdAsync(bool exists)
=> Task.FromResult(exists ? this.movies[this.random.Next(Context.Movies.Count())].Id : Guid.NewGuid().ToString());
#endregion

[SkippableFact]
public async Task IdGenerator_Ulid_CanCreate()
{
Skip.IfNot(CanRunLiveTests());

IRepository<SqliteEntityMovie> repository = await GetPopulatedRepositoryAsync();
string generatedId = string.Empty;
((EntityTableRepository<SqliteEntityMovie>) repository).IdGenerator = _ => { generatedId = Ulid.NewUlid().ToString(); return generatedId; };

SqliteEntityMovie addition = TestData.Movies.OfType<SqliteEntityMovie>(TestData.Movies.BlackPanther);
addition.Id = null;
SqliteEntityMovie sut = addition.Clone();
await repository.CreateAsync(sut);
SqliteEntityMovie actual = await GetEntityAsync(sut.Id);

actual.Should().BeEquivalentTo<IMovie>(addition);
actual.UpdatedAt.Should().BeAfter(StartTime);
generatedId.Should().NotBeNullOrEmpty();
actual.Id.Should().Be(generatedId);
}

[Fact]
public void EntityTableRepository_BadDbSet_Throws()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Ulid" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.InMemory\CommunityToolkit.Datasync.Server.InMemory.csproj" />
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using CommunityToolkit.Datasync.TestCommon;
using CommunityToolkit.Datasync.TestCommon.Databases;
using CommunityToolkit.Datasync.TestCommon.Models;
using TestData = CommunityToolkit.Datasync.TestCommon.TestData;

namespace CommunityToolkit.Datasync.Server.InMemory.Test;
Expand Down Expand Up @@ -124,4 +125,51 @@ public async Task ReplaceAsync_Throws_OnForcedException(string id)

await act.Should().ThrowAsync<ApplicationException>();
}

[SkippableFact]
public async Task IdGenerator_Ulid_CanCreate()
{
Skip.IfNot(CanRunLiveTests());

IRepository<InMemoryMovie> repository = await GetPopulatedRepositoryAsync();
string generatedId = string.Empty;
((InMemoryRepository<InMemoryMovie>)repository).IdGenerator = _ => { generatedId = Ulid.NewUlid().ToString(); return generatedId; };

InMemoryMovie addition = TestData.Movies.OfType<InMemoryMovie>(TestData.Movies.BlackPanther);
addition.Id = null;
InMemoryMovie sut = addition.Clone();
await repository.CreateAsync(sut);
InMemoryMovie actual = await GetEntityAsync(sut.Id);

actual.Should().BeEquivalentTo<IMovie>(addition);
actual.UpdatedAt.Should().BeAfter(StartTime);
generatedId.Should().NotBeNullOrEmpty();
actual.Id.Should().Be(generatedId);
}

[SkippableFact]
public async Task VersionGenerator_Ticks_CanCreate()
{
Skip.IfNot(CanRunLiveTests());

IRepository<InMemoryMovie> repository = await GetPopulatedRepositoryAsync();
byte[] generatedVersion = [];
((InMemoryRepository<InMemoryMovie>)repository).VersionGenerator = () =>
{
DateTimeOffset offset = DateTimeOffset.UtcNow;
generatedVersion = BitConverter.GetBytes(offset.Ticks);
return generatedVersion;
};

InMemoryMovie addition = TestData.Movies.OfType<InMemoryMovie>(TestData.Movies.BlackPanther);
addition.Id = null;
InMemoryMovie sut = addition.Clone();
await repository.CreateAsync(sut);
InMemoryMovie actual = await GetEntityAsync(sut.Id);

actual.Should().BeEquivalentTo<IMovie>(addition);
actual.UpdatedAt.Should().BeAfter(StartTime);
generatedVersion.Should().NotBeNullOrEmpty();
actual.Version.Should().BeEquivalentTo(generatedVersion);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="Ulid" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.LiteDb\CommunityToolkit.Datasync.Server.LiteDb.csproj" />
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.

using CommunityToolkit.Datasync.TestCommon;
using CommunityToolkit.Datasync.TestCommon.Models;
using LiteDB;

using TestData = CommunityToolkit.Datasync.TestCommon.TestData;
Expand Down Expand Up @@ -67,4 +68,51 @@ protected virtual void Dispose(bool disposing)
}
}
#endregion

[SkippableFact]
public async Task IdGenerator_Ulid_CanCreate()
{
Skip.IfNot(CanRunLiveTests());

IRepository<LiteDbMovie> repository = await GetPopulatedRepositoryAsync();
string generatedId = string.Empty;
((LiteDbRepository<LiteDbMovie>)repository).IdGenerator = _ => { generatedId = Ulid.NewUlid().ToString(); return generatedId; };

LiteDbMovie addition = TestData.Movies.OfType<LiteDbMovie>(TestData.Movies.BlackPanther);
addition.Id = null;
LiteDbMovie sut = addition.Clone();
await repository.CreateAsync(sut);
LiteDbMovie actual = await GetEntityAsync(sut.Id);

actual.Should().BeEquivalentTo<IMovie>(addition);
actual.UpdatedAt.Should().BeAfter(StartTime);
generatedId.Should().NotBeNullOrEmpty();
actual.Id.Should().Be(generatedId);
}

[SkippableFact]
public async Task VersionGenerator_Ticks_CanCreate()
{
Skip.IfNot(CanRunLiveTests());

IRepository<LiteDbMovie> repository = await GetPopulatedRepositoryAsync();
byte[] generatedVersion = [];
((LiteDbRepository<LiteDbMovie>)repository).VersionGenerator = () =>
{
DateTimeOffset offset = DateTimeOffset.UtcNow;
generatedVersion = BitConverter.GetBytes(offset.Ticks);
return generatedVersion;
};

LiteDbMovie addition = TestData.Movies.OfType<LiteDbMovie>(TestData.Movies.BlackPanther);
addition.Id = null;
LiteDbMovie sut = addition.Clone();
await repository.CreateAsync(sut);
LiteDbMovie actual = await GetEntityAsync(sut.Id);

actual.Should().BeEquivalentTo<IMovie>(addition);
actual.UpdatedAt.Should().BeAfter(StartTime);
generatedVersion.Should().NotBeNullOrEmpty();
actual.Version.Should().BeEquivalentTo(generatedVersion);
}
}
Loading

0 comments on commit db1a7e2

Please sign in to comment.