Skip to content

Commit db1a7e2

Browse files
authored
(#125) Support for alternative Id and Version generators to support ULID, Snowflake, and other customizations. (#127)
1 parent fb3018d commit db1a7e2

File tree

14 files changed

+225
-15
lines changed

14 files changed

+225
-15
lines changed

src/CommunityToolkit.Datasync.Server.EntityFrameworkCore/EntityTableRepository.cs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,14 @@ public EntityTableRepository(DbContext context)
6060
}
6161

6262
/// <summary>
63-
/// Creates a new ID for an entity when one is not provided.
63+
/// The mechanism by which an Id is generated when one is not provided.
6464
/// </summary>
65-
/// <returns>A globally unique identifier for the entity.</returns>
66-
protected string CreateId() => Guid.NewGuid().ToString();
65+
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");
66+
67+
/// <summary>
68+
/// The mechanism by which a new version byte array is generated.
69+
/// </summary>
70+
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();
6771

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

8892
if (this.shouldUpdateVersion)
8993
{
90-
entity.Version = Guid.NewGuid().ToByteArray();
94+
entity.Version = VersionGenerator.Invoke();
9195
}
9296
}
9397

@@ -128,7 +132,7 @@ public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken can
128132
{
129133
if (string.IsNullOrEmpty(entity.Id))
130134
{
131-
entity.Id = CreateId();
135+
entity.Id = IdGenerator.Invoke(entity);
132136
}
133137

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

193197
await WrapExceptionAsync(entity.Id, async () =>
194198
{
195-
TEntity storedEntity = await DataSet.FindAsync(new object[] { entity.Id }, cancellationToken).ConfigureAwait(false)
199+
TEntity storedEntity = await DataSet.FindAsync([entity.Id], cancellationToken).ConfigureAwait(false)
196200
?? throw new HttpException((int)HttpStatusCode.NotFound);
197201

198202
if (version?.Length > 0 && !storedEntity.Version.SequenceEqual(version))

src/CommunityToolkit.Datasync.Server.InMemory/InMemoryRepository.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,21 @@ public InMemoryRepository(IEnumerable<TEntity> entities)
3434
{
3535
foreach (TEntity entity in entities)
3636
{
37-
entity.Id ??= Guid.NewGuid().ToString();
37+
entity.Id ??= IdGenerator.Invoke(entity);
3838
StoreEntity(entity);
3939
}
4040
}
4141

42+
/// <summary>
43+
/// The mechanism by which an Id is generated when one is not provided.
44+
/// </summary>
45+
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");
46+
47+
/// <summary>
48+
/// The mechanism by which a new version byte array is generated.
49+
/// </summary>
50+
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();
51+
4252
#region Internal properties and methods for testing.
4353
/// <summary>
4454
/// If set, the repository will throw this exception when any method is called.
@@ -95,7 +105,7 @@ internal void RemoveEntity(string id)
95105
internal void StoreEntity(TEntity entity)
96106
{
97107
entity.UpdatedAt = DateTimeOffset.UtcNow;
98-
entity.Version = Guid.NewGuid().ToByteArray();
108+
entity.Version = VersionGenerator.Invoke();
99109
this._entities[entity.Id] = Disconnect(entity);
100110
}
101111

@@ -124,7 +134,7 @@ public virtual ValueTask CreateAsync(TEntity entity, CancellationToken cancellat
124134
ThrowExceptionIfSet();
125135
if (string.IsNullOrEmpty(entity.Id))
126136
{
127-
entity.Id = Guid.NewGuid().ToString();
137+
entity.Id = IdGenerator.Invoke(entity);
128138
}
129139

130140
if (this._entities.TryGetValue(entity.Id, out TEntity? storedEntity))

src/CommunityToolkit.Datasync.Server.LiteDb/LiteDbRepository.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,24 @@ public LiteDbRepository(LiteDatabase dbConnection, string collectionName)
5050
/// </summary>
5151
public virtual ILiteCollection<TEntity> Collection { get; }
5252

53+
/// <summary>
54+
/// The mechanism by which an Id is generated when one is not provided.
55+
/// </summary>
56+
public Func<TEntity, string> IdGenerator { get; set; } = _ => Guid.NewGuid().ToString("N");
57+
58+
/// <summary>
59+
/// The mechanism by which a new version byte array is generated.
60+
/// </summary>
61+
public Func<byte[]> VersionGenerator { get; set; } = () => Guid.NewGuid().ToByteArray();
62+
5363
/// <summary>
5464
/// Updates the system properties for the provided entity on write.
5565
/// </summary>
5666
/// <param name="entity">The entity to update.</param>
57-
protected static void UpdateEntity(TEntity entity)
67+
protected void UpdateEntity(TEntity entity)
5868
{
5969
entity.UpdatedAt = DateTimeOffset.UtcNow;
60-
entity.Version = Guid.NewGuid().ToByteArray();
70+
entity.Version = VersionGenerator.Invoke();
6171
}
6272

6373
/// <summary>
@@ -101,7 +111,7 @@ public virtual async ValueTask CreateAsync(TEntity entity, CancellationToken can
101111
{
102112
if (string.IsNullOrEmpty(entity.Id))
103113
{
104-
entity.Id = Guid.NewGuid().ToString();
114+
entity.Id = IdGenerator.Invoke(entity);
105115
}
106116

107117
await ExecuteOnLockedCollectionAsync(() =>

src/CommunityToolkit.Datasync.Server.NSwag/DatasyncOperationProcessor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ private static void ProcessDatasyncOperation(OperationProcessorContext context)
110110
}
111111
}
112112

113-
private static void AddMissingSchemaProperties(JsonSchema? schema)
113+
internal static void AddMissingSchemaProperties(JsonSchema? schema)
114114
{
115115
if (schema is null)
116116
{

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
33
<PropertyGroup>
4-
<Authors>Microsoft.Toolkit,dotnetfoundation,Community Toolkit</Authors>
4+
<Authors>Microsoft.Toolkit,dotnetfoundation</Authors>
55
<Company>.NET Foundation</Company>
66
<Copyright>(c) .NET Foundation and Contributors. All rights reserved.</Copyright>
77
<LicenseUrl>https://github.com/CommunityToolkit/Datasync/blob/main/LICENSE.md</LicenseUrl>

tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
2+
<ItemGroup>
3+
<PackageReference Include="Ulid" Version="1.3.4" />
4+
</ItemGroup>
25
<ItemGroup>
36
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.EntityFrameworkCore\CommunityToolkit.Datasync.Server.EntityFrameworkCore.csproj" />
47
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />

tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/RepositoryControlledEntityTableRepository_Tests.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
using CommunityToolkit.Datasync.TestCommon;
66
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using CommunityToolkit.Datasync.TestCommon.Models;
78
using Microsoft.EntityFrameworkCore;
89
using Xunit.Abstractions;
910

11+
using TestData = CommunityToolkit.Datasync.TestCommon.TestData;
12+
1013
namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test;
1114

1215
[ExcludeFromCodeCoverage]
@@ -37,4 +40,51 @@ protected override Task<IRepository<RepositoryControlledEntityMovie>> GetPopulat
3740
protected override Task<string> GetRandomEntityIdAsync(bool exists)
3841
=> Task.FromResult(exists ? this.movies[this.random.Next(Context.Movies.Count())].Id : Guid.NewGuid().ToString());
3942
#endregion
43+
44+
[SkippableFact]
45+
public async Task IdGenerator_Ulid_CanCreate()
46+
{
47+
Skip.IfNot(CanRunLiveTests());
48+
49+
IRepository<RepositoryControlledEntityMovie> repository = await GetPopulatedRepositoryAsync();
50+
string generatedId = string.Empty;
51+
((EntityTableRepository<RepositoryControlledEntityMovie>)repository).IdGenerator = _ => { generatedId = Ulid.NewUlid().ToString(); return generatedId; };
52+
53+
RepositoryControlledEntityMovie addition = TestData.Movies.OfType<RepositoryControlledEntityMovie>(TestData.Movies.BlackPanther);
54+
addition.Id = null;
55+
RepositoryControlledEntityMovie sut = addition.Clone();
56+
await repository.CreateAsync(sut);
57+
RepositoryControlledEntityMovie actual = await GetEntityAsync(sut.Id);
58+
59+
actual.Should().BeEquivalentTo<IMovie>(addition);
60+
actual.UpdatedAt.Should().BeAfter(StartTime);
61+
generatedId.Should().NotBeNullOrEmpty();
62+
actual.Id.Should().Be(generatedId);
63+
}
64+
65+
[SkippableFact]
66+
public async Task VersionGenerator_Ticks_CanCreate()
67+
{
68+
Skip.IfNot(CanRunLiveTests());
69+
70+
IRepository<RepositoryControlledEntityMovie> repository = await GetPopulatedRepositoryAsync();
71+
byte[] generatedVersion = [];
72+
((EntityTableRepository<RepositoryControlledEntityMovie>)repository).VersionGenerator = () =>
73+
{
74+
DateTimeOffset offset = DateTimeOffset.UtcNow;
75+
generatedVersion = BitConverter.GetBytes(offset.Ticks);
76+
return generatedVersion;
77+
};
78+
79+
RepositoryControlledEntityMovie addition = TestData.Movies.OfType<RepositoryControlledEntityMovie>(TestData.Movies.BlackPanther);
80+
addition.Id = null;
81+
RepositoryControlledEntityMovie sut = addition.Clone();
82+
await repository.CreateAsync(sut);
83+
RepositoryControlledEntityMovie actual = await GetEntityAsync(sut.Id);
84+
85+
actual.Should().BeEquivalentTo<IMovie>(addition);
86+
actual.UpdatedAt.Should().BeAfter(StartTime);
87+
generatedVersion.Should().NotBeNullOrEmpty();
88+
actual.Version.Should().BeEquivalentTo(generatedVersion);
89+
}
4090
}

tests/CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test/SqliteEntityTableRepository_Tests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
using CommunityToolkit.Datasync.TestCommon;
66
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using CommunityToolkit.Datasync.TestCommon.Models;
78
using Microsoft.EntityFrameworkCore;
89
using Xunit.Abstractions;
910

11+
using TestData = CommunityToolkit.Datasync.TestCommon.TestData;
12+
1013
namespace CommunityToolkit.Datasync.Server.EntityFrameworkCore.Test;
1114

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

44+
[SkippableFact]
45+
public async Task IdGenerator_Ulid_CanCreate()
46+
{
47+
Skip.IfNot(CanRunLiveTests());
48+
49+
IRepository<SqliteEntityMovie> repository = await GetPopulatedRepositoryAsync();
50+
string generatedId = string.Empty;
51+
((EntityTableRepository<SqliteEntityMovie>) repository).IdGenerator = _ => { generatedId = Ulid.NewUlid().ToString(); return generatedId; };
52+
53+
SqliteEntityMovie addition = TestData.Movies.OfType<SqliteEntityMovie>(TestData.Movies.BlackPanther);
54+
addition.Id = null;
55+
SqliteEntityMovie sut = addition.Clone();
56+
await repository.CreateAsync(sut);
57+
SqliteEntityMovie actual = await GetEntityAsync(sut.Id);
58+
59+
actual.Should().BeEquivalentTo<IMovie>(addition);
60+
actual.UpdatedAt.Should().BeAfter(StartTime);
61+
generatedId.Should().NotBeNullOrEmpty();
62+
actual.Id.Should().Be(generatedId);
63+
}
64+
4165
[Fact]
4266
public void EntityTableRepository_BadDbSet_Throws()
4367
{

tests/CommunityToolkit.Datasync.Server.InMemory.Test/CommunityToolkit.Datasync.Server.InMemory.Test.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
2+
<ItemGroup>
3+
<PackageReference Include="Ulid" Version="1.3.4" />
4+
</ItemGroup>
25
<ItemGroup>
36
<ProjectReference Include="..\..\src\CommunityToolkit.Datasync.Server.InMemory\CommunityToolkit.Datasync.Server.InMemory.csproj" />
47
<ProjectReference Include="..\CommunityToolkit.Datasync.TestCommon\CommunityToolkit.Datasync.TestCommon.csproj" />

tests/CommunityToolkit.Datasync.Server.InMemory.Test/InMemoryRepository_Tests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using CommunityToolkit.Datasync.TestCommon;
66
using CommunityToolkit.Datasync.TestCommon.Databases;
7+
using CommunityToolkit.Datasync.TestCommon.Models;
78
using TestData = CommunityToolkit.Datasync.TestCommon.TestData;
89

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

125126
await act.Should().ThrowAsync<ApplicationException>();
126127
}
128+
129+
[SkippableFact]
130+
public async Task IdGenerator_Ulid_CanCreate()
131+
{
132+
Skip.IfNot(CanRunLiveTests());
133+
134+
IRepository<InMemoryMovie> repository = await GetPopulatedRepositoryAsync();
135+
string generatedId = string.Empty;
136+
((InMemoryRepository<InMemoryMovie>)repository).IdGenerator = _ => { generatedId = Ulid.NewUlid().ToString(); return generatedId; };
137+
138+
InMemoryMovie addition = TestData.Movies.OfType<InMemoryMovie>(TestData.Movies.BlackPanther);
139+
addition.Id = null;
140+
InMemoryMovie sut = addition.Clone();
141+
await repository.CreateAsync(sut);
142+
InMemoryMovie actual = await GetEntityAsync(sut.Id);
143+
144+
actual.Should().BeEquivalentTo<IMovie>(addition);
145+
actual.UpdatedAt.Should().BeAfter(StartTime);
146+
generatedId.Should().NotBeNullOrEmpty();
147+
actual.Id.Should().Be(generatedId);
148+
}
149+
150+
[SkippableFact]
151+
public async Task VersionGenerator_Ticks_CanCreate()
152+
{
153+
Skip.IfNot(CanRunLiveTests());
154+
155+
IRepository<InMemoryMovie> repository = await GetPopulatedRepositoryAsync();
156+
byte[] generatedVersion = [];
157+
((InMemoryRepository<InMemoryMovie>)repository).VersionGenerator = () =>
158+
{
159+
DateTimeOffset offset = DateTimeOffset.UtcNow;
160+
generatedVersion = BitConverter.GetBytes(offset.Ticks);
161+
return generatedVersion;
162+
};
163+
164+
InMemoryMovie addition = TestData.Movies.OfType<InMemoryMovie>(TestData.Movies.BlackPanther);
165+
addition.Id = null;
166+
InMemoryMovie sut = addition.Clone();
167+
await repository.CreateAsync(sut);
168+
InMemoryMovie actual = await GetEntityAsync(sut.Id);
169+
170+
actual.Should().BeEquivalentTo<IMovie>(addition);
171+
actual.UpdatedAt.Should().BeAfter(StartTime);
172+
generatedVersion.Should().NotBeNullOrEmpty();
173+
actual.Version.Should().BeEquivalentTo(generatedVersion);
174+
}
127175
}

0 commit comments

Comments
 (0)