From 212e9078a4c1adf9f2705ba8984e874ef0dc01d6 Mon Sep 17 00:00:00 2001 From: slewis74 Date: Tue, 8 Jun 2021 21:26:22 +1000 Subject: [PATCH 1/4] Initial work on supporting strongly typed keys (Id property on documents) --- .../Advanced/HooksFixture.cs | 4 +- .../Model/Customer.cs | 26 ++++++++++- .../Model/CustomerMap.cs | 45 ++++++++++++++++++- .../RelationalStoreFixture.cs | 24 +++++----- .../RelationalTransaction/LoadFixture.cs | 12 ++--- .../SetUp/FixtureWithRelationalStore.cs | 2 + source/Nevermore/Advanced/ReadTransaction.cs | 5 ++- source/Nevermore/IReadQueryExecutor.cs | 6 ++- .../Util/DataModificationQueryBuilder.cs | 5 ++- source/Nevermore/Util/DatabaseTypeMap.cs | 4 +- .../StronglyTypeStringExtensionMethods.cs | 13 ++++++ 11 files changed, 117 insertions(+), 29 deletions(-) create mode 100644 source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs diff --git a/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs b/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs index a9039fa7..9cde393f 100644 --- a/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs +++ b/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs @@ -19,11 +19,11 @@ public void ShouldCallHooks() using var transaction = Store.BeginTransaction(); - var customer = new Customer {Id = "C131", FirstName = "Fred", LastName = "Freddy"}; + var customer = new Customer {Id = "C131".ToCustomerId(), FirstName = "Fred", LastName = "Freddy"}; transaction.Insert(customer); AssertLogged(log, "BeforeInsert", "AfterInsert"); - customer = transaction.Load("C131"); + customer = transaction.Load("C131".ToCustomerId()); transaction.Update(customer); AssertLogged(log, "BeforeUpdate", "AfterUpdate"); diff --git a/source/Nevermore.IntegrationTests/Model/Customer.cs b/source/Nevermore.IntegrationTests/Model/Customer.cs index b77d27cc..d792165f 100644 --- a/source/Nevermore.IntegrationTests/Model/Customer.cs +++ b/source/Nevermore.IntegrationTests/Model/Customer.cs @@ -1,3 +1,4 @@ +using System; using Nevermore.IntegrationTests.Contracts; namespace Nevermore.IntegrationTests.Model @@ -9,7 +10,7 @@ public Customer() Roles = new ReferenceCollection(); } - public string Id { get; set; } + public CustomerId Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public ReferenceCollection Roles { get; } @@ -18,4 +19,27 @@ public Customer() public string ApiKey { get; set; } public string[] Passphrases { get; set; } } + + public class CustomerId + { + public CustomerId(string value) + { + Value = value; + } + + public string Value { get; } + + public override string ToString() + { + return Value; + } + } + + public static class CustomerIdExtensionMethods + { + public static CustomerId? ToCustomerId(this string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : new CustomerId(value); + } + } } \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/CustomerMap.cs b/source/Nevermore.IntegrationTests/Model/CustomerMap.cs index c4791688..554ee79b 100644 --- a/source/Nevermore.IntegrationTests/Model/CustomerMap.cs +++ b/source/Nevermore.IntegrationTests/Model/CustomerMap.cs @@ -1,3 +1,7 @@ +using System; +using System.Data.Common; +using System.Reflection; +using Nevermore.Advanced.TypeHandlers; using Nevermore.Mapping; namespace Nevermore.IntegrationTests.Model @@ -6,7 +10,7 @@ public class CustomerMap : DocumentMap { public CustomerMap() { - Id().MaxLength(100); + Id().MaxLength(100).CustomPropertyHandler(new CustomerIdPropertyHandler()); Column(m => m.FirstName).MaxLength(20); Column(m => m.LastName).MaxLength(50); Column(m => m.Nickname); @@ -14,4 +18,43 @@ public CustomerMap() Unique("UniqueCustomerNames", new[] { "FirstName", "LastName" }, "Customers must have a unique name"); } } + + class CustomerIdPropertyHandler : IPropertyHandler + { + PropertyInfo idProperty = typeof(Customer).GetProperty("Id"); + + public object Read(object target) + { + return (idProperty.GetValue(target) as CustomerId)?.Value; + } + + public void Write(object target, object value) + { + if (value is CustomerId) + idProperty.SetValue(target, value); + else + idProperty.SetValue(target, ((string)value).ToCustomerId()); + } + } + + class CustomerIdTypeHandler : ITypeHandler + { + public bool CanConvert(Type objectType) + { + return objectType == typeof(CustomerId); + } + + public object ReadDatabase(DbDataReader reader, int columnIndex) + { + if (reader.IsDBNull(columnIndex)) + return default(CustomerId); + var text = reader.GetString(columnIndex); + return new CustomerId(text); + } + + public void WriteDatabase(DbParameter parameter, object value) + { + parameter.Value = ((CustomerId)value)?.Value; + } + } } \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs index 121ca03c..78e9505b 100644 --- a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs +++ b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs @@ -15,16 +15,16 @@ public void ShouldGenerateIdsUnlessExplicitlyAssigned() // The Id columns allow you to give records an ID, or use an auto-generated, unique ID using (var transaction = Store.BeginTransaction()) { - var customer1 = new Customer {Id = "Customers-Alice", FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; + var customer1 = new Customer {Id = "Customers-Alice".ToCustomerId(), FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; var customer2 = new Customer {FirstName = "Bob", LastName = "Banana", LuckyNumbers = new[] {12, 13}, Nickname = "B-man", Roles = {"web-server", "app-server"}}; var customer3 = new Customer {FirstName = "Charlie", LastName = "Cherry", LuckyNumbers = new[] {12, 13}, Nickname = "Chazza", Roles = {"web-server", "app-server"}}; transaction.Insert(customer1); transaction.Insert(customer2); transaction.Insert(customer3, new InsertOptions { CustomAssignedId = "Customers-Chazza"}); - customer1.Id.Should().Be("Customers-Alice"); - customer2.Id.Should().StartWith("Customers-"); - customer3.Id.Should().Be("Customers-Chazza"); + customer1.Id.Value.Should().Be("Customers-Alice"); + customer2.Id.Value.Should().StartWith("Customers-"); + customer3.Id.Value.Should().Be("Customers-Chazza"); transaction.Commit(); } @@ -82,7 +82,7 @@ public void ShouldPersistCollectionsToAllowInSearches() [Test] public void ShouldHandleIdsWithInOperand() { - string customerId; + CustomerId customerId; using (var transaction = Store.BeginTransaction()) { var customer = new Customer {FirstName = "Alice", LastName = "Apple"}; @@ -94,7 +94,7 @@ public void ShouldHandleIdsWithInOperand() using (var transaction = Store.BeginTransaction()) { var customer = transaction.Query() - .Where("Id", ArraySqlOperand.In, new[] {customerId}) + .Where("Id", ArraySqlOperand.In, new[] {customerId.Value}) .Stream() .Single(); customer.FirstName.Should().Be("Alice"); @@ -233,7 +233,7 @@ public void ShouldShowFriendlyUniqueConstraintErrors() [Test] public void ShouldPersistAndLoadReferenceCollectionsOnSingleDocuments() { - var customerId = string.Empty; + CustomerId? customerId = null; using (var transaction = Store.BeginTransaction()) { var customer = new Customer {FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; @@ -243,7 +243,7 @@ public void ShouldPersistAndLoadReferenceCollectionsOnSingleDocuments() } using (var transaction = Store.BeginTransaction()) { - var loadedCustomer = transaction.Load(customerId); + var loadedCustomer = transaction.Load(customerId); loadedCustomer.Roles.Count.Should().Be(2); } } @@ -256,7 +256,7 @@ public void ShouldUseIdPassedInToInsertMethod() { var customer = new Customer {FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345" }); - Assert.That(customer.Id, Is.EqualTo("12345"), "Id passed in should be used"); + Assert.That(customer.Id?.Value, Is.EqualTo("12345"), "Id passed in should be used"); } } @@ -265,9 +265,9 @@ public void ShouldUseIdPassedInIfSame() { using (var transaction = Store.BeginTransaction()) { - var customer = new Customer {Id = "12345", FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; + var customer = new Customer {Id = "12345".ToCustomerId(), FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345" }); - Assert.That(customer.Id, Is.EqualTo("12345"), "Id passed in should be used if same"); + Assert.That(customer.Id?.Value, Is.EqualTo("12345"), "Id passed in should be used if same"); } } @@ -278,7 +278,7 @@ public void ShouldThrowIfConflictingIdsPassedIn() { Assert.Throws(() => { - var customer = new Customer {Id = "123456", FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; + var customer = new Customer {Id = "123456".ToCustomerId(), FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345" }); }); } diff --git a/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs b/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs index 0fe91a19..9680d2c8 100644 --- a/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs +++ b/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs @@ -43,7 +43,7 @@ public void LoadWithMultipleIdsWithDifferentLength() Type = ProductType.Normal }; trn.Insert(product1); - + var product2 = new Product { Id = "Products-133", @@ -53,9 +53,9 @@ public void LoadWithMultipleIdsWithDifferentLength() }; trn.Insert(product2); trn.Commit(); - + var ids = new[] {product1.Id, product2.Id}; - + var products = trn.LoadMany(ids); products.Should().HaveCount(ids.Length); @@ -83,13 +83,13 @@ public void LoadStreamWithMoreThan2100Ids() public void StoreNonInheritedTypesSerializesCorrectly() { using var trn = Store.BeginTransaction(); - + var customer = new Customer { FirstName = "Bob", LastName = "Tester", Nickname = "Bob the builder", - Id = "Customers-01" + Id = "Customers-01".ToCustomerId() }; trn.Insert(customer); @@ -218,7 +218,7 @@ public void StoreStringInheritedTypesSerializeCorrectly() brandToTestSerialization.JSON.Should().Be("{\"Description\":\"Details for Brand A.\"}"); } } - + [Test] public void StoreAndLoadStringInheritedTypes() { diff --git a/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs b/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs index d73bcba3..21548bfd 100644 --- a/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs +++ b/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs @@ -33,6 +33,8 @@ protected FixtureWithRelationalStore() new DocumentWithRowVersionMap()); config.TypeHandlers.Register(new ReferenceCollectionTypeHandler()); + config.TypeHandlers.Register(new CustomerIdTypeHandler()); + config.InstanceTypeResolvers.Register(new ProductTypeResolver()); config.InstanceTypeResolvers.Register(new BrandTypeResolver()); diff --git a/source/Nevermore/Advanced/ReadTransaction.cs b/source/Nevermore/Advanced/ReadTransaction.cs index 0c7b7679..561c171c 100644 --- a/source/Nevermore/Advanced/ReadTransaction.cs +++ b/source/Nevermore/Advanced/ReadTransaction.cs @@ -14,6 +14,7 @@ using Nevermore.Diagnostics; using Nevermore.Querying.AST; using Nevermore.Transient; +using Nevermore.Util; namespace Nevermore.Advanced { @@ -81,7 +82,7 @@ public async Task OpenAsync(IsolationLevel isolationLevel) } [Pure] - private TDocument Load(TKey id) where TDocument : class + public TDocument Load(TKey id) where TDocument : class { return Stream(PrepareLoad(id)).FirstOrDefault(); } @@ -545,6 +546,8 @@ PreparedCommand PrepareLoad(TKey id) var tableName = mapping.TableName; var args = new CommandParameterValues {{"Id", id}}; + if (mapping.IdColumn.Type.IsStronglyTypedString()) + args = new CommandParameterValues {{"Id", id.ToString()}}; return new PreparedCommand($"SELECT TOP 1 * FROM [{configuration.GetSchemaNameOrDefault(mapping)}].[{tableName}] WHERE [{mapping.IdColumn.ColumnName}] = @Id", args, RetriableOperation.Select, mapping, commandBehavior: CommandBehavior.SingleResult | CommandBehavior.SingleRow | CommandBehavior.SequentialAccess); } diff --git a/source/Nevermore/IReadQueryExecutor.cs b/source/Nevermore/IReadQueryExecutor.cs index e432d87f..0e2628a1 100644 --- a/source/Nevermore/IReadQueryExecutor.cs +++ b/source/Nevermore/IReadQueryExecutor.cs @@ -45,6 +45,8 @@ public interface IReadQueryExecutor /// The document, or null if the document is not found. [Pure] TDocument Load(Guid id) where TDocument : class; + [Pure] TDocument Load(TKey id) where TDocument : class; + /// /// Loads a single document given its ID. If the item is not found, returns null. /// @@ -523,7 +525,7 @@ public interface IReadQueryExecutor [Pure] IAsyncEnumerable StreamAsync(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); /// - /// Executes a query that returns strongly typed documents. + /// Executes a query that returns strongly typed documents. /// /// Everything needed to run the query. /// The type of document being queried. Results from the database will be mapped to this type. @@ -531,7 +533,7 @@ public interface IReadQueryExecutor [Pure] IEnumerable Stream(PreparedCommand preparedCommand); /// - /// Executes a query that returns strongly typed documents. + /// Executes a query that returns strongly typed documents. /// /// Everything needed to run the query. /// Token to use to cancel the command. diff --git a/source/Nevermore/Util/DataModificationQueryBuilder.cs b/source/Nevermore/Util/DataModificationQueryBuilder.cs index b9a166f6..a77c69e8 100644 --- a/source/Nevermore/Util/DataModificationQueryBuilder.cs +++ b/source/Nevermore/Util/DataModificationQueryBuilder.cs @@ -109,7 +109,7 @@ public PreparedCommand PrepareDelete(object id, DeleteOptions options var mapping = mappings.Resolve(typeof(TDocument)); var idType = id.GetType(); - if (mapping.IdColumn.Type != idType) + if (mapping.IdColumn.Type != idType && !mapping.IdColumn.Type.IsStronglyTypedString()) throw new ArgumentException($"Provided Id of type '{idType.FullName}' does not match configured type of '{mapping.IdColumn.Type.FullName}'."); return PrepareDelete(mapping, id, options); @@ -268,7 +268,8 @@ CommandParameterValues GetDocumentParameters(Func allocateI customAssignedId != null && id != null && customAssignedId != id) throw new ArgumentException("Do not pass a different Id when one is already set on the document"); - if (mapping.IdColumn.Type == typeof(string) && string.IsNullOrWhiteSpace((string)id)) + if ((mapping.IdColumn.Type == typeof(string) && string.IsNullOrWhiteSpace((string)id)) || + (mapping.IdColumn.Type.IsStronglyTypedString() && string.IsNullOrWhiteSpace(id?.ToString()))) { id = string.IsNullOrWhiteSpace(customAssignedId as string) ? allocateId(mapping) : customAssignedId; mapping.IdColumn.PropertyHandler.Write(document, id); diff --git a/source/Nevermore/Util/DatabaseTypeMap.cs b/source/Nevermore/Util/DatabaseTypeMap.cs index 4ace37a7..afb1b5c5 100644 --- a/source/Nevermore/Util/DatabaseTypeMap.cs +++ b/source/Nevermore/Util/DatabaseTypeMap.cs @@ -63,8 +63,8 @@ static DatabaseTypeConverter() { return DbType.Binary; } - if (propertyType == typeof(StreamReader) || propertyType == typeof(TextReader) || propertyType == typeof(StringReader) || - typeof(TextReader).IsAssignableFrom(propertyType) ) + if (propertyType == typeof(StreamReader) || propertyType == typeof(TextReader) || propertyType == typeof(StringReader) || + typeof(TextReader).IsAssignableFrom(propertyType) || propertyType.IsStronglyTypedString()) { return DbType.String; } diff --git a/source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs b/source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs new file mode 100644 index 00000000..ce2ed6f1 --- /dev/null +++ b/source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs @@ -0,0 +1,13 @@ +using System; + +namespace Nevermore.Util +{ + static class StronglyTypeStringExtensionMethods + { + public static bool IsStronglyTypedString(this Type type) + { + var property = type.GetProperty("Value"); + return type.GetProperties().Length == 1 && property != null && property.PropertyType == typeof(string); + } + } +} \ No newline at end of file From 8f1cd298a9d94c766ddd866ba4da65aee221a0fa Mon Sep 17 00:00:00 2001 From: slewis74 Date: Wed, 9 Jun 2021 13:57:22 +1000 Subject: [PATCH 2/4] Rolling back the change to DatabaseTypeMap, the SchemaGenerator should be taking care of this --- source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs | 2 +- source/Nevermore/Util/DatabaseTypeMap.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs b/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs index 166a0b76..7f0aba63 100644 --- a/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs +++ b/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs @@ -52,7 +52,7 @@ static bool IsNullable(ColumnMapping column) static string GetDatabaseType(ColumnMapping column) { - var dbType = DatabaseTypeConverter.AsDbType(column.Type); + var dbType = column.Type.IsStronglyTypedString() ? DbType.String : DatabaseTypeConverter.AsDbType(column.Type); switch (dbType) { diff --git a/source/Nevermore/Util/DatabaseTypeMap.cs b/source/Nevermore/Util/DatabaseTypeMap.cs index afb1b5c5..bee9121f 100644 --- a/source/Nevermore/Util/DatabaseTypeMap.cs +++ b/source/Nevermore/Util/DatabaseTypeMap.cs @@ -64,7 +64,7 @@ static DatabaseTypeConverter() return DbType.Binary; } if (propertyType == typeof(StreamReader) || propertyType == typeof(TextReader) || propertyType == typeof(StringReader) || - typeof(TextReader).IsAssignableFrom(propertyType) || propertyType.IsStronglyTypedString()) + typeof(TextReader).IsAssignableFrom(propertyType)) { return DbType.String; } From c5265a4b64eb8ce98f7bd63d833e7f8ef24154cc Mon Sep 17 00:00:00 2001 From: slewis74 Date: Wed, 9 Jun 2021 13:59:05 +1000 Subject: [PATCH 3/4] Adding behaviour to PropertyHandler, so consumers don't need to add a custom property handler as well as a TypeHandler --- .../Model/Customer.cs | 28 +++++++++++++++ .../Model/CustomerMap.cs | 21 +----------- .../RelationalStoreFixture.cs | 2 +- .../PropertyHandlers/PropertyHandler.cs | 34 +++++++++++++++---- .../Util/DataModificationQueryBuilder.cs | 2 +- 5 files changed, 59 insertions(+), 28 deletions(-) diff --git a/source/Nevermore.IntegrationTests/Model/Customer.cs b/source/Nevermore.IntegrationTests/Model/Customer.cs index d792165f..4e87ba97 100644 --- a/source/Nevermore.IntegrationTests/Model/Customer.cs +++ b/source/Nevermore.IntegrationTests/Model/Customer.cs @@ -33,6 +33,34 @@ public override string ToString() { return Value; } + + public static bool operator !=(CustomerId? a, CustomerId? b) + { + return !(a == b); + } + public static bool operator ==(CustomerId? a, CustomerId? b) + { + return (a is null && b is null) || + (!(a is null) && a.Equals(b)); + } + + protected bool Equals(CustomerId other) + { + return Value == other.Value; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((CustomerId) obj); + } + + public override int GetHashCode() + { + return (Value != null ? Value.GetHashCode() : 0); + } } public static class CustomerIdExtensionMethods diff --git a/source/Nevermore.IntegrationTests/Model/CustomerMap.cs b/source/Nevermore.IntegrationTests/Model/CustomerMap.cs index 554ee79b..515b8290 100644 --- a/source/Nevermore.IntegrationTests/Model/CustomerMap.cs +++ b/source/Nevermore.IntegrationTests/Model/CustomerMap.cs @@ -1,6 +1,5 @@ using System; using System.Data.Common; -using System.Reflection; using Nevermore.Advanced.TypeHandlers; using Nevermore.Mapping; @@ -10,7 +9,7 @@ public class CustomerMap : DocumentMap { public CustomerMap() { - Id().MaxLength(100).CustomPropertyHandler(new CustomerIdPropertyHandler()); + Id(x => x.Id).MaxLength(100); Column(m => m.FirstName).MaxLength(20); Column(m => m.LastName).MaxLength(50); Column(m => m.Nickname); @@ -19,24 +18,6 @@ public CustomerMap() } } - class CustomerIdPropertyHandler : IPropertyHandler - { - PropertyInfo idProperty = typeof(Customer).GetProperty("Id"); - - public object Read(object target) - { - return (idProperty.GetValue(target) as CustomerId)?.Value; - } - - public void Write(object target, object value) - { - if (value is CustomerId) - idProperty.SetValue(target, value); - else - idProperty.SetValue(target, ((string)value).ToCustomerId()); - } - } - class CustomerIdTypeHandler : ITypeHandler { public bool CanConvert(Type objectType) diff --git a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs index 78e9505b..1de4a503 100644 --- a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs +++ b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs @@ -266,7 +266,7 @@ public void ShouldUseIdPassedInIfSame() using (var transaction = Store.BeginTransaction()) { var customer = new Customer {Id = "12345".ToCustomerId(), FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; - transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345" }); + transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345".ToCustomerId() }); Assert.That(customer.Id?.Value, Is.EqualTo("12345"), "Id passed in should be used if same"); } } diff --git a/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs b/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs index ae53a9b5..ffdd4df1 100644 --- a/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs +++ b/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Linq.Expressions; using System.Reflection; +using Nevermore.Util; namespace Nevermore.Advanced.PropertyHandlers { @@ -84,12 +86,32 @@ static Action CompileSetter(PropertyInfo propertyInfo) var assign = Expression.Assign( Expression.Property(Expression.Convert(targetArgument, propertyInfo.DeclaringType), propertyInfo), - Expression.Convert(valueArgument, propertyInfo.PropertyType)); + CompileConvert(propertyInfo, valueArgument)); var setter = Expression.Lambda>(assign, targetArgument, valueArgument); return setter.Compile(); } + static Expression CompileConvert(PropertyInfo propertyInfo, ParameterExpression valueArgument) + { + if (!propertyInfo.PropertyType.IsStronglyTypedString()) + return Expression.Convert(valueArgument, propertyInfo.PropertyType); + + // the strongly typed string must either have a ctor parameter for the value + var constructorInfos = propertyInfo.PropertyType.GetConstructors(); + var ctorInfo = constructorInfos.SingleOrDefault(c => c.GetParameters().Length == 1); + if (ctorInfo != null) + { + return Expression.New(ctorInfo, Expression.Convert(valueArgument, typeof(string))); + } + + // or the Value property must have a setter + var ctor = Expression.New(propertyInfo.PropertyType); + var valueProperty = propertyInfo.PropertyType.GetProperty("Value"); + var valueAssignment = Expression.Bind(valueProperty, Expression.Convert(valueArgument, typeof(string))); + return Expression.MemberInit(ctor, valueAssignment); + } + static Action TryCompileListSetter(PropertyInfo propertyInfo) { var targetArgument = Expression.Parameter(typeof(object), "target"); @@ -98,7 +120,7 @@ static Action TryCompileListSetter(PropertyInfo propertyInfo) var typeT = GetGenericListArgumentType(propertyInfo.PropertyType); if (typeT == null) return null; - + var collectionT = typeof(ICollection<>).MakeGenericType(typeT); var enumerableT = typeof(IEnumerable<>).MakeGenericType(typeT); @@ -110,15 +132,15 @@ static Action TryCompileListSetter(PropertyInfo propertyInfo) if (!collectionT.IsAssignableFrom(propertyInfo.PropertyType)) return null; - + var setter = Expression.Lambda>( - Expression.Call(null, assignMethod, + Expression.Call(null, assignMethod, Expression.Convert( Expression.Property( Expression.Convert(targetArgument, propertyInfo.DeclaringType), propertyInfo), collectionT), - + Expression.Convert( valueArgument, enumerableT @@ -143,7 +165,7 @@ static Type GetGenericListArgumentType(Type propertyType) return implementedInterface.GenericTypeArguments[0]; } } - + return null; } diff --git a/source/Nevermore/Util/DataModificationQueryBuilder.cs b/source/Nevermore/Util/DataModificationQueryBuilder.cs index a77c69e8..202c608f 100644 --- a/source/Nevermore/Util/DataModificationQueryBuilder.cs +++ b/source/Nevermore/Util/DataModificationQueryBuilder.cs @@ -265,7 +265,7 @@ CommandParameterValues GetDocumentParameters(Func allocateI var id = mapping.IdColumn.PropertyHandler.Read(document); if (customIdAssignmentBehavior == CustomIdAssignmentBehavior.ThrowIfIdAlreadySetToDifferentValue && - customAssignedId != null && id != null && customAssignedId != id) + customAssignedId != null && id != null && !id.Equals(customAssignedId)) throw new ArgumentException("Do not pass a different Id when one is already set on the document"); if ((mapping.IdColumn.Type == typeof(string) && string.IsNullOrWhiteSpace((string)id)) || From 07519aa0398bf274769455e34707bd6bb8147e61 Mon Sep 17 00:00:00 2001 From: slewis74 Date: Wed, 9 Jun 2021 17:45:23 +1000 Subject: [PATCH 4/4] Re-simplified the PropertyHandler and modified how AllocateId works, so the expected type is passed through the callback --- .../KeyAllocatorFixture.cs | 28 ++++++------- .../Model/Customer.cs | 2 +- .../Model/CustomerMap.cs | 2 +- .../RelationalStoreFixture.cs | 4 +- .../SetUp/SchemaGenerator.cs | 2 +- .../Delete/DeleteQueryBuilderFixture.cs | 6 +-- .../DataModificationQueryBuilderFixture.cs | 2 +- .../PropertyHandlers/PropertyHandler.cs | 24 +----------- source/Nevermore/Advanced/ReadTransaction.cs | 4 +- .../TypeHandlers/ITypeHandlerRegistry.cs | 3 +- .../TypeHandlers/TypeHandlerRegistry.cs | 3 +- source/Nevermore/Advanced/WriteTransaction.cs | 39 +++++++++++++++---- source/Nevermore/IReadQueryExecutor.cs | 7 ++++ source/Nevermore/IWriteQueryExecutor.cs | 14 ++++--- source/Nevermore/Mapping/DocumentMap.cs | 4 +- .../Util/DataModificationQueryBuilder.cs | 25 ++++++------ .../Util/StronglyTypeIdExtensionMethods.cs | 12 ++++++ .../StronglyTypeStringExtensionMethods.cs | 13 ------- 18 files changed, 103 insertions(+), 91 deletions(-) create mode 100644 source/Nevermore/Util/StronglyTypeIdExtensionMethods.cs delete mode 100644 source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs diff --git a/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs b/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs index 034756cf..3c207039 100644 --- a/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs +++ b/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs @@ -18,7 +18,7 @@ public void ShouldAllocateKeysInChunks() var allocatorA = new KeyAllocator(Store, 10); var allocatorB = new KeyAllocator(Store, 10); - // A gets 1-10 + // A gets 1-10 AssertNext(allocatorA, "Todos", 1); AssertNext(allocatorA, "Todos", 2); AssertNext(allocatorA, "Todos", 3); @@ -76,7 +76,7 @@ public void ShouldAllocateInParallel() const int allocationCount = 20; const int threadCount = 10; - var projectIds = new ConcurrentBag(); + var customerIds = new ConcurrentBag(); var deploymentIds = new ConcurrentBag(); var random = new Random(1); @@ -90,22 +90,22 @@ public void ShouldAllocateInParallel() var sequence = random.Next(3); if (sequence == 0) { - var id = transaction.AllocateId(typeof (Customer)); - projectIds.Add(id); + var id = transaction.AllocateId(typeof (Customer), typeof(CustomerId)); + customerIds.Add((CustomerId)id); transaction.Commit(); } else if (sequence == 1) { // Abandon some transactions (just projects to make it easier) - var id = transaction.AllocateId(typeof(Customer)); + var id = transaction.AllocateId(typeof(Customer), typeof(CustomerId)); // Abandoned Ids are not returned to the pool - projectIds.Add(id); + customerIds.Add((CustomerId)id); transaction.Dispose(); } else if (sequence == 2) { - var id = transaction.AllocateId(typeof(Order)); - deploymentIds.Add(id); + var id = transaction.AllocateId(typeof(Order), typeof(string)); + deploymentIds.Add((string)id); transaction.Commit(); } } @@ -115,21 +115,21 @@ public void ShouldAllocateInParallel() Task.WaitAll(tasks); Func removePrefix = x => int.Parse(x.Split('-')[1]); - var projectIdsAfter = projectIds.Select(removePrefix).OrderBy(x => x).ToArray(); + var customerIdsAfter = customerIds.Select(x => removePrefix(x.Value)).OrderBy(x => x).ToArray(); var deploymentIdsAfter = deploymentIds.Select(removePrefix).OrderBy(x => x).ToArray(); - projectIdsAfter.Distinct().Count().Should().Be(projectIdsAfter.Length); + customerIdsAfter.Distinct().Count().Should().Be(customerIdsAfter.Length); deploymentIdsAfter.Distinct().Count().Should().Be(deploymentIdsAfter.Length); - + // Check that there are no gaps in sequence - var firstProjectId = projectIdsAfter.First(); - var lastProjectId = projectIdsAfter.Last(); + var firstProjectId = customerIdsAfter.First(); + var lastProjectId = customerIdsAfter.Last(); var expectedProjectIds = Enumerable.Range(firstProjectId, lastProjectId - firstProjectId + 1) .ToList(); - projectIdsAfter.Should().BeEquivalentTo(expectedProjectIds); + customerIdsAfter.Should().BeEquivalentTo(expectedProjectIds); } static void AssertNext(KeyAllocator allocator, string collection, int expected) diff --git a/source/Nevermore.IntegrationTests/Model/Customer.cs b/source/Nevermore.IntegrationTests/Model/Customer.cs index 4e87ba97..d5fef063 100644 --- a/source/Nevermore.IntegrationTests/Model/Customer.cs +++ b/source/Nevermore.IntegrationTests/Model/Customer.cs @@ -22,7 +22,7 @@ public Customer() public class CustomerId { - public CustomerId(string value) + internal CustomerId(string value) { Value = value; } diff --git a/source/Nevermore.IntegrationTests/Model/CustomerMap.cs b/source/Nevermore.IntegrationTests/Model/CustomerMap.cs index 515b8290..194e6e65 100644 --- a/source/Nevermore.IntegrationTests/Model/CustomerMap.cs +++ b/source/Nevermore.IntegrationTests/Model/CustomerMap.cs @@ -9,7 +9,7 @@ public class CustomerMap : DocumentMap { public CustomerMap() { - Id(x => x.Id).MaxLength(100); + Id().MaxLength(100); Column(m => m.FirstName).MaxLength(20); Column(m => m.LastName).MaxLength(50); Column(m => m.Nickname); diff --git a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs index 1de4a503..6ccdae5b 100644 --- a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs +++ b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs @@ -20,7 +20,7 @@ public void ShouldGenerateIdsUnlessExplicitlyAssigned() var customer3 = new Customer {FirstName = "Charlie", LastName = "Cherry", LuckyNumbers = new[] {12, 13}, Nickname = "Chazza", Roles = {"web-server", "app-server"}}; transaction.Insert(customer1); transaction.Insert(customer2); - transaction.Insert(customer3, new InsertOptions { CustomAssignedId = "Customers-Chazza"}); + transaction.Insert(customer3, new InsertOptions { CustomAssignedId = "Customers-Chazza".ToCustomerId() }); customer1.Id.Value.Should().Be("Customers-Alice"); customer2.Id.Value.Should().StartWith("Customers-"); @@ -255,7 +255,7 @@ public void ShouldUseIdPassedInToInsertMethod() using (var transaction = Store.BeginTransaction()) { var customer = new Customer {FirstName = "Alice", LastName = "Apple", LuckyNumbers = new[] {12, 13}, Nickname = "Ally", Roles = {"web-server", "app-server"}}; - transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345" }); + transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345".ToCustomerId() }); Assert.That(customer.Id?.Value, Is.EqualTo("12345"), "Id passed in should be used"); } } diff --git a/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs b/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs index 7f0aba63..322cb128 100644 --- a/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs +++ b/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs @@ -52,7 +52,7 @@ static bool IsNullable(ColumnMapping column) static string GetDatabaseType(ColumnMapping column) { - var dbType = column.Type.IsStronglyTypedString() ? DbType.String : DatabaseTypeConverter.AsDbType(column.Type); + var dbType = column.Type.IsStronglyTypedId() ? DbType.String : DatabaseTypeConverter.AsDbType(column.Type); switch (dbType) { diff --git a/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs b/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs index 18906628..156861b4 100644 --- a/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs +++ b/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs @@ -27,7 +27,7 @@ public DeleteQueryBuilderFixture() mappings = Substitute.For(); mappings.Resolve().Returns(c => new EmptyMap()); mappings.Resolve(Arg.Any()).Returns(c => new EmptyMap()); - + queryExecutor = Substitute.For(); queryExecutor.ExecuteNonQuery(Arg.Any()).Returns(info => { @@ -43,7 +43,7 @@ IDeleteQueryBuilder CreateQueryBuilder() where TDocument : configuration.DocumentSerializer = new NewtonsoftDocumentSerializer(configuration); return new DeleteQueryBuilder( new UniqueParameterNameGenerator(), - new DataModificationQueryBuilder(configuration, s => null), + new DataModificationQueryBuilder(configuration, (s, _) => null), queryExecutor ); } @@ -164,7 +164,7 @@ public void VariablesCasingIsNormalisedForWhere() .Parameter("OTHERVAR", "Bar") .Delete(); #pragma warning restore NV0006 - + parameters.Count.Should().Be(2); foreach (var parameter in parameters) query.Should().Contain("@" + parameter.Key, "Should contain @" + parameter.Key); diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs index 694f33bb..379e6b94 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs @@ -30,7 +30,7 @@ public DataModificationQueryBuilderFixture() new OtherMap()); builder = new DataModificationQueryBuilder( configuration, - m => idAllocator() + (m, t) => idAllocator() ); } diff --git a/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs b/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs index ffdd4df1..46ffe172 100644 --- a/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs +++ b/source/Nevermore/Advanced/PropertyHandlers/PropertyHandler.cs @@ -1,9 +1,7 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; -using Nevermore.Util; namespace Nevermore.Advanced.PropertyHandlers { @@ -86,32 +84,12 @@ static Action CompileSetter(PropertyInfo propertyInfo) var assign = Expression.Assign( Expression.Property(Expression.Convert(targetArgument, propertyInfo.DeclaringType), propertyInfo), - CompileConvert(propertyInfo, valueArgument)); + Expression.Convert(valueArgument, propertyInfo.PropertyType)); var setter = Expression.Lambda>(assign, targetArgument, valueArgument); return setter.Compile(); } - static Expression CompileConvert(PropertyInfo propertyInfo, ParameterExpression valueArgument) - { - if (!propertyInfo.PropertyType.IsStronglyTypedString()) - return Expression.Convert(valueArgument, propertyInfo.PropertyType); - - // the strongly typed string must either have a ctor parameter for the value - var constructorInfos = propertyInfo.PropertyType.GetConstructors(); - var ctorInfo = constructorInfos.SingleOrDefault(c => c.GetParameters().Length == 1); - if (ctorInfo != null) - { - return Expression.New(ctorInfo, Expression.Convert(valueArgument, typeof(string))); - } - - // or the Value property must have a setter - var ctor = Expression.New(propertyInfo.PropertyType); - var valueProperty = propertyInfo.PropertyType.GetProperty("Value"); - var valueAssignment = Expression.Bind(valueProperty, Expression.Convert(valueArgument, typeof(string))); - return Expression.MemberInit(ctor, valueAssignment); - } - static Action TryCompileListSetter(PropertyInfo propertyInfo) { var targetArgument = Expression.Parameter(typeof(object), "target"); diff --git a/source/Nevermore/Advanced/ReadTransaction.cs b/source/Nevermore/Advanced/ReadTransaction.cs index 561c171c..1d2f0a6d 100644 --- a/source/Nevermore/Advanced/ReadTransaction.cs +++ b/source/Nevermore/Advanced/ReadTransaction.cs @@ -545,9 +545,7 @@ PreparedCommand PrepareLoad(TKey id) throw new ArgumentException($"Provided Id of type '{id.GetType().FullName}' does not match configured type of '{mapping.IdColumn.Type.FullName}'."); var tableName = mapping.TableName; - var args = new CommandParameterValues {{"Id", id}}; - if (mapping.IdColumn.Type.IsStronglyTypedString()) - args = new CommandParameterValues {{"Id", id.ToString()}}; + var args = new CommandParameterValues {{ "Id", mapping.IdColumn.Type.IsStronglyTypedId() ? (object)id.ToString() : id }}; return new PreparedCommand($"SELECT TOP 1 * FROM [{configuration.GetSchemaNameOrDefault(mapping)}].[{tableName}] WHERE [{mapping.IdColumn.ColumnName}] = @Id", args, RetriableOperation.Select, mapping, commandBehavior: CommandBehavior.SingleResult | CommandBehavior.SingleRow | CommandBehavior.SequentialAccess); } diff --git a/source/Nevermore/Advanced/TypeHandlers/ITypeHandlerRegistry.cs b/source/Nevermore/Advanced/TypeHandlers/ITypeHandlerRegistry.cs index 41a23357..1a635574 100644 --- a/source/Nevermore/Advanced/TypeHandlers/ITypeHandlerRegistry.cs +++ b/source/Nevermore/Advanced/TypeHandlers/ITypeHandlerRegistry.cs @@ -1,10 +1,11 @@ +#nullable enable using System; namespace Nevermore.Advanced.TypeHandlers { public interface ITypeHandlerRegistry { - ITypeHandler Resolve(Type type); + ITypeHandler? Resolve(Type type); void Register(ITypeHandler handler); } } \ No newline at end of file diff --git a/source/Nevermore/Advanced/TypeHandlers/TypeHandlerRegistry.cs b/source/Nevermore/Advanced/TypeHandlers/TypeHandlerRegistry.cs index 1f4235d8..b5219af5 100644 --- a/source/Nevermore/Advanced/TypeHandlers/TypeHandlerRegistry.cs +++ b/source/Nevermore/Advanced/TypeHandlers/TypeHandlerRegistry.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -10,7 +11,7 @@ public class TypeHandlerRegistry : ITypeHandlerRegistry readonly List typeHandlers = new List(); readonly ConcurrentDictionary cache = new ConcurrentDictionary(); - public ITypeHandler Resolve(Type type) + public ITypeHandler? Resolve(Type type) { if (cache.TryGetValue(type, out var existing)) return existing; diff --git a/source/Nevermore/Advanced/WriteTransaction.cs b/source/Nevermore/Advanced/WriteTransaction.cs index fd7c2a39..7e1a748a 100644 --- a/source/Nevermore/Advanced/WriteTransaction.cs +++ b/source/Nevermore/Advanced/WriteTransaction.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Nevermore.Diagnostics; @@ -200,26 +202,47 @@ public IDeleteQueryBuilder DeleteQuery() where TDocument : return new DeleteQueryBuilder(ParameterNameGenerator, builder, this); } - public string AllocateId(Type documentType) + public object AllocateId(Type documentType, Type idColumnType) { var mapping = configuration.DocumentMaps.Resolve(documentType); - return AllocateId(mapping); + return AllocateId(mapping, idColumnType); } - string AllocateId(DocumentMap mapping) + object AllocateId(DocumentMap mapping, Type idColumnType) { - return AllocateId(mapping.TableName, mapping.IdFormat); + return AllocateId(mapping.TableName, idColumnType, mapping.IdFormat); } - public string AllocateId(string tableName, string idPrefix) + public object AllocateId(string tableName, string idPrefix, Type idColumnType) { - return AllocateId(tableName, key => $"{idPrefix}-{key}"); + return AllocateId(tableName, idColumnType, key => $"{idPrefix}-{key}"); } - public string AllocateId(string tableName, Func idFormatter) + public object AllocateId(string tableName, Type idColumnType, Func idFormatter) { var key = keyAllocator.NextId(tableName); - return idFormatter(key); + var id = idFormatter(key); + if (id.GetType() != idColumnType && idColumnType.IsStronglyTypedId()) + id = ConvertToStronglyTypedId(idColumnType, id); + return id; + } + + object ConvertToStronglyTypedId(Type idColumnType, object value) + { + // see if there's a constructor that takes the same type as value, if so call it + var ctorInfo = idColumnType.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).SingleOrDefault(c => c.GetParameters().Length == 1 && c.GetParameters().Single().ParameterType == value.GetType()); + if (ctorInfo != null) + { + return Activator.CreateInstance(idColumnType, bindingAttr:BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new [] { value }, CultureInfo.CurrentCulture); + } + + // else call the default ctor and then look to see if there is a property with the same type that can be set + var id = Activator.CreateInstance(idColumnType); + var propInfo = idColumnType.GetProperties().FirstOrDefault(p => p.CanWrite && p.PropertyType == value.GetType()); + if (propInfo == null) + throw new ArgumentException($"Unable to locate a constructor or settable property taking type {value.GetType().Name} that can be used to create an instance of {idColumnType.Name}"); + propInfo.SetValue(id, value); + return id; } public void Commit() diff --git a/source/Nevermore/IReadQueryExecutor.cs b/source/Nevermore/IReadQueryExecutor.cs index 0e2628a1..357d00b6 100644 --- a/source/Nevermore/IReadQueryExecutor.cs +++ b/source/Nevermore/IReadQueryExecutor.cs @@ -45,6 +45,13 @@ public interface IReadQueryExecutor /// The document, or null if the document is not found. [Pure] TDocument Load(Guid id) where TDocument : class; + /// + /// Loads a single document given its ID. If the item is not found, returns null. + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The type of the Id + /// The Id of the document to find. + /// The document, or null if the document is not found. [Pure] TDocument Load(TKey id) where TDocument : class; /// diff --git a/source/Nevermore/IWriteQueryExecutor.cs b/source/Nevermore/IWriteQueryExecutor.cs index 32f5b94b..46c075a2 100644 --- a/source/Nevermore/IWriteQueryExecutor.cs +++ b/source/Nevermore/IWriteQueryExecutor.cs @@ -41,7 +41,7 @@ public interface IWriteQueryExecutor : IReadQueryExecutor /// /// Immediately inserts multiple items into a specific table. Useful for up to a few hundred items, but not more - /// (depends on the number of properties in each item). + /// (depends on the number of properties in each item). /// /// The type of document being inserted. /// The document instances to insert (will be formed into a multiple VALUES for a single SQL INSERT. @@ -68,7 +68,7 @@ public interface IWriteQueryExecutor : IReadQueryExecutor Task InsertManyAsync(IReadOnlyCollection documents, InsertOptions options, CancellationToken cancellationToken = default) where TDocument : class; /// - /// Updates an existing document in the database. + /// Updates an existing document in the database. /// /// The type of document being updated. /// The document to update. @@ -76,7 +76,7 @@ public interface IWriteQueryExecutor : IReadQueryExecutor void Update(TDocument document, UpdateOptions options = null) where TDocument : class; /// - /// Updates an existing document in the database. + /// Updates an existing document in the database. /// /// The type of document being updated. /// The document to update. @@ -84,7 +84,7 @@ public interface IWriteQueryExecutor : IReadQueryExecutor Task UpdateAsync(TDocument document, CancellationToken cancellationToken = default) where TDocument : class; /// - /// Updates an existing document in the database. + /// Updates an existing document in the database. /// /// The type of document being updated. /// The document to update. @@ -229,15 +229,17 @@ public interface IWriteQueryExecutor : IReadQueryExecutor /// If the mapping specifies a SingletonId, that is returned /// /// + /// /// - string AllocateId(Type documentType); + object AllocateId(Type documentType, Type idColumnType); /// /// Allocates an ID using the specified table name. Any mapping for that table is not used. /// /// /// + /// /// - string AllocateId(string tableName, string idPrefix); + object AllocateId(string tableName, string idPrefix, Type idColumnType); } } \ No newline at end of file diff --git a/source/Nevermore/Mapping/DocumentMap.cs b/source/Nevermore/Mapping/DocumentMap.cs index e00c1b05..59bd6799 100644 --- a/source/Nevermore/Mapping/DocumentMap.cs +++ b/source/Nevermore/Mapping/DocumentMap.cs @@ -46,7 +46,7 @@ protected string IdPrefix /// /// Gets or sets a formatting function used to format generated document IDs. Examples: i => "C" + i; /// - protected Func IdFormat + protected Func IdFormat { get => map.IdFormat; set => map.IdFormat = value; @@ -304,7 +304,7 @@ public DocumentMap() public JsonStorageFormat JsonStorageFormat { get; set; } public string TableName { get; set; } public string IdPrefix { get; set; } - public Func IdFormat { get; set; } + public Func IdFormat { get; set; } public bool ExpectLargeDocuments { get; set; } diff --git a/source/Nevermore/Util/DataModificationQueryBuilder.cs b/source/Nevermore/Util/DataModificationQueryBuilder.cs index 202c608f..7e1259ba 100644 --- a/source/Nevermore/Util/DataModificationQueryBuilder.cs +++ b/source/Nevermore/Util/DataModificationQueryBuilder.cs @@ -24,9 +24,9 @@ internal class DataModificationQueryBuilder readonly IDocumentMapRegistry mappings; readonly IDocumentSerializer serializer; readonly IRelationalStoreConfiguration configuration; - readonly Func keyAllocator; + readonly Func keyAllocator; - public DataModificationQueryBuilder(IRelationalStoreConfiguration configuration, Func keyAllocator) + public DataModificationQueryBuilder(IRelationalStoreConfiguration configuration, Func keyAllocator) { this.mappings = configuration.DocumentMaps; this.serializer = configuration.DocumentSerializer; @@ -41,7 +41,7 @@ public PreparedCommand PrepareInsert(IReadOnlyList documents, InsertOpti var sb = new StringBuilder(); AppendInsertStatement(sb, mapping, options.TableName, options.SchemaName, options.Hint, documents.Count, options.IncludeDefaultModelColumns); - var parameters = GetDocumentParameters(m => keyAllocator(m), options.CustomAssignedId, documents, mapping, DataModification.Insert); + var parameters = GetDocumentParameters((m, t) => keyAllocator(m, t), options.CustomAssignedId, documents, mapping, DataModification.Insert); AppendRelatedDocumentStatementsForInsert(sb, parameters, mapping, documents); return new PreparedCommand(sb.ToString(), parameters, RetriableOperation.Insert, mapping, options.CommandTimeout); @@ -85,7 +85,7 @@ public PreparedCommand PrepareUpdate(object document, UpdateOptions options = nu var statement = $"UPDATE [{configuration.GetSchemaNameOrDefault(mapping)}].[{mapping.TableName}] {options.Hint ?? ""} SET {updates}{returnRowVersionStatement} WHERE [{mapping.IdColumn.ColumnName}] = @{mapping.IdColumn.ColumnName}{rowVersionCheckStatement}"; var parameters = GetDocumentParameters( - m => throw new Exception("Cannot update a document if it does not have an ID"), + (m, t) => throw new Exception("Cannot update a document if it does not have an ID"), null, null, document, @@ -109,7 +109,7 @@ public PreparedCommand PrepareDelete(object id, DeleteOptions options var mapping = mappings.Resolve(typeof(TDocument)); var idType = id.GetType(); - if (mapping.IdColumn.Type != idType && !mapping.IdColumn.Type.IsStronglyTypedString()) + if (mapping.IdColumn.Type != idType) throw new ArgumentException($"Provided Id of type '{idType.FullName}' does not match configured type of '{mapping.IdColumn.Type.FullName}'."); return PrepareDelete(mapping, id, options); @@ -233,7 +233,7 @@ void Append(string prefix) } } - CommandParameterValues GetDocumentParameters(Func allocateId, object customAssignedId, IReadOnlyList documents, DocumentMap mapping, DataModification dataModification) + CommandParameterValues GetDocumentParameters(Func allocateId, object customAssignedId, IReadOnlyList documents, DocumentMap mapping, DataModification dataModification) { if (documents.Count == 1) return GetDocumentParameters(allocateId, customAssignedId, CustomIdAssignmentBehavior.ThrowIfIdAlreadySetToDifferentValue, documents[0], mapping, dataModification, ""); @@ -260,24 +260,27 @@ enum DataModification Update } - CommandParameterValues GetDocumentParameters(Func allocateId, object customAssignedId, CustomIdAssignmentBehavior? customIdAssignmentBehavior, object document, DocumentMap mapping, DataModification dataModification, string prefix = null) + CommandParameterValues GetDocumentParameters(Func allocateId, object customAssignedId, CustomIdAssignmentBehavior? customIdAssignmentBehavior, object document, DocumentMap mapping, DataModification dataModification, string prefix = null) { var id = mapping.IdColumn.PropertyHandler.Read(document); + if (customAssignedId != null && customAssignedId.GetType() != mapping.IdColumn.Type) + throw new ArgumentException($"The given custom Id '{customAssignedId}' must be of type ({mapping.IdColumn.Type.Name}), to match the model's Id property"); + if (customIdAssignmentBehavior == CustomIdAssignmentBehavior.ThrowIfIdAlreadySetToDifferentValue && customAssignedId != null && id != null && !id.Equals(customAssignedId)) throw new ArgumentException("Do not pass a different Id when one is already set on the document"); - if ((mapping.IdColumn.Type == typeof(string) && string.IsNullOrWhiteSpace((string)id)) || - (mapping.IdColumn.Type.IsStronglyTypedString() && string.IsNullOrWhiteSpace(id?.ToString()))) + if (id == null || (mapping.IdColumn.Type == typeof(string) && string.IsNullOrWhiteSpace((string)id))) { - id = string.IsNullOrWhiteSpace(customAssignedId as string) ? allocateId(mapping) : customAssignedId; + var customIdIsNotSet = customAssignedId == null || (customAssignedId is string assignedId && string.IsNullOrWhiteSpace(assignedId)); + id = customIdIsNotSet ? allocateId(mapping, mapping.IdColumn.Type) : customAssignedId; mapping.IdColumn.PropertyHandler.Write(document, id); } var result = new CommandParameterValues { - [$"{prefix}{mapping.IdColumn.ColumnName}"] = id + [$"{prefix}{mapping.IdColumn.ColumnName}"] = mapping.IdColumn.Type.IsStronglyTypedId() ? id.ToString() : id }; switch (mapping.JsonStorageFormat) diff --git a/source/Nevermore/Util/StronglyTypeIdExtensionMethods.cs b/source/Nevermore/Util/StronglyTypeIdExtensionMethods.cs new file mode 100644 index 00000000..a3b1f360 --- /dev/null +++ b/source/Nevermore/Util/StronglyTypeIdExtensionMethods.cs @@ -0,0 +1,12 @@ +using System; + +namespace Nevermore.Util +{ + static class StronglyTypeIdExtensionMethods + { + public static bool IsStronglyTypedId(this Type type) + { + return type.IsClass && type != typeof(string); + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs b/source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs deleted file mode 100644 index ce2ed6f1..00000000 --- a/source/Nevermore/Util/StronglyTypeStringExtensionMethods.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; - -namespace Nevermore.Util -{ - static class StronglyTypeStringExtensionMethods - { - public static bool IsStronglyTypedString(this Type type) - { - var property = type.GetProperty("Value"); - return type.GetProperties().Length == 1 && property != null && property.PropertyType == typeof(string); - } - } -} \ No newline at end of file