diff --git a/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs b/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs index a9039fa7..06fee314 100644 --- a/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs +++ b/source/Nevermore.IntegrationTests/Advanced/HooksFixture.cs @@ -19,16 +19,16 @@ 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"); - transaction.Delete(customer); + transaction.Delete(customer); AssertLogged(log, "BeforeDelete", "AfterDelete"); transaction.Commit(); diff --git a/source/Nevermore.IntegrationTests/Advanced/IdentityIdFixture.cs b/source/Nevermore.IntegrationTests/Advanced/IdentityIdFixture.cs new file mode 100644 index 00000000..c3e1d472 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Advanced/IdentityIdFixture.cs @@ -0,0 +1,130 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Nevermore.IntegrationTests.Model; +using Nevermore.IntegrationTests.SetUp; +using NUnit.Framework; + +namespace Nevermore.IntegrationTests.Advanced +{ + public class IdentityIdFixture : FixtureWithRelationalStore + { + [Test] + public void InsertUpdatesDocumentId() + { + // ChaosSqlCommand is set to retry some of the reads which breaks row versioning code because INSERTS/UPDATES, + // even executed via SqlReader, must not be retired. + NoMonkeyBusiness(); + + var document1 = new DocumentWithIdentityId {Name = "Name"}; + + document1.Id.Should().Be(0); + + RunInTransaction(transaction => transaction.Insert(document1)); + + document1.Id.Should().NotBe(0); + } + + [Test] + public void InsertManyUpdatesDocumentIds() + { + // ChaosSqlCommand is set to retry some of the reads which breaks row versioning code because INSERTS/UPDATES, + // even executed via SqlReader, must not be retired. + NoMonkeyBusiness(); + + var document1 = new DocumentWithIdentityId {Name = "Name"}; + var document2 = new DocumentWithIdentityId {Name = "Name"}; + + document1.Id.Should().Be(0); + document2.Id.Should().Be(0); + + RunInTransaction(transaction => transaction.InsertMany(new[] + { + document1, + document2 + })); + + document1.Id.Should().NotBe(0); + document2.Id.Should().NotBe(0); + document1.Id.Should().NotBe(document2.Id); + } + + [Test] + public async Task InsertAsyncUpdatesDocumentIdAndRowVersion() + { + // ChaosSqlCommand is set to retry some of the reads which breaks row versioning code because INSERTS/UPDATES, + // even executed via SqlReader, must not be retired. + NoMonkeyBusiness(); + + var document1 = new DocumentWithIdentityId {Name = "Name"}; + + document1.Id.Should().Be(0); + + await RunInTransactionAsync(async transaction => await transaction.InsertAsync(document1)); + + document1.Id.Should().NotBe(0); + } + + + [Test] + public async Task InsertAsyncUpdatesDocumentId() + { + // ChaosSqlCommand is set to retry some of the reads which breaks row versioning code because INSERTS/UPDATES, + // even executed via SqlReader, must not be retired. + NoMonkeyBusiness(); + + var document1 = new DocumentWithIdentityIdAndRowVersion {Name = "Name"}; + + document1.Id.Should().Be(0); + + await RunInTransactionAsync(async transaction => await transaction.InsertAsync(document1)); + + document1.Id.Should().NotBe(0); + + var document2 = RunInTransaction(transaction => transaction.Load(document1.Id)); + + document2.RowVersion.Should().Equal(document1.RowVersion); + + document2.Name = "Name2"; + RunInTransaction(transaction => transaction.Update(document2)); + + document2.RowVersion.Should().NotEqual(document1.RowVersion); + } + + TResult RunInTransaction(Func func) + { + using var transaction = Store.BeginTransaction(); + var result = func(transaction); + transaction.Commit(); + + return result; + } + + void RunInTransaction(Action action) + { + RunInTransaction(transaction => + { + action(transaction); + return string.Empty; + }); + } + + async Task RunInTransactionAsync(Func> func) + { + using var transaction = Store.BeginTransaction(); + var result = await func(transaction); + await transaction.CommitAsync(); + + return result; + } + + async Task RunInTransactionAsync(Func action) + { + await RunInTransactionAsync(async transaction => + { + await action(transaction); + return true; + }); + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Advanced/MappingFixture.cs b/source/Nevermore.IntegrationTests/Advanced/MappingFixture.cs index e237d13c..42d52237 100644 --- a/source/Nevermore.IntegrationTests/Advanced/MappingFixture.cs +++ b/source/Nevermore.IntegrationTests/Advanced/MappingFixture.cs @@ -19,7 +19,7 @@ class User // Test accessibility public string Prop1 { get; set; } - public string Prop2 { get; } = "Hello"; + public string Prop2 { get; } = "Hello"; public string Prop3 { get; private set; } public string Prop4 { get; protected set; } public string Prop5 { get; private set; } @@ -31,13 +31,13 @@ public void SetOtherProps(string prop3, string prop4, string prop5) Prop5 = prop5; } } - + enum Education { School, HighSchool, College } public override void OneTimeSetUp() { base.OneTimeSetUp(); - + NoMonkeyBusiness(); KeepDataBetweenTests(); @@ -86,7 +86,7 @@ public void ShouldFailIfReadOnlyNotMapped() [Test, Order(2)] public void ShouldWorkIfProp2IsSaveOnly() { - var map = ((IDocumentMap)new UserMap()).Build(); + var map = ((IDocumentMap)new UserMap()).Build(Configuration.PrimaryKeyHandlers); // Pretend the user edited their document map to set it to SaveOnly ((IColumnMappingBuilder) map.Columns.Single(c => c.ColumnName == "Prop2")).SaveOnly(); Configuration.DocumentMaps.Register(map); @@ -105,9 +105,9 @@ public void ShouldInsert() Prop1 = "Prop1", FirstName = "Fred" }; - + user.SetOtherProps("Prop3", "Prop4", "Prop5"); - + transaction.Insert(user); transaction.Update(user); transaction.Commit(); @@ -117,7 +117,7 @@ public void ShouldInsert() public void ShouldLoad() { using var transaction = Store.BeginTransaction(); - + var user = transaction.Load("users-123"); user.Should().NotBeNull(); user.Age.Should().Be(18); @@ -132,7 +132,7 @@ public void ShouldLoad() public void ShouldDelete() { using var transaction = Store.BeginTransaction(); - + transaction.Delete("users-123"); var user = transaction.Load("users-123"); user.Should().BeNull(); diff --git a/source/Nevermore.IntegrationTests/Advanced/NoJsonFixture.cs b/source/Nevermore.IntegrationTests/Advanced/NoJsonFixture.cs index 414ff3f7..e601666e 100644 --- a/source/Nevermore.IntegrationTests/Advanced/NoJsonFixture.cs +++ b/source/Nevermore.IntegrationTests/Advanced/NoJsonFixture.cs @@ -1,7 +1,4 @@ -using System.Collections.Generic; -using System.Data; using FluentAssertions; -using Microsoft.Data.SqlClient.Server; using Nevermore.IntegrationTests.SetUp; using Nevermore.Mapping; using NUnit.Framework; @@ -14,9 +11,9 @@ public override void OneTimeSetUp() { base.OneTimeSetUp(); NoMonkeyBusiness(); - + ExecuteSql("create table TestSchema.Car([Id] nvarchar(50), [Name] nvarchar(100))"); - + Store.Configuration.DocumentMaps.Register(new CarMap()); } @@ -25,23 +22,23 @@ class Car public string Id { get; set; } public string Name { get; set; } } - - class CarMap : DocumentMap + + class CarMap : DocumentMap { public CarMap() { Column(m => m.Name); - + JsonStorageFormat = JsonStorageFormat.NoJson; } } - + [Test] public void ShouldMapWithoutJson() { using var transaction = Store.BeginTransaction(); transaction.Insert(new Car { Name = "Volvo" }); - + var car = transaction.Load("Cars-1"); car.Should().NotBeNull(); car.Name.Should().Be("Volvo"); @@ -54,9 +51,9 @@ public void ShouldMapWithoutJson() car.Name.Should().Be("Bertie"); transaction.Query().Count().Should().Be(1); - - transaction.Delete(car); - + + transaction.Delete(car); + transaction.Query().Count().Should().Be(0); car = transaction.Load("Cars-1"); diff --git a/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs b/source/Nevermore.IntegrationTests/KeyAllocatorFixture.cs index 034756cf..9a97ecec 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,21 +90,21 @@ public void ShouldAllocateInParallel() var sequence = random.Next(3); if (sequence == 0) { - var id = transaction.AllocateId(typeof (Customer)); - projectIds.Add(id); + var id = transaction.AllocateId(); + customerIds.Add(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(); // Abandoned Ids are not returned to the pool - projectIds.Add(id); + customerIds.Add(id); transaction.Dispose(); } else if (sequence == 2) { - var id = transaction.AllocateId(typeof(Order)); + var id = transaction.AllocateId(); deploymentIds.Add(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/CustomIdType.cs b/source/Nevermore.IntegrationTests/Model/CustomIdType.cs new file mode 100644 index 00000000..f3abd024 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/CustomIdType.cs @@ -0,0 +1,54 @@ +#nullable enable +using System; +using System.Globalization; +using System.Reflection; + +namespace Nevermore.IntegrationTests.Model +{ + public class CustomIdType + { + internal CustomIdType(T value) + { + Value = value; + } + + public T Value { get; } + + public override string? ToString() + { + return Value?.ToString(); + } + + 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 !(Value is null) && Value.Equals(((CustomIdType) obj).Value); + } + + public override int GetHashCode() + { + return (Value != null ? Value.GetHashCode() : 0); + } + + public static CustomIdType? Create(Type customType, T value) + { + const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + var instance = Activator.CreateInstance(customType, bindingFlags, null, new object[] { value! }, CultureInfo.CurrentCulture); + return instance as CustomIdType; + } + + public static TCustomIdType? Create(T value) where TCustomIdType : CustomIdType + { + return (TCustomIdType?) Create(typeof(TCustomIdType), value); + } + } + + public class StringCustomIdType : CustomIdType + { + internal StringCustomIdType(string value) : base(value) + { + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/CustomPrefixIdKeyHandler.cs b/source/Nevermore.IntegrationTests/Model/CustomPrefixIdKeyHandler.cs new file mode 100644 index 00000000..7a8f80f0 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/CustomPrefixIdKeyHandler.cs @@ -0,0 +1,11 @@ +namespace Nevermore.IntegrationTests.Model +{ + class CustomPrefixIdKeyHandler : StringCustomIdTypeIdKeyHandler + { + public const string CustomPrefix = "CustomPrefix"; + + public CustomPrefixIdKeyHandler():base(CustomPrefix) + { + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/Customer.cs b/source/Nevermore.IntegrationTests/Model/Customer.cs index b77d27cc..8a682af7 100644 --- a/source/Nevermore.IntegrationTests/Model/Customer.cs +++ b/source/Nevermore.IntegrationTests/Model/Customer.cs @@ -9,7 +9,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 +18,21 @@ public Customer() public string ApiKey { get; set; } public string[] Passphrases { get; set; } } + + public class CustomerId : StringCustomIdType + { + internal CustomerId(string value) : base(value) + { + } + } + +#nullable enable + + 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/DocumentWithCustomPrefix.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefix.cs new file mode 100644 index 00000000..888f4c44 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefix.cs @@ -0,0 +1,15 @@ +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithCustomPrefix + { + public CustomPrefixId Id { get; set; } + public string Name { get; set; } + } + + public class CustomPrefixId : StringCustomIdType + { + internal CustomPrefixId(string value) : base(value) + { + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixAndStringId.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixAndStringId.cs new file mode 100644 index 00000000..6665ce82 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixAndStringId.cs @@ -0,0 +1,8 @@ +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithCustomPrefixAndStringId + { + public string Id { get; set; } + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixAndStringIdMap.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixAndStringIdMap.cs new file mode 100644 index 00000000..fa9c450d --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixAndStringIdMap.cs @@ -0,0 +1,15 @@ +using Nevermore.Mapping; + +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithCustomPrefixAndStringIdMap : DocumentMap + { + public const string CustomPrefix = "CustomPrefix"; + + public DocumentWithCustomPrefixAndStringIdMap() + { + Id().KeyHandler(new StringPrimaryKeyHandler(CustomPrefix)); + Column(m => m.Name); + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixMap.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixMap.cs new file mode 100644 index 00000000..f294808b --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithCustomPrefixMap.cs @@ -0,0 +1,13 @@ +using Nevermore.Mapping; + +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithCustomPrefixMap : DocumentMap + { + public DocumentWithCustomPrefixMap() + { + Id(); + Column(m => m.Name); + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityId.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityId.cs new file mode 100644 index 00000000..bb973586 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityId.cs @@ -0,0 +1,8 @@ +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithIdentityId + { + public int Id { get; set; } + public string Name { get; set; } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdAndRowVersion.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdAndRowVersion.cs new file mode 100644 index 00000000..4578c751 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdAndRowVersion.cs @@ -0,0 +1,9 @@ +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithIdentityIdAndRowVersion + { + public int Id { get; set; } + public string Name { get; set; } + public byte[] RowVersion { get; set; } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdAndRowVersionMap.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdAndRowVersionMap.cs new file mode 100644 index 00000000..7184aff0 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdAndRowVersionMap.cs @@ -0,0 +1,14 @@ +using Nevermore.Mapping; + +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithIdentityIdAndRowVersionMap : DocumentMap + { + public DocumentWithIdentityIdAndRowVersionMap() + { + Id(t => t.Id).Identity(); + Column(t => t.Name); + RowVersion(m => m.RowVersion); + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdMap.cs b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdMap.cs new file mode 100644 index 00000000..7b00ece3 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/DocumentWithIdentityIdMap.cs @@ -0,0 +1,13 @@ +using Nevermore.Mapping; + +namespace Nevermore.IntegrationTests.Model +{ + public class DocumentWithIdentityIdMap : DocumentMap + { + public DocumentWithIdentityIdMap() + { + Id(t => t.Id).Identity(); + Column(t => t.Name); + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/StringCustomIdTypeHandler.cs b/source/Nevermore.IntegrationTests/Model/StringCustomIdTypeHandler.cs new file mode 100644 index 00000000..0dbfb33a --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/StringCustomIdTypeHandler.cs @@ -0,0 +1,37 @@ +#nullable enable +using System; +using System.Data; +using System.Data.Common; +using Nevermore.Advanced.TypeHandlers; + +namespace Nevermore.IntegrationTests.Model +{ + class StringCustomIdTypeHandler : ITypeHandler where T : CustomIdType + { + public bool CanConvert(Type objectType) + { + return objectType == typeof(T); + } + + public object? ReadDatabase(DbDataReader reader, int columnIndex) + { + if (reader.IsDBNull(columnIndex)) return null; + var value = reader.GetString(columnIndex); + if (string.IsNullOrWhiteSpace(value)) return null; + return CustomIdType.Create(value); + } + + public void WriteDatabase(DbParameter parameter, object value) + { + parameter.DbType = DbType.String; + if (value is T wrapped) + { + parameter.Value = wrapped.Value; + } + else + { + parameter.Value = null; + } + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/Model/StringCustomIdTypeIdKeyHandler.cs b/source/Nevermore.IntegrationTests/Model/StringCustomIdTypeIdKeyHandler.cs new file mode 100644 index 00000000..4542fac9 --- /dev/null +++ b/source/Nevermore.IntegrationTests/Model/StringCustomIdTypeIdKeyHandler.cs @@ -0,0 +1,37 @@ +#nullable enable +using System; +using System.Data; +using Microsoft.Data.SqlClient.Server; +using Nevermore.Mapping; + +namespace Nevermore.IntegrationTests.Model +{ + class StringCustomIdTypeIdKeyHandler : IPrimaryKeyHandler + where T : StringCustomIdType + { + readonly string? customPrefix; + + public StringCustomIdTypeIdKeyHandler(string? customPrefix = null) + { + this.customPrefix = customPrefix; + } + + public Type Type => typeof(T); + + public SqlMetaData GetSqlMetaData(string name) + => new SqlMetaData(name, SqlDbType.NVarChar, 300); + + public object? ConvertToPrimitiveValue(object? id) + { + if (!(id is StringCustomIdType stringCustomType)) + throw new ArgumentException($"Expected the id to be a {typeof(T).Name}"); + return stringCustomType.Value; + } + + public object GetNextKey(IKeyAllocator keyAllocator, string tableName) + { + var key = keyAllocator.NextId(tableName); + return CustomIdType.Create($"{customPrefix ?? tableName}s-{key}")!; + } + } +} \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/RelatedDocumentTableFixture.cs b/source/Nevermore.IntegrationTests/RelatedDocumentTableFixture.cs index 0a17b53a..c34b1ca0 100644 --- a/source/Nevermore.IntegrationTests/RelatedDocumentTableFixture.cs +++ b/source/Nevermore.IntegrationTests/RelatedDocumentTableFixture.cs @@ -108,7 +108,7 @@ public void WhenTheOrderIsDeleted() var order = new Order() {Id = orderId}; using (var trn = Store.BeginTransaction()) { - trn.Delete(order); + trn.Delete(order); trn.Commit(); } } @@ -245,7 +245,7 @@ public void DeleteWithQueryBuilder() RelatedDocumentRow[] GetReferencesFromDb() { - var map = ((IDocumentMap)new OrderMap()).Build().RelatedDocumentsMappings.First(); + var map = ((IDocumentMap)new OrderMap()).Build(Configuration.PrimaryKeyHandlers).RelatedDocumentsMappings.First(); Func Callback() => reader @@ -302,7 +302,7 @@ public override int GetHashCode() return hashCode; } } - + public static bool operator ==(RelatedDocumentRow left, RelatedDocumentRow right) => Equals(left, right); public static bool operator !=(RelatedDocumentRow left, RelatedDocumentRow right) => !Equals(left, right); } diff --git a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs index 121ca03c..d388024b 100644 --- a/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs +++ b/source/Nevermore.IntegrationTests/RelationalStoreFixture.cs @@ -1,4 +1,6 @@ -using System; +#nullable enable +using System; +using System.Collections.Generic; using System.Linq; using FluentAssertions; using Nevermore.IntegrationTests.Model; @@ -15,16 +17,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"}); + transaction.Insert(customer3, new InsertOptions { CustomAssignedId = "Customers-Chazza".ToCustomerId() }); - 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 +84,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,13 +96,41 @@ 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"); } } + [Test] + public void ShouldHandleLoadManyWithCustomKeyType() + { + CustomerId customerId; + var ids = new List(); + using (var transaction = Store.BeginTransaction()) + { + var customer1 = new Customer {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 = {"db-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); + transaction.Commit(); + ids.Add(customer1.Id); + ids.Add(customer3.Id); + ids.Add(customer2.Id); + } + + using (var transaction = Store.BeginTransaction()) + { + var customers = transaction.LoadMany(ids); + customers.SingleOrDefault(c => c.FirstName == "Bob").Should().NotBeNull("Bob's entry should be returned"); + customers.SingleOrDefault(c => c.FirstName == "Charlie").Should().NotBeNull("Charlie's entry should be returned"); + customers.SingleOrDefault(c => c.FirstName == "Alice").Should().NotBeNull("Alice's entry should be returned"); + } + } + [Test] public void ShouldMultiSelect() { @@ -233,7 +263,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,8 +273,8 @@ public void ShouldPersistAndLoadReferenceCollectionsOnSingleDocuments() } using (var transaction = Store.BeginTransaction()) { - var loadedCustomer = transaction.Load(customerId); - loadedCustomer.Roles.Count.Should().Be(2); + var loadedCustomer = transaction.Load(customerId); + loadedCustomer!.Roles.Count.Should().Be(2); } } @@ -255,8 +285,8 @@ 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" }); - Assert.That(customer.Id, Is.EqualTo("12345"), "Id passed in should be used"); + transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345".ToCustomerId() }); + Assert.That(customer.Id?.Value, Is.EqualTo("12345"), "Id passed in should be used"); } } @@ -265,9 +295,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"}}; - transaction.Insert(customer, new InsertOptions { CustomAssignedId = "12345" }); - Assert.That(customer.Id, Is.EqualTo("12345"), "Id passed in should be used if same"); + 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".ToCustomerId() }); + Assert.That(customer.Id?.Value, Is.EqualTo("12345"), "Id passed in should be used if same"); } } @@ -278,7 +308,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/DeleteFixture.cs b/source/Nevermore.IntegrationTests/RelationalTransaction/DeleteFixture.cs index fe77c34f..bc93edc9 100644 --- a/source/Nevermore.IntegrationTests/RelationalTransaction/DeleteFixture.cs +++ b/source/Nevermore.IntegrationTests/RelationalTransaction/DeleteFixture.cs @@ -16,7 +16,7 @@ public void DeleteByEntity() using (var trn = Store.BeginTransaction()) { var product = trn.Load(id); - trn.Delete(product); + trn.Delete(product); trn.Commit(); } diff --git a/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs b/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs index eea2d561..c5af8e3e 100644 --- a/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs +++ b/source/Nevermore.IntegrationTests/RelationalTransaction/LoadFixture.cs @@ -39,7 +39,7 @@ public void LoadWithMultipleIdsWithDifferentLength() Type = ProductType.Normal }; trn.Insert(product1); - + var product2 = new Product { Id = "Products-133", @@ -49,9 +49,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); @@ -75,13 +75,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); @@ -202,7 +202,7 @@ public void StoreStringInheritedTypesSerializeCorrectly() var brandToTestSerialization = allBrands.Single(x => x.Name == "Brand A"); brandToTestSerialization.JSON.Should().Be("{\"Description\":\"Details for Brand A.\"}"); } - + [Test] public void StoreAndLoadStringInheritedTypes() { @@ -422,5 +422,41 @@ public void LoadManyByWrongIdType_ShouldThrowArgumentException() target.ShouldThrow().Which.Message.Should().Be("Provided Id of type 'System.String' does not match configured type of 'System.Guid'."); } + + [Test] + public void StoreAndLoadWithCustomIdAndCustomPrefix() + { + using (var trn = Store.BeginTransaction()) + { + var document = new DocumentWithCustomPrefix() + { + Name = "test" + }; + + trn.Insert(document); + + document.Id.Value.Should().StartWith(CustomPrefixIdKeyHandler.CustomPrefix); + + trn.LoadRequired(document.Id); + } + } + + [Test] + public void StoreAndLoadWithCustomPrefix() + { + using (var trn = Store.BeginTransaction()) + { + var document = new DocumentWithCustomPrefixAndStringId() + { + Name = "test" + }; + + trn.Insert(document); + + document.Id.Should().StartWith(DocumentWithCustomPrefixAndStringIdMap.CustomPrefix); + + trn.LoadRequired(document.Id); + } + } } } \ No newline at end of file diff --git a/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs b/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs index d73bcba3..e438b965 100644 --- a/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs +++ b/source/Nevermore.IntegrationTests/SetUp/FixtureWithRelationalStore.cs @@ -15,11 +15,8 @@ public abstract class FixtureWithRelationalStore : FixtureWithDatabase protected FixtureWithRelationalStore() { - var config = new RelationalStoreConfiguration(ConnectionString); - config.CommandFactory = new ChaosSqlCommandFactory(new SqlCommandFactory()); - config.ApplicationName = "Nevermore-IntegrationTests"; - config.DefaultSchema = "TestSchema"; - config.DocumentMaps.Register( + var documentMaps = new IDocumentMap[] + { new CustomerMap(), new BrandMap(), new ProductMap(), @@ -30,29 +27,38 @@ protected FixtureWithRelationalStore() new MessageWithIntIdMap(), new MessageWithLongIdMap(), new MessageWithGuidIdMap(), - new DocumentWithRowVersionMap()); + new DocumentWithRowVersionMap(), + new DocumentWithIdentityIdMap(), + new DocumentWithIdentityIdAndRowVersionMap(), + new DocumentWithCustomPrefixMap(), + new DocumentWithCustomPrefixAndStringIdMap() + }; + + var config = new RelationalStoreConfiguration(ConnectionString) + { + CommandFactory = new ChaosSqlCommandFactory(new SqlCommandFactory()), + ApplicationName = "Nevermore-IntegrationTests", + DefaultSchema = "TestSchema" + }; config.TypeHandlers.Register(new ReferenceCollectionTypeHandler()); + config.TypeHandlers.Register(new StringCustomIdTypeHandler()); + config.TypeHandlers.Register(new StringCustomIdTypeHandler()); + + config.PrimaryKeyHandlers.Register(new StringCustomIdTypeIdKeyHandler()); + config.PrimaryKeyHandlers.Register(new CustomPrefixIdKeyHandler()); + config.InstanceTypeResolvers.Register(new ProductTypeResolver()); config.InstanceTypeResolvers.Register(new BrandTypeResolver()); + config.DocumentMaps.Register(documentMaps); + config.UseJsonNetSerialization(settings => { settings.ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor; }); - GenerateSchemaAutomatically( - new OrderMap(), - new ProductMap(), - new CustomerMap(), - new LineItemMap(), - new BrandMap(), - new MachineMap(), - new MessageWithStringIdMap(), - new MessageWithIntIdMap(), - new MessageWithLongIdMap(), - new MessageWithGuidIdMap(), - new DocumentWithRowVersionMap()); + GenerateSchemaAutomatically(config, documentMaps); Store = new RelationalStore(config); } @@ -86,16 +92,15 @@ protected void KeepDataBetweenTests() resetBetweenTests = false; } - void GenerateSchemaAutomatically(params IDocumentMap[] mappings) + void GenerateSchemaAutomatically(RelationalStoreConfiguration configuration, params IDocumentMap[] mappings) { try { var schema = new StringBuilder(); foreach (var map in mappings) { - SchemaGenerator.WriteTableSchema(map.Build(), null, schema); + SchemaGenerator.WriteTableSchema(map.Build(configuration.PrimaryKeyHandlers), null, schema); } - schema.AppendLine($"ALTER TABLE [TestSchema].[{nameof(DocumentWithRowVersion)}] ADD [RowVersion] rowversion"); integrationTestDatabase.ExecuteScript(schema.ToString()); } catch (Exception ex) diff --git a/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs b/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs index 166a0b76..d241bd81 100644 --- a/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs +++ b/source/Nevermore.IntegrationTests/SetUp/SchemaGenerator.cs @@ -1,6 +1,7 @@ using System.Data; using System.Linq; using System.Text; +using Nevermore.IntegrationTests.Model; using Nevermore.Mapping; using Nevermore.Util; @@ -12,7 +13,9 @@ public static void WriteTableSchema(DocumentMap mapping, string tableNameOverrid { var tableName = tableNameOverride ?? mapping.TableName; result.AppendLine("CREATE TABLE [TestSchema].[" + tableName + "] ("); - result.Append($" [Id] {GetDatabaseType(mapping.IdColumn)} NOT NULL CONSTRAINT [PK_{tableName}_Id] PRIMARY KEY CLUSTERED, ").AppendLine(); + + var identity = mapping.IsIdentityId ? " IDENTITY(1,1)" : null; + result.Append($" [Id] {GetDatabaseType(mapping.IdColumn)}{identity} NOT NULL CONSTRAINT [PK_{tableName}_Id] PRIMARY KEY CLUSTERED, ").AppendLine(); foreach (var column in mapping.WritableIndexedColumns()) { @@ -20,6 +23,10 @@ public static void WriteTableSchema(DocumentMap mapping, string tableNameOverrid } result.AppendFormat(" [JSON] NVARCHAR(MAX) NOT NULL").AppendLine(); + + if (mapping.IsRowVersioningEnabled) + result.Append(" ,[RowVersion] TIMESTAMP").AppendLine(); + result.AppendLine(")"); foreach (var unique in mapping.UniqueConstraints) @@ -52,7 +59,7 @@ static bool IsNullable(ColumnMapping column) static string GetDatabaseType(ColumnMapping column) { - var dbType = DatabaseTypeConverter.AsDbType(column.Type); + var dbType = typeof(StringCustomIdType).IsAssignableFrom(column.Type) ? DbType.String : DatabaseTypeConverter.AsDbType(column.Type); switch (dbType) { diff --git a/source/Nevermore.Tests/CommandParameterValuesFixture.cs b/source/Nevermore.Tests/CommandParameterValuesFixture.cs index 9342b245..e096971b 100644 --- a/source/Nevermore.Tests/CommandParameterValuesFixture.cs +++ b/source/Nevermore.Tests/CommandParameterValuesFixture.cs @@ -25,7 +25,7 @@ public void ShouldReplaceParametersWithExactMatch() command.Parameters["someId_2"].Value.Should().Be("id2"); command.Parameters["someIdentifier"].Value.Should().Be("value"); } - + [Test] public void ShouldReplaceParametersWithExactMatchEndOfQuery() { @@ -91,19 +91,19 @@ public Map() Column(c => c.LastName).MaxLength(256); } } - + [Test] public void ShouldUseMaxLengthsWhenAvailable() { var mapping = new Map(); - + var parameters = new CommandParameterValues(); parameters["FirstName"] = "Fred"; parameters["LastName"] = "Fred"; - + var command = new SqlCommand($"SELECT BLAH BLAH"); - - parameters.ContributeTo(command, new TypeHandlerRegistry(), ((IDocumentMap)mapping).Build()); + + parameters.ContributeTo(command, new TypeHandlerRegistry(), ((IDocumentMap)mapping).Build(new PrimaryKeyHandlerRegistry())); command.Parameters["FirstName"].Size.Should().Be(128); command.Parameters["LastName"].Size.Should().Be(256); diff --git a/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs b/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs index 18906628..615704f4 100644 --- a/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs +++ b/source/Nevermore.Tests/Delete/DeleteQueryBuilderFixture.cs @@ -6,7 +6,6 @@ using Nevermore.Mapping; using Nevermore.Querying; using Nevermore.Util; -using Newtonsoft.Json; using NSubstitute; using NUnit.Framework; @@ -27,7 +26,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 => { @@ -164,7 +163,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); @@ -227,7 +226,13 @@ public void VariablesCasingIsNormalisedForWhereIn() } } + internal class Empty + {} + internal class EmptyMap : DocumentMap { + public EmptyMap() : base(typeof(Empty), "Empty") + { + } } } \ No newline at end of file diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithIdentityId.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithIdentityId.approved.txt new file mode 100644 index 00000000..eed05c95 --- /dev/null +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithIdentityId.approved.txt @@ -0,0 +1,8 @@ +DECLARE @InsertedRows TABLE ([Id] int) +INSERT INTO [dbo].[TestDocumentTbl] ([AColumn], [JSON]) OUTPUT inserted.[Id] INTO @InsertedRows VALUES +(@AColumn, @JSON) +SELECT [Id] FROM @InsertedRows + +@Id=0 +@JSON={} +@AColumn=AValue \ No newline at end of file diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithIdentityIdAndRowVersion.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithIdentityIdAndRowVersion.approved.txt new file mode 100644 index 00000000..3b87e0e0 --- /dev/null +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithIdentityIdAndRowVersion.approved.txt @@ -0,0 +1,8 @@ +DECLARE @InsertedRows TABLE ([RowVersion] binary(8), [Id] int) +INSERT INTO [dbo].[TestDocumentTbl] ([AColumn], [JSON]) OUTPUT inserted.[RowVersion],inserted.[Id] INTO @InsertedRows VALUES +(@AColumn, @JSON) +SELECT [RowVersion],[Id] FROM @InsertedRows + +@Id=0 +@JSON={} +@AColumn=AValue \ No newline at end of file diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithReadOnlyColumn.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithReadOnlyColumn.approved.txt index 6e2d858e..50cf6c9f 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithReadOnlyColumn.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertDocumentWithReadOnlyColumn.approved.txt @@ -1,5 +1,7 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.RowVersion VALUES +DECLARE @InsertedRows TABLE ([RowVersion] binary(8)) +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.[RowVersion] INTO @InsertedRows VALUES (@Id, @AColumn, @JSON) +SELECT [RowVersion] FROM @InsertedRows @Id=Doc-1 @JSON={"NotMapped":"NonMappedValue"} diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithManyRelatedDocuments.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithManyRelatedDocuments.approved.txt index 790fc30e..5ff5a30c 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithManyRelatedDocuments.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithManyRelatedDocuments.approved.txt @@ -1,4 +1,4 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES (@0__Id, @0__AColumn, @0__JSON) ,(@1__Id, @1__AColumn, @1__JSON) ,(@2__Id, @2__AColumn, @2__JSON) diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithMultipleRelatedDocumentsMaps.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithMultipleRelatedDocumentsMaps.approved.txt index a2b04e49..00f455da 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithMultipleRelatedDocumentsMaps.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocumentWithMultipleRelatedDocumentsMaps.approved.txt @@ -1,4 +1,4 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES (@Id, @AColumn, @JSON) INSERT INTO [dbo].[RelatedDocument] ([Id], [Table], [RelatedDocumentId], [RelatedDocumentTable]) VALUES (@Id, 'TestDocumentTbl', @relateddocument_0, 'OtherTbl') diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocuments.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocuments.approved.txt index e2a908ba..cecadfc0 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocuments.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertMultipleDocuments.approved.txt @@ -1,6 +1,8 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.RowVersion VALUES +DECLARE @InsertedRows TABLE ([RowVersion] binary(8)) +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.[RowVersion] INTO @InsertedRows VALUES (@0__Id, @0__AColumn, @0__JSON) ,(@1__Id, @1__AColumn, @1__JSON) +SELECT [RowVersion] FROM @InsertedRows @0__Id=New-Id-1 @0__JSON={"NotMapped":"NonMappedValue"} diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocument.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocument.approved.txt index 8753c3f0..c28ffe19 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocument.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocument.approved.txt @@ -1,5 +1,7 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.RowVersion VALUES +DECLARE @InsertedRows TABLE ([RowVersion] binary(8)) +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.[RowVersion] INTO @InsertedRows VALUES (@Id, @AColumn, @JSON) +SELECT [RowVersion] FROM @InsertedRows @Id=New-Id @JSON={"NotMapped":"NonMappedValue"} diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithDocumentIdAlreadySet.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithDocumentIdAlreadySet.approved.txt index fa893fed..37e0e614 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithDocumentIdAlreadySet.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithDocumentIdAlreadySet.approved.txt @@ -1,5 +1,7 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.RowVersion VALUES +DECLARE @InsertedRows TABLE ([RowVersion] binary(8)) +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) OUTPUT inserted.[RowVersion] INTO @InsertedRows VALUES (@Id, @AColumn, @JSON) +SELECT [RowVersion] FROM @InsertedRows @Id=SuppliedId @JSON={"NotMapped":"NonMappedValue"} diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithManyRelatedDocuments.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithManyRelatedDocuments.approved.txt index 45842fbf..9bb3d7a5 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithManyRelatedDocuments.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithManyRelatedDocuments.approved.txt @@ -1,4 +1,4 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES (@Id, @AColumn, @JSON) INSERT INTO [dbo].[RelatedDocument] ([Id], [Table], [RelatedDocumentId], [RelatedDocumentTable]) VALUES (@Id, 'TestDocumentTbl', @relateddocument_0, 'OtherTbl') diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithNoRelatedDocuments.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithNoRelatedDocuments.approved.txt index 3e8f05e8..946232ad 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithNoRelatedDocuments.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithNoRelatedDocuments.approved.txt @@ -1,4 +1,4 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES (@Id, @AColumn, @JSON) @Id=New-Id diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithOneRelatedDocument.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithOneRelatedDocument.approved.txt index ab6f27ad..d8865e00 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithOneRelatedDocument.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithOneRelatedDocument.approved.txt @@ -1,4 +1,4 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES +INSERT INTO [dbo].[TestDocumentTbl] ([Id], [AColumn], [JSON]) VALUES (@Id, @AColumn, @JSON) INSERT INTO [dbo].[RelatedDocument] ([Id], [Table], [RelatedDocumentId], [RelatedDocumentTable]) VALUES (@Id, 'TestDocumentTbl', @relateddocument_0, 'OtherTbl') diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithTableNameAndHints.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithTableNameAndHints.approved.txt index 118e8424..4f0daef4 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithTableNameAndHints.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertSingleDocumentWithTableNameAndHints.approved.txt @@ -1,5 +1,7 @@ -INSERT INTO [dbo].[AltTableName] WITH (NOLOCK) ([Id], [AColumn], [JSON]) OUTPUT inserted.RowVersion VALUES +DECLARE @InsertedRows TABLE ([RowVersion] binary(8)) +INSERT INTO [dbo].[AltTableName] WITH (NOLOCK) ([Id], [AColumn], [JSON]) OUTPUT inserted.[RowVersion] INTO @InsertedRows VALUES (@Id, @AColumn, @JSON) +SELECT [RowVersion] FROM @InsertedRows @Id=New-Id @JSON={"NotMapped":"NonMappedValue"} diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertWithoutDefaultColumns.approved.txt b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertWithoutDefaultColumns.approved.txt index 40ad11d7..cc51e7a9 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertWithoutDefaultColumns.approved.txt +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.InsertWithoutDefaultColumns.approved.txt @@ -1,5 +1,7 @@ -INSERT INTO [dbo].[TestDocumentTbl] ([AColumn]) OUTPUT inserted.RowVersion VALUES +DECLARE @InsertedRows TABLE ([RowVersion] binary(8)) +INSERT INTO [dbo].[TestDocumentTbl] ([AColumn]) OUTPUT inserted.[RowVersion] INTO @InsertedRows VALUES (@AColumn) +SELECT [RowVersion] FROM @InsertedRows @Id=New-Id @JSON={"NotMapped":"NonMappedValue"} diff --git a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs index 694f33bb..321d453c 100644 --- a/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs +++ b/source/Nevermore.Tests/Util/DataModificationQueryBuilderFixture.cs @@ -27,7 +27,9 @@ public DataModificationQueryBuilderFixture() new TestDocumentMap(), new TestDocumentWithRelatedDocumentsMap(), new TestDocumentWithMultipleRelatedDocumentsMap(), - new OtherMap()); + new OtherMap(), + new TestDocumentWithIdentityIdMap(), + new TestDocumentWithIdentityIdAndRowVersionMap()); builder = new DataModificationQueryBuilder( configuration, m => idAllocator() @@ -71,8 +73,8 @@ public void InsertSingleDocumentWithTableNameAndHints() new[] {document}, new InsertOptions { - TableName ="AltTableName", - Hint ="WITH (NOLOCK)" + TableName = "AltTableName", + Hint = "WITH (NOLOCK)" } ); @@ -86,7 +88,7 @@ public void InsertWithoutDefaultColumns() var result = builder.PrepareInsert( new[] {document}, - new InsertOptions { IncludeDefaultModelColumns = false} + new InsertOptions {IncludeDefaultModelColumns = false} ); this.Assent(Format(result)); @@ -195,11 +197,27 @@ public void InsertDocumentWithReadOnlyColumn() var document = new TestDocument {AColumn = "AValue", NotMapped = "NonMappedValue", Id = "Doc-1", ReadOnly = "Value"}; idAllocator = () => "New-Id-" + (++n); - var result = builder.PrepareInsert(new [] { document }); + var result = builder.PrepareInsert(new[] {document}); this.Assent(Format(result)); } + [Test] + public void InsertDocumentWithIdentityId() + { + var document = new TestDocumentWithIdentityId {AColumn = "AValue"}; + var result = builder.PrepareInsert(new[] {document}); + this.Assent(Format(result)); + } + + [Test] + public void InsertDocumentWithIdentityIdAndRowVersion() + { + var document = new TestDocumentWithIdentityIdAndRowVersion {AColumn = "AValue"}; + var result = builder.PrepareInsert(new[] {document}); + this.Assent(Format(result)); + } + [Test] public void Update() { @@ -219,7 +237,7 @@ public void UpdateWithHint() var result = builder.PrepareUpdate( document, - new UpdateOptions { Hint = "WITH (NO LOCK)"} + new UpdateOptions {Hint = "WITH (NO LOCK)"} ); this.Assent(Format(result)); @@ -384,6 +402,19 @@ class Other public string Id { get; set; } } + class TestDocumentWithIdentityId + { + public int Id { get; set; } + public string AColumn { get; set; } + } + + class TestDocumentWithIdentityIdAndRowVersion + { + public int Id { get; set; } + public string AColumn { get; set; } + public int RowVersion { get; set; } + } + class TestDocumentMap : DocumentMap { public TestDocumentMap() @@ -424,5 +455,26 @@ public OtherMap() TableName = "OtherTbl"; } } + + class TestDocumentWithIdentityIdMap : DocumentMap + { + public TestDocumentWithIdentityIdMap() + { + TableName = "TestDocumentTbl"; + Id(t => t.Id).Identity(); + Column(t => t.AColumn); + } + } + + class TestDocumentWithIdentityIdAndRowVersionMap : DocumentMap + { + public TestDocumentWithIdentityIdAndRowVersionMap() + { + TableName = "TestDocumentTbl"; + Id(t => t.Id).Identity(); + Column(t => t.AColumn); + RowVersion(t => t.RowVersion); + } + } } } \ No newline at end of file diff --git a/source/Nevermore.sln.DotSettings b/source/Nevermore.sln.DotSettings index 1b96c1b6..dee3ac84 100644 --- a/source/Nevermore.sln.DotSettings +++ b/source/Nevermore.sln.DotSettings @@ -1,8 +1,13 @@  Implicit + 400 <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True + True + True + True True True True \ No newline at end of file diff --git a/source/Nevermore/Advanced/QueryBuilder.cs b/source/Nevermore/Advanced/QueryBuilder.cs index 2e53a68a..1128baa3 100644 --- a/source/Nevermore/Advanced/QueryBuilder.cs +++ b/source/Nevermore/Advanced/QueryBuilder.cs @@ -90,7 +90,7 @@ public IArrayParametersQueryBuilder WhereParameterized(string fieldName { return new ArrayParametersQueryBuilder(AddAlwaysFalseWhere(), parameterNamesList); } - + selectBuilder.AddWhere(new ArrayWhereParameter(fieldName, operand, parameterNamesList)); IQueryBuilder builder = this; return new ArrayParametersQueryBuilder(parameterNamesList.Aggregate(builder, (b, p) => b.Parameter(p)), parameterNamesList); @@ -398,10 +398,10 @@ public async Task> ToListAsync(int skip, int take, CancellationTok { results.Add(item); } - + return results; } - + SubquerySelectBuilder BuildToList(int skip, int take, out CommandParameterValues parmeterValues) { const string rowNumberColumnName = "RowNum"; @@ -454,7 +454,7 @@ public async Task> ToListAsync(CancellationToken cancellationToken { var results = new List(); - await foreach (var item in StreamAsync(cancellationToken)) + await foreach (var item in StreamAsync(cancellationToken)) results.Add(item); return results; diff --git a/source/Nevermore/Advanced/ReadTransaction.cs b/source/Nevermore/Advanced/ReadTransaction.cs index 6e2c6f88..6cbee6ce 100644 --- a/source/Nevermore/Advanced/ReadTransaction.cs +++ b/source/Nevermore/Advanced/ReadTransaction.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Data; @@ -26,9 +27,9 @@ public class ReadTransaction : IReadTransaction, ITransactionDiagnostic readonly RetriableOperation operationsToRetry; readonly IRelationalStoreConfiguration configuration; readonly ITableAliasGenerator tableAliasGenerator = new TableAliasGenerator(); - readonly string name; + readonly string? name; - DbConnection connection; + DbConnection? connection; protected IUniqueParameterNameGenerator ParameterNameGenerator { get; } = new UniqueParameterNameGenerator(); @@ -39,7 +40,7 @@ public class ReadTransaction : IReadTransaction, ITransactionDiagnostic public IDictionary State { get; } - public ReadTransaction(RelationalTransactionRegistry registry, RetriableOperation operationsToRetry, IRelationalStoreConfiguration configuration, string name = null) + public ReadTransaction(RelationalTransactionRegistry registry, RetriableOperation operationsToRetry, IRelationalStoreConfiguration configuration, string? name = null) { State = new Dictionary(); this.registry = registry; @@ -51,7 +52,7 @@ public ReadTransaction(RelationalTransactionRegistry registry, RetriableOperatio registry.Add(this); } - protected DbTransaction Transaction { get; private set; } + protected DbTransaction? Transaction { get; private set; } public void Open() { @@ -71,34 +72,35 @@ public async Task OpenAsync() public void Open(IsolationLevel isolationLevel) { Open(); - Transaction = connection.BeginTransaction(isolationLevel); + Transaction = connection!.BeginTransaction(isolationLevel); } public async Task OpenAsync(IsolationLevel isolationLevel) { await OpenAsync(); - Transaction = await connection.BeginTransactionAsync(isolationLevel); + Transaction = await connection!.BeginTransactionAsync(isolationLevel); } [Pure] - private TDocument Load(TKey id) where TDocument : class + public TDocument? Load(TKey id) where TDocument : class { return Stream(PrepareLoad(id)).FirstOrDefault(); } [Pure] - public TDocument Load(string id) where TDocument : class => Load(id); + public TDocument? Load(string id) where TDocument : class => Load(id); [Pure] - public TDocument Load(int id) where TDocument : class => Load(id); + public TDocument? Load(int id) where TDocument : class => Load(id); [Pure] - public TDocument Load(long id) where TDocument : class => Load(id); + public TDocument? Load(long id) where TDocument : class => Load(id); [Pure] - public TDocument Load(Guid id) where TDocument : class => Load(id); + public TDocument? Load(Guid id) where TDocument : class => Load(id); - private async Task LoadAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class + [Pure] + public async Task LoadAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class { var results = StreamAsync(PrepareLoad(id), cancellationToken); await foreach (var row in results.WithCancellation(cancellationToken)) @@ -107,50 +109,63 @@ private async Task LoadAsync(TKey id, CancellationTo } [Pure] - public Task LoadAsync(string id, CancellationToken cancellationToken = default) where TDocument : class + public Task LoadAsync(string id, CancellationToken cancellationToken = default) where TDocument : class => LoadAsync(id, cancellationToken); [Pure] - public Task LoadAsync(int id, CancellationToken cancellationToken = default) where TDocument : class + public Task LoadAsync(int id, CancellationToken cancellationToken = default) where TDocument : class => LoadAsync(id, cancellationToken); [Pure] - public Task LoadAsync(long id, CancellationToken cancellationToken = default) where TDocument : class + public Task LoadAsync(long id, CancellationToken cancellationToken = default) where TDocument : class => LoadAsync(id, cancellationToken); [Pure] - public Task LoadAsync(Guid id, CancellationToken cancellationToken = default) where TDocument : class + public Task LoadAsync(Guid id, CancellationToken cancellationToken = default) where TDocument : class => LoadAsync(id, cancellationToken); - private List LoadMany(IEnumerable ids) where TDocument : class + [Pure] + public List LoadMany(params TKey[] ids) where TDocument : class + => LoadStream(ids).ToList(); + + [Pure] + public List LoadMany(IEnumerable ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(params string[] ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(params int[] ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(params long[] ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(params Guid[] ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(IEnumerable ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(IEnumerable ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(IEnumerable ids) where TDocument : class => LoadStream(ids).ToList(); + [Pure] public List LoadMany(IEnumerable ids) where TDocument : class => LoadStream(ids).ToList(); [Pure] - private async Task> LoadManyAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class + public async Task> LoadManyAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class { var results = new List(); await foreach (var item in LoadStreamAsync(ids, cancellationToken)) @@ -178,7 +193,7 @@ public Task> LoadManyAsync(IEnumerable ids, Can => LoadManyAsync(ids, cancellationToken); [Pure] - private TDocument LoadRequired(TKey id) where TDocument : class + public TDocument LoadRequired(TKey id) where TDocument : class { var result = Load(id); if (result == null) @@ -203,7 +218,7 @@ public TDocument LoadRequired(Guid id) where TDocument : class => LoadRequired(id); [Pure] - private async Task LoadRequiredAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class + public async Task LoadRequiredAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class { var result = await LoadAsync(id, cancellationToken); if (result == null) @@ -227,7 +242,12 @@ public Task LoadRequiredAsync(long id, CancellationToken c public Task LoadRequiredAsync(Guid id, CancellationToken cancellationToken = default) where TDocument : class => LoadRequiredAsync(id, cancellationToken); - private List LoadManyRequired(IEnumerable ids) where TDocument : class + [Pure] + public List LoadManyRequired(params TKey[] ids) where TDocument : class + => LoadManyRequired(ids.AsEnumerable()); + + [Pure] + public List LoadManyRequired(IEnumerable ids) where TDocument : class { var idList = ids.Distinct().ToList(); var results = LoadMany(idList); @@ -240,31 +260,40 @@ private List LoadManyRequired(IEnumerable ids) return results; } + [Pure] public List LoadManyRequired(params string[] ids) where TDocument : class => LoadManyRequired(ids); + [Pure] public List LoadManyRequired(params int[] ids) where TDocument : class => LoadManyRequired(ids); + [Pure] public List LoadManyRequired(params long[] ids) where TDocument : class => LoadManyRequired(ids); + [Pure] public List LoadManyRequired(params Guid[] ids) where TDocument : class => LoadManyRequired(ids); + [Pure] public List LoadManyRequired(IEnumerable ids) where TDocument : class => LoadManyRequired(ids); + [Pure] public List LoadManyRequired(IEnumerable ids) where TDocument : class => LoadManyRequired(ids); + [Pure] public List LoadManyRequired(IEnumerable ids) where TDocument : class => LoadManyRequired(ids); + [Pure] public List LoadManyRequired(IEnumerable ids) where TDocument : class => LoadManyRequired(ids); - private async Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class + [Pure] + public async Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class { var idList = ids.Distinct().ToArray(); var results = await LoadManyAsync(idList, cancellationToken); @@ -277,20 +306,28 @@ private async Task> LoadManyRequiredAsync(IEnum return results; } + [Pure] public Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class => LoadManyRequiredAsync(ids, cancellationToken); + [Pure] public Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class => LoadManyRequiredAsync(ids, cancellationToken); + [Pure] public Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class => LoadManyRequiredAsync(ids, cancellationToken); + [Pure] public Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class => LoadManyRequiredAsync(ids, cancellationToken); [Pure] - private IEnumerable LoadStream(IEnumerable ids) where TDocument : class + public IEnumerable LoadStream(params TKey[] ids) where TDocument : class + => LoadStream(ids.AsEnumerable()); + + [Pure] + public IEnumerable LoadStream(IEnumerable ids) where TDocument : class { var idList = ids.Where(id => id != null).Distinct().ToList(); @@ -330,7 +367,7 @@ public IEnumerable LoadStream(params Guid[] ids) where TDo => LoadStream(ids); [Pure] - private async IAsyncEnumerable LoadStreamAsync(IEnumerable ids, [EnumeratorCancellation] CancellationToken cancellationToken = default) where TDocument : class + public async IAsyncEnumerable LoadStreamAsync(IEnumerable ids, [EnumeratorCancellation] CancellationToken cancellationToken = default) where TDocument : class { var idList = ids.Where(id => id != null).Distinct().ToList(); if (idList.Count == 0) @@ -361,7 +398,7 @@ public IAsyncEnumerable LoadStreamAsync(IEnumerable public ITableSourceQueryBuilder Query() where TRecord : class { var map = configuration.DocumentMaps.Resolve(typeof(TRecord)); - return new TableSourceQueryBuilder(map.TableName, configuration.GetSchemaNameOrDefault(map), map.IdColumn.ColumnName, this, tableAliasGenerator, ParameterNameGenerator, new CommandParameterValues(), new Parameters(), new ParameterDefaults()); + return new TableSourceQueryBuilder(map.TableName, configuration.GetSchemaNameOrDefault(map), map.IdColumn?.ColumnName, this, tableAliasGenerator, ParameterNameGenerator, new CommandParameterValues(), new Parameters(), new ParameterDefaults()); } public ISubquerySourceBuilder RawSqlQuery(string query) where TRecord : class @@ -369,12 +406,12 @@ public ISubquerySourceBuilder RawSqlQuery(string query) where return new SubquerySourceBuilder(new RawSql(query), this, tableAliasGenerator, ParameterNameGenerator, new CommandParameterValues(), new Parameters(), new ParameterDefaults()); } - public IEnumerable Stream(string query, CommandParameterValues args, TimeSpan? commandTimeout = null) + public IEnumerable Stream(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null) { return Stream(new PreparedCommand(query, args, RetriableOperation.Select, commandBehavior: CommandBehavior.SequentialAccess | CommandBehavior.SingleResult, commandTimeout: commandTimeout)); } - public IAsyncEnumerable StreamAsync(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) + public IAsyncEnumerable StreamAsync(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) { return StreamAsync(new PreparedCommand(query, args, RetriableOperation.Select, commandBehavior: CommandBehavior.SequentialAccess | CommandBehavior.SingleResult, commandTimeout: commandTimeout), cancellationToken); } @@ -452,12 +489,12 @@ void AddCommandTrace(string commandText) } } - public int ExecuteNonQuery(string query, CommandParameterValues args, TimeSpan? commandTimeout = null) + public int ExecuteNonQuery(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null) { return ExecuteNonQuery(new PreparedCommand(query, args, RetriableOperation.None, commandBehavior: CommandBehavior.Default, commandTimeout: commandTimeout)); } - public Task ExecuteNonQueryAsync(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) + public Task ExecuteNonQueryAsync(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) { return ExecuteNonQueryAsync(new PreparedCommand(query, args, RetriableOperation.None, commandBehavior: CommandBehavior.Default, commandTimeout: commandTimeout), cancellationToken); } @@ -474,12 +511,12 @@ public async Task ExecuteNonQueryAsync(PreparedCommand preparedCommand, Can return await command.ExecuteNonQueryAsync(cancellationToken); } - public TResult ExecuteScalar(string query, CommandParameterValues args, RetriableOperation operation, TimeSpan? commandTimeout = null) + public TResult ExecuteScalar(string query, CommandParameterValues? args = null, RetriableOperation retriableOperation = RetriableOperation.Select, TimeSpan? commandTimeout = null) { - return ExecuteScalar(new PreparedCommand(query, args, operation, commandTimeout: commandTimeout)); + return ExecuteScalar(new PreparedCommand(query, args, retriableOperation, commandTimeout: commandTimeout)); } - public Task ExecuteScalarAsync(string query, CommandParameterValues args = null, RetriableOperation retriableOperation = RetriableOperation.Select, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) + public Task ExecuteScalarAsync(string query, CommandParameterValues? args = null, RetriableOperation retriableOperation = RetriableOperation.Select, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) { return ExecuteScalarAsync(new PreparedCommand(query, args, retriableOperation, null, commandTimeout), cancellationToken); } @@ -489,7 +526,7 @@ public TResult ExecuteScalar(PreparedCommand preparedCommand) using var command = CreateCommand(preparedCommand); var result = command.ExecuteScalar(); if (result == DBNull.Value) - return default; + return default!; return (TResult) result; } @@ -498,16 +535,16 @@ public async Task ExecuteScalarAsync(PreparedCommand preparedC using var command = CreateCommand(preparedCommand); var result = await command.ExecuteScalarAsync(cancellationToken); if (result == DBNull.Value) - return default; + return default!; return (TResult) result; } - public DbDataReader ExecuteReader(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null) + public DbDataReader ExecuteReader(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null) { return ExecuteReader(new PreparedCommand(query, args, RetriableOperation.Select, commandTimeout: commandTimeout)); } - public Task ExecuteReaderAsync(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) + public Task ExecuteReaderAsync(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default) { return ExecuteReaderAsync(new PreparedCommand(query, args, RetriableOperation.Select, commandTimeout: commandTimeout), cancellationToken); } @@ -530,7 +567,7 @@ protected TResult[] ReadResults(PreparedCommand preparedCommand, Func ReadResultsAsync(PreparedCommand preparedCommand, Func mapper, CancellationToken cancellationToken = default) + protected async Task ReadResultsAsync(PreparedCommand preparedCommand, Func> mapper, CancellationToken cancellationToken) { using var command = CreateCommand(preparedCommand); return await command.ReadResultsAsync(mapper, cancellationToken); @@ -540,11 +577,14 @@ PreparedCommand PrepareLoad(TKey id) { var mapping = configuration.DocumentMaps.Resolve(typeof(TDocument)); + if (mapping.IdColumn is null) + throw new InvalidOperationException($"Cannot load {mapping.Type.Name} by Id, as no Id column has been mapped."); + if (mapping.IdColumn.Type != typeof(TKey)) - throw new ArgumentException($"Provided Id of type '{id.GetType().FullName}' does not match configured type of '{mapping.IdColumn.Type.FullName}'."); + 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}}; + var args = new CommandParameterValues {{ "Id", mapping.IdColumn.PrimaryKeyHandler.ConvertToPrimitiveValue(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); } @@ -552,11 +592,11 @@ PreparedCommand PrepareLoadMany(IEnumerable idList) { var mapping = configuration.DocumentMaps.Resolve(typeof(TDocument)); - if (mapping.IdColumn.Type != typeof(TKey)) - throw new ArgumentException($"Provided Id of type '{typeof(TKey).FullName}' does not match configured type of '{mapping.IdColumn.Type.FullName}'."); + if (mapping.IdColumn?.Type != typeof(TKey)) + throw new ArgumentException($"Provided Id of type '{typeof(TKey).FullName}' does not match configured type of '{mapping.IdColumn?.Type.FullName}'."); var param = new CommandParameterValues(); - param.AddTable("criteriaTable", idList.ToList()); + param.AddTable("criteriaTable", idList.ToList(), configuration); var statement = $"SELECT s.* FROM [{configuration.GetSchemaNameOrDefault(mapping)}].[{mapping.TableName}] s INNER JOIN @criteriaTable t on t.[ParameterValue] = s.[{mapping.IdColumn.ColumnName}] order by s.[{mapping.IdColumn.ColumnName}]"; return new PreparedCommand(statement, param, RetriableOperation.Select, mapping, commandBehavior: CommandBehavior.SingleResult | CommandBehavior.SequentialAccess); } @@ -628,7 +668,7 @@ public override string ToString() return $"{CreatedTime} - {connection?.State} - {name}"; } - string ITransactionDiagnostic.Name => name; + string? ITransactionDiagnostic.Name => name; void ITransactionDiagnostic.WriteCurrentTransactions(StringBuilder output) { diff --git a/source/Nevermore/Advanced/WriteTransaction.cs b/source/Nevermore/Advanced/WriteTransaction.cs index 26deff5d..72bfeca5 100644 --- a/source/Nevermore/Advanced/WriteTransaction.cs +++ b/source/Nevermore/Advanced/WriteTransaction.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Data; +using System.Data.Common; using System.Diagnostics; using System.Linq; using System.Threading; @@ -36,8 +38,9 @@ public void Insert(TDocument document, InsertOptions options = null) var command = builder.PrepareInsert(new[] {document}, options); configuration.Hooks.BeforeInsert(document, command.Mapping, this); - var newRowVersion = ExecuteSingleDataModification(command); - ApplyNewRowVersionIfRequired(document, command.Mapping, newRowVersion); + var output = ExecuteSingleDataModification(command); + ApplyNewRowVersionIfRequired(document, command.Mapping, output); + ApplyIdentityIdsIfRequired(document, command.Mapping, output); configuration.Hooks.AfterInsert(document, command.Mapping, this); configuration.RelatedDocumentStore.PopulateRelatedDocuments(this, document); @@ -53,8 +56,9 @@ public async Task InsertAsync(TDocument document, InsertOptions optio var command = builder.PrepareInsert(new[] {document}, options); await configuration.Hooks.BeforeInsertAsync(document, command.Mapping, this); - var newRowVersion = await ExecuteSingleDataModificationAsync(command, cancellationToken); - ApplyNewRowVersionIfRequired(document, command.Mapping, newRowVersion); + var output = await ExecuteSingleDataModificationAsync(command, cancellationToken); + ApplyNewRowVersionIfRequired(document, command.Mapping, output); + ApplyIdentityIdsIfRequired(document, command.Mapping, output); await configuration.Hooks.AfterInsertAsync(document, command.Mapping, this); configuration.RelatedDocumentStore.PopulateRelatedDocuments(this, document); @@ -68,8 +72,9 @@ public void InsertMany(IReadOnlyCollection documents, Inse var command = builder.PrepareInsert(documentList, options); foreach (var document in documents) configuration.Hooks.BeforeInsert(document, command.Mapping, this); - var newRowVersions = ExecuteDataModification(command); - ApplyNewRowVersionsIfRequired(documentList, command.Mapping, newRowVersions); + var outputs = ExecuteDataModification(command); + ApplyNewRowVersionsIfRequired(documentList, command.Mapping, outputs); + ApplyIdentityIdsIfRequired(documentList, command.Mapping, outputs); foreach (var document in documentList) configuration.Hooks.AfterInsert(document, command.Mapping, this); configuration.RelatedDocumentStore.PopulateRelatedDocuments(this, documentList); @@ -87,12 +92,15 @@ public async Task InsertManyAsync(IReadOnlyCollection docu var command = builder.PrepareInsert(documentList, options); - foreach (var document in documentList) await configuration.Hooks.BeforeInsertAsync(document, command.Mapping, this); + foreach (var document in documentList) + await configuration.Hooks.BeforeInsertAsync(document, command.Mapping, this); - var newRowVersions = await ExecuteDataModificationAsync(command, cancellationToken); - ApplyNewRowVersionsIfRequired(documentList, command.Mapping, newRowVersions); + var outputs = await ExecuteDataModificationAsync(command, cancellationToken); + ApplyNewRowVersionsIfRequired(documentList, command.Mapping, outputs); + ApplyIdentityIdsIfRequired(documentList, command.Mapping, outputs); - foreach (var document in documentList) await configuration.Hooks.AfterInsertAsync(document, command.Mapping, this); + foreach (var document in documentList) + await configuration.Hooks.AfterInsertAsync(document, command.Mapping, this); configuration.RelatedDocumentStore.PopulateRelatedDocuments(this, documentList); } @@ -102,8 +110,8 @@ public void Update(TDocument document, UpdateOptions options = null) var command = builder.PrepareUpdate(document, options); configuration.Hooks.BeforeUpdate(document, command.Mapping, this); - var newRowVersion = ExecuteSingleDataModification(command); - ApplyNewRowVersionIfRequired(document, command.Mapping, newRowVersion); + var output = ExecuteSingleDataModification(command); + ApplyNewRowVersionIfRequired(document, command.Mapping, output); configuration.Hooks.AfterUpdate(document, command.Mapping, this); configuration.RelatedDocumentStore.PopulateRelatedDocuments(this, document); @@ -119,32 +127,32 @@ public async Task UpdateAsync(TDocument document, UpdateOptions optio var command = builder.PrepareUpdate(document, options); await configuration.Hooks.BeforeUpdateAsync(document, command.Mapping, this); - var newRowVersion = await ExecuteSingleDataModificationAsync(command, cancellationToken); - ApplyNewRowVersionIfRequired(document, command.Mapping, newRowVersion); + var output = await ExecuteSingleDataModificationAsync(command, cancellationToken); + ApplyNewRowVersionIfRequired(document, command.Mapping, output); await configuration.Hooks.AfterUpdateAsync(document, command.Mapping, this); configuration.RelatedDocumentStore.PopulateRelatedDocuments(this, document); } public void Delete(string id, DeleteOptions options = null) where TDocument : class - => Delete((object) id, options); + => Delete(id, options); public void Delete(int id, DeleteOptions options = null) where TDocument : class - => Delete((object) id, options); + => Delete(id, options); public void Delete(long id, DeleteOptions options = null) where TDocument : class - => Delete((object) id, options); + => Delete(id, options); public void Delete(Guid id, DeleteOptions options = null) where TDocument : class - => Delete((object) id, options); + => Delete(id, options); - public void Delete(TDocument document, DeleteOptions options = null) where TDocument : class + public void Delete(TDocument document, DeleteOptions options = null) where TDocument : class { - var id = configuration.DocumentMaps.GetId(document); - Delete(id, options); + var id = (TKey)configuration.DocumentMaps.GetId(document); + Delete(id, options); } - void Delete(object id, DeleteOptions options = null) where TDocument : class + public void Delete(TKey id, DeleteOptions options = null) where TDocument : class { var command = builder.PrepareDelete(id, options); configuration.Hooks.BeforeDelete(id, command.Mapping, this); @@ -164,30 +172,31 @@ public Task DeleteAsync(long id, CancellationToken cancellationToken public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) where TDocument : class => DeleteAsync(id, null, cancellationToken); - public Task DeleteAsync(TDocument document, CancellationToken cancellationToken = default) where TDocument : class - { - return DeleteAsync(document, null, cancellationToken); - } + public Task DeleteAsync(TDocument document, CancellationToken cancellationToken = default) where TDocument : class + => DeleteAsync(document, null, cancellationToken); public Task DeleteAsync(string id, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class - => DeleteAsync((object) id, options, cancellationToken); + => DeleteAsync(id, options, cancellationToken); public Task DeleteAsync(int id, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class - => DeleteAsync((object) id, options, cancellationToken); + => DeleteAsync(id, options, cancellationToken); public Task DeleteAsync(long id, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class - => DeleteAsync((object) id, options, cancellationToken); + => DeleteAsync(id, options, cancellationToken); public Task DeleteAsync(Guid id, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class - => DeleteAsync((object) id, options, cancellationToken); + => DeleteAsync(id, options, cancellationToken); - public Task DeleteAsync(TDocument document, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class + public Task DeleteAsync(TDocument document, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class { - var id = configuration.DocumentMaps.GetId(document); - return DeleteAsync(id, options, cancellationToken); + var id = (TKey)configuration.DocumentMaps.GetId(document); + return DeleteAsync(id, options, cancellationToken); } - async Task DeleteAsync(object id, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class + public Task DeleteAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class + => DeleteAsync(id, null, cancellationToken); + + public async Task DeleteAsync(TKey id, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class { var command = builder.PrepareDelete(id, options); await configuration.Hooks.BeforeDeleteAsync(id, command.Mapping, this); @@ -200,30 +209,51 @@ public IDeleteQueryBuilder DeleteQuery() where TDocument : return new DeleteQueryBuilder(ParameterNameGenerator, builder, this); } - public string AllocateId(Type documentType) + public TKey AllocateId(Type documentType) { var mapping = configuration.DocumentMaps.Resolve(documentType); - return AllocateId(mapping); + return AllocateIdForMapping(mapping); } - string AllocateId(DocumentMap mapping) + public TKey AllocateId() { - return AllocateId(mapping.TableName, mapping.IdFormat); + var mapping = configuration.DocumentMaps.Resolve(); + return AllocateIdForMapping(mapping); } - public string AllocateId(string tableName, string idPrefix) + TKey AllocateIdForMapping(DocumentMap mapping) + { + if (mapping.IdColumn?.Direction == ColumnDirection.FromDatabase) + throw new InvalidOperationException($"The document map for {mapping.Type} is configured to use an identity key handler."); + + return (TKey) AllocateIdUsingHandler(mapping); + } + + object AllocateId(DocumentMap mapping) + { + if (mapping.IdColumn?.Direction == ColumnDirection.FromDatabase) + throw new InvalidOperationException($"The document map for {mapping.Type} is configured to use an identity key handler."); + + return AllocateIdUsingHandler(mapping); + } + + object AllocateIdUsingHandler(DocumentMap mapping) { - return AllocateId(tableName, key => $"{idPrefix}-{key}"); + if (mapping.IdColumn is null || mapping.IsIdentityId) + throw new InvalidOperationException($"Cannot allocate an id when an Id column has not been mapped."); + return mapping.IdColumn.PrimaryKeyHandler.GetNextKey(keyAllocator, mapping.TableName); } - public string AllocateId(string tableName, Func idFormatter) + public string AllocateId(string tableName, string idPrefix) { var key = keyAllocator.NextId(tableName); - return idFormatter(key); + return $"{idPrefix}-{key}"; } public void Commit() { + if (Transaction is null) + throw new InvalidOperationException("There is no current transaction, call Open/OpenAsync to start a transaction"); if (!configuration.AllowSynchronousOperations) throw new SynchronousOperationsDisabledException(); configuration.Hooks.BeforeCommit(this); @@ -233,64 +263,125 @@ public void Commit() public async Task CommitAsync(CancellationToken cancellationToken = default) { + if (Transaction is null) + throw new InvalidOperationException("There is no current transaction, call Open/OpenAsync to start a transaction"); await configuration.Hooks.BeforeCommitAsync(this); await Transaction.CommitAsync(cancellationToken); await configuration.Hooks.AfterCommitAsync(this); } - object[] ExecuteDataModification(PreparedCommand command) + DataModificationOutput[] ExecuteDataModification(PreparedCommand command) { - if (!command.Mapping.IsRowVersioningEnabled) + if (!command.Mapping.HasModificationOutputs) { ExecuteNonQuery(command); - return Array.Empty(); + return Array.Empty(); } //The results need to be read eagerly so errors are raised while code is still executing within CommandExecutor error handling logic - return ReadResults(command, reader => reader.GetValue(0)); + return ReadResults(command, + reader => DataModificationOutput.Read(reader, command.Mapping, + command.Operation == RetriableOperation.Insert)); } - object ExecuteSingleDataModification(PreparedCommand command) + DataModificationOutput ExecuteSingleDataModification(PreparedCommand command) { var results = ExecuteDataModification(command); return results.SingleOrDefault(); } - async Task ExecuteDataModificationAsync(PreparedCommand command, CancellationToken cancellationToken) + async Task ExecuteDataModificationAsync(PreparedCommand command, CancellationToken cancellationToken) { - if (!command.Mapping.IsRowVersioningEnabled) + if (!command.Mapping.HasModificationOutputs) { await ExecuteNonQueryAsync(command, cancellationToken); - return Array.Empty(); + return Array.Empty(); } - return await ReadResultsAsync(command, reader => reader.GetValue(0), cancellationToken); + return await ReadResultsAsync(command, + async reader => await DataModificationOutput.ReadAsync(reader, command.Mapping, + command.Operation == RetriableOperation.Insert, cancellationToken), cancellationToken); } - async Task ExecuteSingleDataModificationAsync(PreparedCommand command, CancellationToken cancellationToken) + async Task ExecuteSingleDataModificationAsync(PreparedCommand command, CancellationToken cancellationToken) { var results = await ExecuteDataModificationAsync(command, cancellationToken); return results.SingleOrDefault(); } - void ApplyNewRowVersionsIfRequired(IReadOnlyList documentList, DocumentMap mapping, object[] newRowVersions) + void ApplyNewRowVersionsIfRequired(IReadOnlyList documentList, DocumentMap mapping, DataModificationOutput[] outputs) { if (!mapping.IsRowVersioningEnabled) return; for (var i = 0; i < documentList.Count; i++) { - ApplyNewRowVersionIfRequired(documentList[i], mapping, newRowVersions[i]); + ApplyNewRowVersionIfRequired(documentList[i], mapping, outputs[i]); } } - void ApplyNewRowVersionIfRequired(TDocument document, DocumentMap mapping, object newRowVersion) where TDocument : class + void ApplyNewRowVersionIfRequired(TDocument document, DocumentMap mapping, DataModificationOutput output) where TDocument : class { if (!mapping.IsRowVersioningEnabled) return; - if (newRowVersion == null) throw new StaleDataException($"Modification failed for '{typeof(TDocument).Name}' document with '{mapping.GetId(document)}' Id because submitted data was out of date. Refresh the document and try again."); + if (output?.RowVersion == null) + throw new StaleDataException($"Modification failed for '{typeof(TDocument).Name}' document with '{mapping.GetId(document)}' Id because submitted data was out of date. Refresh the document and try again."); + + mapping.RowVersionColumn!.PropertyHandler.Write(document, output.RowVersion); + } + + void ApplyIdentityIdsIfRequired(IReadOnlyList documentList, DocumentMap mapping, DataModificationOutput[] outputs) + { + if (!mapping.IsIdentityId) return; - mapping.RowVersionColumn.PropertyHandler.Write(document, newRowVersion); + for (var i = 0; i < documentList.Count; i++) + { + ApplyIdentityIdsIfRequired(documentList[i], mapping, outputs[i]); + } } + void ApplyIdentityIdsIfRequired(TDocument document, DocumentMap mapping, DataModificationOutput output) where TDocument : class + { + if (!mapping.IsIdentityId) return; + + if (output?.Id == null) + throw new InvalidOperationException( + $"Modification failed for '{typeof(TDocument).Name}' document with '{mapping.GetId(document)}' Id because the server failed to return a new Identity id."); + + mapping.IdColumn!.PropertyHandler.Write(document, output.Id); + } + + class DataModificationOutput + { + public byte[] RowVersion { get; private set; } + public object Id { get; private set; } + + public static DataModificationOutput Read(DbDataReader reader, DocumentMap map, bool isInsert) + { + var output = new DataModificationOutput(); + + if (map.IsRowVersioningEnabled) + output.RowVersion = + reader.GetFieldValue(map.RowVersionColumn!.ColumnName); + + if (map.IsIdentityId && isInsert) + output.Id = reader.GetFieldValue(map.IdColumn!.ColumnName); + + return output; + } + + public static async Task ReadAsync(DbDataReader reader, DocumentMap map, bool isInsert, CancellationToken cancellationToken) + { + var output = new DataModificationOutput(); + + if (map.IsRowVersioningEnabled) + output.RowVersion = + await reader.GetFieldValueAsync(map.RowVersionColumn!.ColumnName, cancellationToken); + + if (map.IsIdentityId && isInsert) + output.Id = await reader.GetFieldValueAsync(map.IdColumn!.ColumnName, cancellationToken); + + return output; + } + } } } diff --git a/source/Nevermore/CommandExecutor.cs b/source/Nevermore/CommandExecutor.cs index b3e13bd2..141c0835 100644 --- a/source/Nevermore/CommandExecutor.cs +++ b/source/Nevermore/CommandExecutor.cs @@ -180,15 +180,17 @@ public async Task ExecuteReaderAsync(CancellationToken cancellatio } } - public async Task ReadResultsAsync(Func mapper, CancellationToken cancellationToken = default) + public async Task ReadResultsAsync(Func> mapper, CancellationToken cancellationToken) { try { var data = new List(); - await using var reader = await command.ExecuteReaderWithRetryAsync(retryPolicy, prepared.CommandBehavior, cancellationToken: cancellationToken); - while (await reader.ReadAsync(cancellationToken)) + using (var reader = await command.ExecuteReaderWithRetryAsync(retryPolicy, prepared.CommandBehavior, cancellationToken)) { - data.Add(mapper(reader)); + while (await reader.ReadAsync(cancellationToken)) + { + data.Add(await mapper(reader)); + } } return data.ToArray(); diff --git a/source/Nevermore/CommandParameterValues.cs b/source/Nevermore/CommandParameterValues.cs index e8b06cda..e6600017 100644 --- a/source/Nevermore/CommandParameterValues.cs +++ b/source/Nevermore/CommandParameterValues.cs @@ -56,49 +56,29 @@ public CommandParameterValues(object args) public CommandType CommandType { get; set; } - public void AddTable(string name, IReadOnlyCollection ids) + public void AddTable(string name, IReadOnlyCollection ids, IRelationalStoreConfiguration configuration) { - var idColumnMetadata = GetSqlMetaData(ids, "ParameterValue"); + var primaryKeyHandler = configuration.PrimaryKeyHandlers.Resolve(typeof(T)); + if (primaryKeyHandler is null) + throw new InvalidOperationException($"Unable to locate primary key handler for type {typeof(T).Name}"); + + var idColumnMetadata = primaryKeyHandler.GetSqlMetaData("ParameterValue"); var dataRecords = ids.Where(v => v != null).Select(v => { var record = new SqlDataRecord(idColumnMetadata); - record.SetValue(0, v); + record.SetValue(0, primaryKeyHandler.ConvertToPrimitiveValue(v)); return record; }).ToList(); - + AddTable(name, new TableValuedParameter("dbo.[ParameterList]", dataRecords)); } - + public void AddTable(string name, TableValuedParameter tvp) { Add(name, tvp); } - static SqlMetaData GetSqlMetaData(IReadOnlyCollection ids, string name) - { - var valueType = typeof(T); - var notSupportedErrorMsg = $"'{valueType.Name}' is not a valid ID type, supported types are: {nameof(String)}, {nameof(Int32)}, {nameof(Int64)} and {nameof(Guid)}."; - switch (Type.GetTypeCode(valueType)) - { - //TODO: May consider dynamically resolving the 'ParameterValue' column length for User-Defined Table Type 'ParameterList' based on each DocumentMap configuration later. - //TODO: The fixed length of 300 is a temporary solution which match our table schema in Script0002-ParameterList.sql - case TypeCode.String: return new SqlMetaData(name, SqlDbType.NVarChar, 300); - case TypeCode.Int32: return new SqlMetaData(name, SqlDbType.Int); - case TypeCode.Int64: return new SqlMetaData(name, SqlDbType.BigInt); - case TypeCode.Object: - { - if (valueType == typeof(Guid)) - { - return new SqlMetaData(name, SqlDbType.UniqueIdentifier); - } - - throw new NotSupportedException(notSupportedErrorMsg); - } - default: throw new NotSupportedException(notSupportedErrorMsg); - } - } - void AddFromParametersObject(object args) { if (args == null) @@ -139,7 +119,7 @@ protected virtual void ContributeParameter(IDbCommand command, ITypeHandlerRegis command.Parameters.Add(p); return; } - + if (value is TableValuedParameter tvp && command is SqlCommand sqlCommand) { var p = sqlCommand.Parameters.Add(name, SqlDbType.Structured); @@ -154,9 +134,9 @@ protected virtual void ContributeParameter(IDbCommand command, ITypeHandlerRegis var i = 0; var inClauseValues = ((IEnumerable) value).Cast().ToList(); - + ListExtender.ExtendListRepeatingLastValue(inClauseValues); - + foreach (var inClauseValue in inClauseValues) { i++; @@ -181,7 +161,7 @@ protected virtual void ContributeParameter(IDbCommand command, ITypeHandlerRegis var columnType = DatabaseTypeConverter.AsDbType(value.GetType()); if (columnType == null) throw new InvalidOperationException($"Cannot map type '{value.GetType().FullName}' to a DbType. Consider providing a custom ITypeHandler."); - + var param = new SqlParameter(); param.ParameterName = name; param.DbType = columnType.Value; @@ -192,11 +172,11 @@ protected virtual void ContributeParameter(IDbCommand command, ITypeHandlerRegis var size = GetBestSizeBucket(text); if (size > 0) { - param.Size = size; + param.Size = size; } } - // To assist SQL's query plan caching, assign a parameter size for our + // To assist SQL's query plan caching, assign a parameter size for our // common id lookups where possible. if (mapping != null && mapping.IdColumn != null @@ -223,11 +203,11 @@ protected virtual void ContributeParameter(IDbCommand command, ITypeHandlerRegis command.Parameters.Add(param); } - + // By default all string parameters have their size automatically assigned based on the length of the string. // This results in a different query plan depending on the size of the text used. The query plan ends up like this: - // + // // (@firstname nvarchar(24))SELECT TOP 100 * FROM dbo.[Customer] WHERE ([FirstName] <> @firstname) ORDER BY [Id] // (@firstname nvarchar(44))SELECT TOP 100 * FROM dbo.[Customer] WHERE ([FirstName] <> @firstname) ORDER BY [Id] // (@firstname nvarchar(47))SELECT TOP 100 * FROM dbo.[Customer] WHERE ([FirstName] <> @firstname) ORDER BY [Id] diff --git a/source/Nevermore/ConfigurationExtensions.cs b/source/Nevermore/ConfigurationExtensions.cs index 85dce581..1d99358c 100644 --- a/source/Nevermore/ConfigurationExtensions.cs +++ b/source/Nevermore/ConfigurationExtensions.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using Nevermore.Advanced; using Nevermore.Advanced.Serialization; @@ -18,10 +19,10 @@ public static void UseJsonNetSerialization(this IRelationalStoreConfiguration co callback(jsonNet.SerializerSettings); } - internal static string GetSchemaNameOrDefault(this IRelationalStoreConfiguration configuration, string schemaName) + internal static string GetSchemaNameOrDefault(this IRelationalStoreConfiguration configuration, string? schemaName) { - return schemaName - ?? configuration.DefaultSchema + return schemaName + ?? configuration.DefaultSchema ?? NevermoreDefaults.FallbackDefaultSchemaName; } diff --git a/source/Nevermore/IReadQueryExecutor.cs b/source/Nevermore/IReadQueryExecutor.cs index e432d87f..1ec3f524 100644 --- a/source/Nevermore/IReadQueryExecutor.cs +++ b/source/Nevermore/IReadQueryExecutor.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Data.Common; @@ -19,7 +20,7 @@ public interface IReadQueryExecutor /// The type of document being queried. Results from the database will be mapped to this type. /// The Id of the document to find. /// The document, or null if the document is not found. - [Pure] TDocument Load(string id) where TDocument : class; + [Pure] TDocument? Load(string id) where TDocument : class; /// /// Loads a single document given its ID. If the item is not found, returns null. @@ -27,7 +28,7 @@ public interface IReadQueryExecutor /// The type of document being queried. Results from the database will be mapped to this type. /// The Id of the document to find. /// The document, or null if the document is not found. - [Pure] TDocument Load(int id) where TDocument : class; + [Pure] TDocument? Load(int id) where TDocument : class; /// /// Loads a single document given its ID. If the item is not found, returns null. @@ -35,7 +36,7 @@ public interface IReadQueryExecutor /// The type of document being queried. Results from the database will be mapped to this type. /// The Id of the document to find. /// The document, or null if the document is not found. - [Pure] TDocument Load(long id) where TDocument : class; + [Pure] TDocument? Load(long id) where TDocument : class; /// /// Loads a single document given its ID. If the item is not found, returns null. @@ -43,7 +44,16 @@ public interface IReadQueryExecutor /// The type of document being queried. Results from the database will be mapped to this type. /// The Id of the document to find. /// The document, or null if the document is not found. - [Pure] TDocument Load(Guid id) where TDocument : class; + [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; /// /// Loads a single document given its ID. If the item is not found, returns null. @@ -52,7 +62,7 @@ public interface IReadQueryExecutor /// The Id of the document to find. /// Token to use to cancel the command. /// The document, or null if the document is not found. - [Pure] Task LoadAsync(string id, CancellationToken cancellationToken = default) where TDocument : class; + [Pure] Task LoadAsync(string id, CancellationToken cancellationToken = default) where TDocument : class; /// /// Loads a single document given its ID. If the item is not found, returns null. @@ -61,7 +71,7 @@ public interface IReadQueryExecutor /// The Id of the document to find. /// Token to use to cancel the command. /// The document, or null if the document is not found. - [Pure] Task LoadAsync(int id, CancellationToken cancellationToken = default) where TDocument : class; + [Pure] Task LoadAsync(int id, CancellationToken cancellationToken = default) where TDocument : class; /// /// Loads a single document given its ID. If the item is not found, returns null. @@ -70,7 +80,7 @@ public interface IReadQueryExecutor /// The Id of the document to find. /// Token to use to cancel the command. /// The document, or null if the document is not found. - [Pure] Task LoadAsync(long id, CancellationToken cancellationToken = default) where TDocument : class; + [Pure] Task LoadAsync(long id, CancellationToken cancellationToken = default) where TDocument : class; /// /// Loads a single document given its ID. If the item is not found, returns null. @@ -79,7 +89,17 @@ public interface IReadQueryExecutor /// The Id of the document to find. /// Token to use to cancel the command. /// The document, or null if the document is not found. - [Pure] Task LoadAsync(Guid id, CancellationToken cancellationToken = default) where TDocument : class; + [Pure] Task LoadAsync(Guid id, CancellationToken cancellationToken = default) 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. + /// Token to use to cancel the command. + /// The document, or null if the document is not found. + [Pure] Task LoadAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class; /// /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, @@ -153,6 +173,26 @@ public interface IReadQueryExecutor /// The documents. [Pure] List LoadMany(IEnumerable ids) where TDocument : class; + /// + /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, + /// the results may contain less items than the number of ID's queried for). + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// The documents. + [Pure] List LoadMany(params TKey[] ids) where TDocument : class; + + /// + /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, + /// the results may contain less items than the number of ID's queried for). + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// The documents. + [Pure] List LoadMany(IEnumerable ids) where TDocument : class; + /// /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, /// the results may contain less items than the number of ID's queried for). @@ -193,6 +233,17 @@ public interface IReadQueryExecutor /// The documents. [Pure] Task> LoadManyAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class; + /// + /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, + /// the results may contain less items than the number of ID's queried for). + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// Token to use to cancel the command. + /// The documents. + [Pure] Task> LoadManyAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class; + /// /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, /// the results may contain less items than the number of ID's queried for). @@ -265,6 +316,26 @@ public interface IReadQueryExecutor /// The documents as a lazy loaded stream. [Pure] IEnumerable LoadStream(IEnumerable ids) where TDocument : class; + /// + /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, + /// the results may contain less items than the number of ID's queried for). + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// The documents as a lazy loaded stream. + [Pure] IEnumerable LoadStream(params TKey[] ids) where TDocument : class; + + /// + /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, + /// the results may contain less items than the number of ID's queried for). + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// The documents as a lazy loaded stream. + [Pure] IEnumerable LoadStream(IEnumerable ids) where TDocument : class; + /// /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, /// the results may contain less items than the number of ID's queried for). @@ -305,6 +376,17 @@ public interface IReadQueryExecutor /// The documents as a lazy loaded stream. [Pure] IAsyncEnumerable LoadStreamAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class; + /// + /// Loads a set of documents by their ID's. Documents that are not found are excluded from the result list (that is, + /// the results may contain less items than the number of ID's queried for). + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// Token to use to cancel the command. + /// The documents as a lazy loaded stream. + [Pure] IAsyncEnumerable LoadStreamAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class; + /// /// Loads a single document given its ID. If the item is not found, throws a . /// @@ -337,6 +419,15 @@ public interface IReadQueryExecutor /// The document, or null if the document is not found. [Pure] TDocument LoadRequired(Guid id) where TDocument : class; + /// + /// Loads a single document given its ID. If the item is not found, throws a . + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// The Id of the document to find. + /// The document, or null if the document is not found. + [Pure] TDocument LoadRequired(TKey id) where TDocument : class; + /// /// Loads a single document given its ID. If the item is not found, throws a . /// @@ -373,6 +464,16 @@ public interface IReadQueryExecutor /// The document, or null if the document is not found. [Pure] Task LoadRequiredAsync(Guid id, CancellationToken cancellationToken = default) where TDocument : class; + /// + /// Loads a single document given its ID. If the item is not found, throws a . + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// The Id of the document to find. + /// Token to use to cancel the command. + /// The document, or null if the document is not found. + [Pure] Task LoadRequiredAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class; + /// /// Loads a set of documents by their ID's. If any of the documents are not found, a /// will be thrown. @@ -445,6 +546,26 @@ public interface IReadQueryExecutor /// The documents. [Pure] List LoadManyRequired(IEnumerable ids) where TDocument : class; + /// + /// Loads a set of documents by their ID's. If any of the documents are not found, a + /// will be thrown. + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// The documents. + [Pure] List LoadManyRequired(params TKey[] ids) where TDocument : class; + + /// + /// Loads a set of documents by their ID's. If any of the documents are not found, a + /// will be thrown. + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// The documents. + [Pure] List LoadManyRequired(IEnumerable ids) where TDocument : class; + /// /// Loads a set of documents by their ID's. If any of the documents are not found, a /// will be thrown. @@ -485,6 +606,17 @@ public interface IReadQueryExecutor /// The documents. [Pure] Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class; + /// + /// Loads a set of documents by their ID's. If any of the documents are not found, a + /// will be thrown. + /// + /// The type of document being queried. Results from the database will be mapped to this type. + /// The primary key type of the document + /// A collection of ID's to query by. + /// Token to use to cancel the command. + /// The documents. + [Pure] Task> LoadManyRequiredAsync(IEnumerable ids, CancellationToken cancellationToken = default) where TDocument : class; + /// /// Begins building a query that returns strongly typed documents. /// @@ -509,7 +641,7 @@ public interface IReadQueryExecutor /// Any arguments to pass to the query as command parameters. /// A custom timeout to use for the command instead of the default. /// A stream of resulting documents. - [Pure] IEnumerable Stream(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null); + [Pure] IEnumerable Stream(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null); /// /// Executes a query that returns strongly typed documents. @@ -520,10 +652,10 @@ public interface IReadQueryExecutor /// A custom timeout to use for the command instead of the default. /// Token to use to cancel the command. /// A stream of resulting documents. - [Pure] IAsyncEnumerable StreamAsync(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); + [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 +663,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. @@ -569,7 +701,7 @@ public interface IReadQueryExecutor /// Any arguments to pass to the query as command parameters. /// A custom timeout to use for the command instead of the default. /// The number of rows affected. - int ExecuteNonQuery(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null); + int ExecuteNonQuery(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null); /// /// Executes a query that returns no results, typically one that will write to the database. It can also be @@ -580,7 +712,7 @@ public interface IReadQueryExecutor /// A custom timeout to use for the command instead of the default. /// Token to use to cancel the command. /// Depends on the query, but typically the number of rows affected. - Task ExecuteNonQueryAsync(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); + Task ExecuteNonQueryAsync(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); /// /// Executes a query that returns no results, typically one that will write to the database. It can also be @@ -608,7 +740,7 @@ public interface IReadQueryExecutor /// The type of operation being performed. The retry policy on the transaction will then decide whether it's safe to retry this command if it fails. /// A custom timeout to use for the command instead of the default. /// A scalar value. - TResult ExecuteScalar(string query, CommandParameterValues args = null, RetriableOperation retriableOperation = RetriableOperation.Select, TimeSpan? commandTimeout = null); + TResult ExecuteScalar(string query, CommandParameterValues? args = null, RetriableOperation retriableOperation = RetriableOperation.Select, TimeSpan? commandTimeout = null); /// /// Executes a query that returns a scalar value (e.g., SELECT query that returns a count). @@ -620,7 +752,7 @@ public interface IReadQueryExecutor /// A custom timeout to use for the command instead of the default. /// Token to use to cancel the command. /// A scalar value. - Task ExecuteScalarAsync(string query, CommandParameterValues args = null, RetriableOperation retriableOperation = RetriableOperation.Select, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); + Task ExecuteScalarAsync(string query, CommandParameterValues? args = null, RetriableOperation retriableOperation = RetriableOperation.Select, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); /// /// Executes a query that returns a scalar value (e.g., SELECT query that returns a count). @@ -646,7 +778,7 @@ public interface IReadQueryExecutor /// Any arguments to pass to the query as command parameters. /// A custom timeout to use for the command instead of the default. /// A data reader. - [Pure] DbDataReader ExecuteReader(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null); + [Pure] DbDataReader ExecuteReader(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null); /// /// Executes a query that returns a data reader that you can process manually. @@ -656,7 +788,7 @@ public interface IReadQueryExecutor /// A custom timeout to use for the command instead of the default. /// Token to use to cancel the command. /// A data reader. - [Pure] Task ExecuteReaderAsync(string query, CommandParameterValues args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); + [Pure] Task ExecuteReaderAsync(string query, CommandParameterValues? args = null, TimeSpan? commandTimeout = null, CancellationToken cancellationToken = default); /// /// Executes a query that returns a data reader that you can process manually. diff --git a/source/Nevermore/IRelationalStoreConfiguration.cs b/source/Nevermore/IRelationalStoreConfiguration.cs index b096147a..3dc27e3a 100644 --- a/source/Nevermore/IRelationalStoreConfiguration.cs +++ b/source/Nevermore/IRelationalStoreConfiguration.cs @@ -13,39 +13,40 @@ public interface IRelationalStoreConfiguration { string ApplicationName { get; set; } string ConnectionString { get; } - + /// /// Gets or sets whether synchronous operations are allowed. The default is true. Set to /// false to have Nevermore throw a when /// calling a synchronous operation. /// public bool AllowSynchronousOperations { get; set; } - + /// /// Gets or sets the default schema name (e.g., 'dbo') that will be used as a prefix on all statements. Can be /// overridden on each document map. /// string DefaultSchema { get; set; } - + IDocumentMapRegistry DocumentMaps { get; } IDocumentSerializer DocumentSerializer { get; set; } IReaderStrategyRegistry ReaderStrategies { get; } ITypeHandlerRegistry TypeHandlers { get; } IInstanceTypeRegistry InstanceTypeResolvers { get; } + IPrimaryKeyHandlerRegistry PrimaryKeyHandlers { get; } IRelatedDocumentStore RelatedDocumentStore { get; set; } IQueryLogger QueryLogger { get; set; } - + /// /// Hooks can be used to apply general logic when documents are inserted, updated or deleted. /// IHookRegistry Hooks { get; } - + /// /// Gets or sets the factory that creates SQL commands. Set this if you want to control how commands are set up, - /// or add a decorator to capture diagnostic information. + /// or add a decorator to capture diagnostic information. /// ISqlCommandFactory CommandFactory { get; set; } - + /// /// Gets or sets the key block size that will be used for the key allocator. A higher number enables less /// SQL queries to get new blocks, but increases fragmentation. @@ -54,11 +55,11 @@ public interface IRelationalStoreConfiguration /// /// Gets or sets whether Multiple Active Result Sets is enabled. On .NET Core 3.1 this has issues on Linux, so it - /// defaults to false. + /// defaults to false. /// /// https://docs.microsoft.com/en-us/sql/relational-databases/native-client/features/using-multiple-active-result-sets-mars?view=sql-server-ver15 bool ForceMultipleActiveResultSets { get; set; } - + /// /// Gets or sets whether to actively detect similar queries being executed in a way that is slightly different, /// resulting in duplicate query plans being created. diff --git a/source/Nevermore/ITransactionDiagnostic.cs b/source/Nevermore/ITransactionDiagnostic.cs index a37bbe5a..827c2868 100644 --- a/source/Nevermore/ITransactionDiagnostic.cs +++ b/source/Nevermore/ITransactionDiagnostic.cs @@ -1,10 +1,11 @@ +#nullable enable using System.Text; namespace Nevermore { internal interface ITransactionDiagnostic { - public string Name { get; } + public string? Name { get; } public void WriteCurrentTransactions(StringBuilder output); } } \ No newline at end of file diff --git a/source/Nevermore/IWriteQueryExecutor.cs b/source/Nevermore/IWriteQueryExecutor.cs index 32f5b94b..d6df3c5b 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. @@ -92,6 +92,15 @@ public interface IWriteQueryExecutor : IReadQueryExecutor /// Token to use to cancel the command. Task UpdateAsync(TDocument document, UpdateOptions options, CancellationToken cancellationToken = default) where TDocument : class; + /// + /// Deletes an existing document from the database by its ID. + /// + /// The type of document being deleted. + /// The document's key type + /// The id of the document to delete. + /// Advanced options for the delete operation. + void Delete(TKey id, DeleteOptions options = null) where TDocument : class; + /// /// Deletes an existing document from the database by its ID. /// @@ -128,9 +137,19 @@ public interface IWriteQueryExecutor : IReadQueryExecutor /// Deletes an existing document from the database. /// /// The type of document being deleted. + /// The document's key type /// The document to delete. /// Advanced options for the delete operation. - void Delete(TDocument document, DeleteOptions options = null) where TDocument : class; + void Delete(TDocument document, DeleteOptions options = null) where TDocument : class; + + /// + /// Deletes an existing document from the database by its ID. + /// + /// The type of document being deleted. + /// The document's key type + /// The id of the document to delete. + /// Token to use to cancel the command. + Task DeleteAsync(TKey id, CancellationToken cancellationToken = default) where TDocument : class; /// /// Deletes an existing document from the database by its ID. @@ -168,9 +187,20 @@ public interface IWriteQueryExecutor : IReadQueryExecutor /// Deletes an existing document from the database. /// /// The type of document being deleted. + /// The document's key type /// The document to delete. /// Token to use to cancel the command. - Task DeleteAsync(TDocument document, CancellationToken cancellationToken = default) where TDocument : class; + Task DeleteAsync(TDocument document, CancellationToken cancellationToken = default) where TDocument : class; + + /// + /// Deletes an existing document from the database by its ID. + /// + /// The type of document being deleted. + /// The document's key type + /// The id of the document to delete. + /// Advanced options for the delete operation. + /// Token to use to cancel the command. + Task DeleteAsync(TKey id, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class; /// /// Deletes an existing document from the database by its ID. @@ -212,10 +242,11 @@ public interface IWriteQueryExecutor : IReadQueryExecutor /// Deletes an existing document from the database. /// /// The type of document being deleted. + /// The document's key type /// The document to delete. /// Advanced options for the delete operation. /// Token to use to cancel the command. - Task DeleteAsync(TDocument document, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class; + Task DeleteAsync(TDocument document, DeleteOptions options, CancellationToken cancellationToken = default) where TDocument : class; /// /// Creates a deletion query for a strongly typed document. @@ -226,18 +257,30 @@ public interface IWriteQueryExecutor : IReadQueryExecutor /// /// Allocate an ID for the specified type. The type must be mapped. - /// If the mapping specifies a SingletonId, that is returned + /// If the mapping specifies a SingletonId, that is returned. /// /// /// - string AllocateId(Type documentType); + /// Will be thrown if the requested key type does not match the mapped document's key type. + TKey AllocateId(Type documentType); /// - /// Allocates an ID using the specified table name. Any mapping for that table is not used. + /// Allocates an ID using the specified table name. Any mapping for that table is not used. The configuration's PrimaryKeyHandlerRegistry must contain + /// an entry for the key type (primitives are handled out of the box). /// /// /// /// string AllocateId(string tableName, string idPrefix); + + /// + /// Allocate an ID for the specified type. The type must be mapped. + /// If the mapping specifies a SingletonId, that is returned + /// + /// The type of document. + /// The key type to allocate, e.g. int, string, MyCustomStringType. + /// + /// Will be thrown if the requested key type does not match the mapped document's key type. + TKey AllocateId(); } } \ No newline at end of file diff --git a/source/Nevermore/ListExtensions.cs b/source/Nevermore/ListExtensions.cs index 486c2042..2674beb5 100644 --- a/source/Nevermore/ListExtensions.cs +++ b/source/Nevermore/ListExtensions.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Linq; @@ -8,43 +9,19 @@ public static class ListExtensions { /// /// Same as Contains but in the opposite calling structure. In Nevermore LINQ queries, translates to "WHERE X IN (@param1, @param2)...". - /// In other cases calls collection.Contains(value). + /// In other cases calls collection.Contains(value). /// /// The value to search for /// The collection to search within. /// True if exists in - public static bool In(this T value, IEnumerable collection) where T : struct, IConvertible + public static bool In(this T? value, IEnumerable collection) { return collection.Contains(value); } - - /// - /// Same as Contains but in the opposite calling structure. In Nevermore LINQ queries, translates to "WHERE X IN (@param1, @param2)...". - /// In other cases calls collection.Contains(value). - /// - /// The value to search for - /// The collection to search within. - /// True if exists in - public static bool In(this T? value, IEnumerable collection) where T : struct, IConvertible - { - return collection.Contains(value); - } - - /// - /// Same as Contains but in the opposite calling structure. In Nevermore LINQ queries, translates to "WHERE X IN (@param1, @param2)...". - /// In other cases calls collection.Contains(value). - /// - /// The value to search for - /// The collection to search within. - /// True if exists in - public static bool In(this string value, IEnumerable collection) - { - return collection.Contains(value); - } - + /// /// Same as !Contains but in the opposite calling structure. In Nevermore LINQ queries, translates to "WHERE X NOT IN (@param1, @param2)...". - /// In other cases calls !collection.Contains(value). + /// In other cases calls !collection.Contains(value). /// /// The value to search for /// The collection to search within. @@ -53,11 +30,11 @@ public static bool NotIn(this T value, IEnumerable collection) where T : s { return !collection.Contains(value); } - - + + /// /// Same as !Contains but in the opposite calling structure. In Nevermore LINQ queries, translates to "WHERE X NOT IN (@param1, @param2)...". - /// In other cases calls !collection.Contains(value). + /// In other cases calls !collection.Contains(value). /// /// The value to search for /// The collection to search within. @@ -66,10 +43,10 @@ public static bool NotIn(this T? value, IEnumerable collection) where T : { return !collection.Contains(value); } - + /// /// Same as !Contains but in the opposite calling structure. In Nevermore LINQ queries, translates to "WHERE X NOT IN (@param1, @param2)...". - /// In other cases calls !collection.Contains(value). + /// In other cases calls !collection.Contains(value). /// /// The value to search for /// The collection to search within. diff --git a/source/Nevermore/Mapping/ColumnMapping.cs b/source/Nevermore/Mapping/ColumnMapping.cs index 4a2a70b7..c4140b5f 100644 --- a/source/Nevermore/Mapping/ColumnMapping.cs +++ b/source/Nevermore/Mapping/ColumnMapping.cs @@ -8,8 +8,6 @@ public class ColumnMapping : IColumnMappingBuilder { const int DefaultPrimaryKeyIdLength = 50; const int DefaultMaxForeignKeyIdLength = 50; - ColumnDirection direction; - int? maxLength; internal ColumnMapping(string columnName, Type type, IPropertyHandler handler, PropertyInfo property) { @@ -22,11 +20,11 @@ internal ColumnMapping(string columnName, Type type, IPropertyHandler handler, P return; if (Property.Name == "Id") { - maxLength = DefaultPrimaryKeyIdLength; + MaxLength = DefaultPrimaryKeyIdLength; } else if (Property.Name.EndsWith("Id")) // Foreign keys { - maxLength = DefaultMaxForeignKeyIdLength; + MaxLength = DefaultMaxForeignKeyIdLength; } } @@ -35,36 +33,39 @@ internal ColumnMapping(string columnName, Type type, IPropertyHandler handler, P public IPropertyHandler PropertyHandler { get; private set; } public PropertyInfo Property { get; } - public int? MaxLength => maxLength; - public ColumnDirection Direction => direction; + public int? MaxLength { get; protected set; } + public ColumnDirection Direction { get; protected set; } + IColumnMappingBuilder IColumnMappingBuilder.MaxLength(int max) { - maxLength = max; + MaxLength = max; return this; } IColumnMappingBuilder IColumnMappingBuilder.LoadOnly() { - direction = ColumnDirection.FromDatabase; + Direction = ColumnDirection.FromDatabase; return this; } IColumnMappingBuilder IColumnMappingBuilder.SaveOnly() { - direction = ColumnDirection.ToDatabase; + Direction = ColumnDirection.ToDatabase; return this; } IColumnMappingBuilder IColumnMappingBuilder.CustomPropertyHandler(IPropertyHandler propertyHandler) { - PropertyHandler = propertyHandler; + SetCustomPropertyHandler(propertyHandler); return this; } + protected virtual void SetCustomPropertyHandler(IPropertyHandler propertyHandler) => PropertyHandler = propertyHandler; + public void Validate() { - if ((direction == ColumnDirection.FromDatabase || direction == ColumnDirection.Both) && !PropertyHandler.CanWrite) + if ((Direction == ColumnDirection.FromDatabase || Direction == ColumnDirection.Both) && !PropertyHandler.CanWrite) { if (Property != null && PropertyHandler is PropertyHandler) { diff --git a/source/Nevermore/Mapping/DocumentMap.cs b/source/Nevermore/Mapping/DocumentMap.cs index e00c1b05..58aa5fb6 100644 --- a/source/Nevermore/Mapping/DocumentMap.cs +++ b/source/Nevermore/Mapping/DocumentMap.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Linq.Expressions; @@ -9,76 +10,50 @@ namespace Nevermore.Mapping { public abstract class DocumentMap : IDocumentMap { - readonly DocumentMap map = InitializeDefault(); + IIdColumnMappingBuilder? idColumn; + ColumnMapping? rowVersionColumn; + ColumnMapping? typeResolutionColumn; + readonly List columns = new List(); + readonly List relatedDocumentsMappings = new List(); + readonly List uniqueConstraints = new List(); protected DocumentMap() { + TableName = typeof(TDocument).Name; } /// /// Gets or sets the name of the schema containing the table that this document will be stored in. /// - protected string SchemaName - { - get => map.SchemaName; - set => map.SchemaName = value; - } + protected string? SchemaName { get; set; } /// /// Gets or sets the name of the table that this document will be stored in. /// - protected string TableName - { - get => map.TableName; - set => map.TableName = value; - } - - /// - /// Gets or sets the prefix to be used before IDs. If you override , this property won't - /// be used. - /// - protected string IdPrefix - { - get => map.IdPrefix; - set => map.IdPrefix = value; - } - - /// - /// Gets or sets a formatting function used to format generated document IDs. Examples: i => "C" + i; - /// - protected Func IdFormat - { - get => map.IdFormat; - set => map.IdFormat = value; - } + protected string TableName { get; set; } /// /// Tells Nevermore whether to expect large documents or not. Defaults to false, since most tables tend to only /// have small documents. However, this property is self-tuning: if Nevermore reads or writes a document /// larger than 1K, it will set this to true. /// - protected bool ExpectLargeDocuments - { - get => map.ExpectLargeDocuments; - set => map.ExpectLargeDocuments = value; - } + protected bool ExpectLargeDocuments { get; set; } /// /// Gets or sets the JSON storage mode. See https://github.com/OctopusDeploy/Nevermore/wiki/Compression for details. /// - protected JsonStorageFormat JsonStorageFormat - { - get => map.JsonStorageFormat; - set => map.JsonStorageFormat = value; - } + protected JsonStorageFormat JsonStorageFormat { get; set; } /// /// Configures the ID of the document. /// /// A builder to further configure the ID. - protected IColumnMappingBuilder Id() + protected IIdColumnMappingBuilder Id() { - return map.IdColumn; + idColumn = GetDefaultIdColumn(); + if (idColumn is null) + throw new InvalidOperationException($"Unable to determine default Id property for {typeof(TDocument).Name}."); + return idColumn; } /// @@ -87,9 +62,9 @@ protected IColumnMappingBuilder Id() /// An expression that accesses the property. E.g., c => c.FirstName /// The property type of the Id column. /// A builder to further configure the ID. - protected IColumnMappingBuilder Id(Expression> property) + protected IIdColumnMappingBuilder Id(Expression> property) { - return Id(null, property); + return Id(null, property); } /// @@ -99,12 +74,12 @@ protected IColumnMappingBuilder Id(ExpressionAn expression that accesses the property. E.g., c => c.FirstName /// The property type of the Id column. /// A builder to further configure the ID. - protected IColumnMappingBuilder Id(string columnName, Expression> property) + protected IIdColumnMappingBuilder Id(string? columnName, Expression> property) { var prop = GetPropertyInfo(property) ?? throw new Exception("The expression for the Id column must be a property."); - map.IdColumn = new ColumnMapping(columnName ?? prop.Name, typeof(TProperty), new PropertyHandler(prop), prop); - return map.IdColumn; + idColumn = new IdColumnMappingBuilder(columnName ?? prop.Name, typeof(TProperty), new PropertyHandler(prop), prop); + return idColumn; } /// @@ -129,13 +104,13 @@ protected IColumnMappingBuilder TypeResolutionColumn(ExpressionAn expression that accesses the property. E.g., c => c.FirstName /// The property type of the column. /// A builder to further configure the column mapping. - protected IColumnMappingBuilder TypeResolutionColumn(string columnName, Expression> property) + protected IColumnMappingBuilder TypeResolutionColumn(string? columnName, Expression> property) { var prop = GetPropertyInfo(property) ?? throw new Exception("The expression for the Type Resolution column must be a property."); - map.TypeResolutionColumn = new ColumnMapping(columnName ?? prop.Name, typeof(TProperty), new PropertyHandler(prop), prop); - map.Columns.Add(map.TypeResolutionColumn); - return map.TypeResolutionColumn; + typeResolutionColumn = new ColumnMapping(columnName ?? prop.Name, typeof(TProperty), new PropertyHandler(prop), prop); + columns.Add(typeResolutionColumn); + return typeResolutionColumn; } /// @@ -151,7 +126,7 @@ protected IColumnMappingBuilder Column(Expression(Expression> getter) { - map.RowVersionColumn = (ColumnMapping)Column(null, getter, null).LoadOnly(); + rowVersionColumn = (ColumnMapping)Column(null, getter, null).LoadOnly(); } /// @@ -174,7 +149,7 @@ protected IColumnMappingBuilder Column(string columnName, Expression< /// A func called when reading data from the database and setting it on the object. /// The property type of the column. /// A builder to further configure the column mapping. - protected IColumnMappingBuilder Column(string columnName, Expression> getter, Action setter) + protected IColumnMappingBuilder Column(string? columnName, Expression>? getter, Action? setter) { var property = GetPropertyInfo(getter ?? throw new ArgumentNullException(nameof(getter))); if (property != null && setter == null) @@ -193,21 +168,21 @@ protected IColumnMappingBuilder Column(string columnName, Expression< /// A custom property handler. /// If handler uses a property, the property, so it can be excluded from JSON. Can be null. /// A builder to further configure the column mapping. - protected IColumnMappingBuilder Column(string columnName, Type propertyType, IPropertyHandler handler, PropertyInfo prop = null) + protected IColumnMappingBuilder Column(string columnName, Type propertyType, IPropertyHandler handler, PropertyInfo? prop = null) { var column = new ColumnMapping(columnName, propertyType, handler, prop); - map.Columns.Add(column); + columns.Add(column); return column; } - protected RelatedDocumentsMapping RelatedDocuments(Expression>> property, string tableName = DocumentMap.RelatedDocumentTableName, string schemaName = null) + protected RelatedDocumentsMapping RelatedDocuments(Expression>> property, string tableName = DocumentMap.RelatedDocumentTableName, string? schemaName = null) { var mapping = new RelatedDocumentsMapping(GetPropertyInfo(property), tableName, schemaName); - map.RelatedDocumentsMappings.Add(mapping); + relatedDocumentsMappings.Add(mapping); return mapping; } - PropertyInfo GetPropertyInfo(Expression> propertyLambda) + PropertyInfo? GetPropertyInfo(Expression> propertyLambda) { var member = propertyLambda.Body as MemberExpression; if (member == null) @@ -217,7 +192,8 @@ PropertyInfo GetPropertyInfo(Expression(Expression(); UniqueConstraints = new List(); RelatedDocumentsMappings = new List(); - IdFormat = key => $"{IdPrefix}-{key}"; } - public Type Type { get; set; } - public ColumnMapping IdColumn { get; set; } - public ColumnMapping RowVersionColumn { get; set; } - public ColumnMapping TypeResolutionColumn { get; set; } + public Type Type { get; } + public IdColumnMapping? IdColumn { get; set; } + public ColumnMapping? RowVersionColumn { get; set; } + public ColumnMapping? TypeResolutionColumn { get; set; } public JsonStorageFormat JsonStorageFormat { get; set; } - public string TableName { get; set; } - public string IdPrefix { get; set; } - public Func IdFormat { get; set; } + public string TableName { get; } public bool ExpectLargeDocuments { get; set; } @@ -314,10 +297,14 @@ public DocumentMap() public List Columns { get; } public List UniqueConstraints { get; } public List RelatedDocumentsMappings { get; } - public string SchemaName { get; set; } + public string? SchemaName { get; set; } public bool IsRowVersioningEnabled => RowVersionColumn != null; + public bool IsIdentityId => IdColumn?.Direction == ColumnDirection.FromDatabase; + + public bool HasModificationOutputs =>IsRowVersioningEnabled || IsIdentityId; + public void Validate() { if (IdColumn == null) @@ -337,9 +324,9 @@ public void Validate() } } - public object GetId(object document) + public object? GetId(object? document) { - if (document == null) + if (document == null || IdColumn == null) return null; var readerWriter = IdColumn.PropertyHandler; diff --git a/source/Nevermore/Mapping/DocumentMapRegistry.cs b/source/Nevermore/Mapping/DocumentMapRegistry.cs index 8b86b937..64ea7b2d 100644 --- a/source/Nevermore/Mapping/DocumentMapRegistry.cs +++ b/source/Nevermore/Mapping/DocumentMapRegistry.cs @@ -1,20 +1,22 @@ +#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; -using Nevermore.Querying.AST; namespace Nevermore.Mapping { public class DocumentMapRegistry : IDocumentMapRegistry { + readonly IPrimaryKeyHandlerRegistry primaryKeyHandlerRegistry; readonly ConcurrentDictionary mappings = new ConcurrentDictionary(); - public DocumentMapRegistry() + public DocumentMapRegistry(IPrimaryKeyHandlerRegistry primaryKeyHandlerRegistry) { + this.primaryKeyHandlerRegistry = primaryKeyHandlerRegistry; } - + public List GetAll() { return new List(mappings.Values); @@ -35,12 +37,12 @@ public void Register(params IDocumentMap[] mappingsToAdd) { Register(mappingsToAdd.AsEnumerable()); } - + public void Register(IEnumerable mappingsToAdd) { foreach (var mapping in mappingsToAdd) { - Register(mapping.Build()); + Register(mapping.Build(primaryKeyHandlerRegistry)); } } @@ -50,14 +52,14 @@ public bool ResolveOptional(Type type, out DocumentMap map) // Walk up the inheritance chain and make sure there's only one map for the document. var currentType = type; - + while (true) { if (mappings.TryGetValue(currentType, out var m)) { maps.Add(m); } - + currentType = currentType.GetTypeInfo().BaseType; if (currentType == typeof(object) || currentType == null) break; @@ -65,7 +67,7 @@ public bool ResolveOptional(Type type, out DocumentMap map) if (maps.Count > 1) throw new InvalidOperationException($"More than one document map is registered against the type '{type.FullName}'. The following maps could apply: " + string.Join(", ", maps.Select(m => m.GetType().FullName))); - + map = maps.SingleOrDefault(); return map != null; } @@ -87,11 +89,11 @@ public DocumentMap Resolve(Type type) { throw NotRegistered(type); } - + return mapping; } - - public object GetId(object instance) + + public object? GetId(object instance) { if (instance == null) throw new ArgumentNullException(nameof(instance)); @@ -102,7 +104,7 @@ public object GetId(object instance) return map.GetId(instance); } - + static Exception NotRegistered(Type type) { return new InvalidOperationException($"To be used for this operation, the class '{type.FullName}' must have a document map that is registered with this relational store. Types without a document map cannot be used for this operation."); diff --git a/source/Nevermore/Mapping/GuidPrimaryKeyHandler.cs b/source/Nevermore/Mapping/GuidPrimaryKeyHandler.cs new file mode 100644 index 00000000..9d329d3d --- /dev/null +++ b/source/Nevermore/Mapping/GuidPrimaryKeyHandler.cs @@ -0,0 +1,17 @@ +using System; +using System.Data; +using Microsoft.Data.SqlClient.Server; + +namespace Nevermore.Mapping +{ + public sealed class GuidPrimaryKeyHandler : PrimaryKeyHandler + { + public override SqlMetaData GetSqlMetaData(string name) + => new SqlMetaData(name, SqlDbType.UniqueIdentifier); + + public override object GetNextKey(IKeyAllocator keyAllocator, string tableName) + { + return Guid.NewGuid(); + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/IDocumentMap.cs b/source/Nevermore/Mapping/IDocumentMap.cs index f5d3552a..d55c9f4b 100644 --- a/source/Nevermore/Mapping/IDocumentMap.cs +++ b/source/Nevermore/Mapping/IDocumentMap.cs @@ -2,6 +2,6 @@ namespace Nevermore.Mapping { public interface IDocumentMap { - DocumentMap Build(); + DocumentMap Build(IPrimaryKeyHandlerRegistry primaryKeyHandlerRegistry); } } \ No newline at end of file diff --git a/source/Nevermore/Mapping/IIdColumnMappingBuilder.cs b/source/Nevermore/Mapping/IIdColumnMappingBuilder.cs new file mode 100644 index 00000000..806b0326 --- /dev/null +++ b/source/Nevermore/Mapping/IIdColumnMappingBuilder.cs @@ -0,0 +1,27 @@ +using System; + +namespace Nevermore.Mapping +{ + public interface IIdColumnMappingBuilder : IColumnMappingBuilder + { + /// + /// Nevermore will treat this Id as an IDENTITY column, and will update the document with the Id assigned by the database after insert. + /// + /// This will also reset the PropertyHandler + IIdColumnMappingBuilder Identity(); + + /// + /// Explicitly set a primary key handler. + /// + /// The primary key handler. + /// + IIdColumnMappingBuilder KeyHandler(IPrimaryKeyHandler primaryKeyHandler); + + /// + /// Builds the IdColumnMapping. + /// + /// The primary key handler registry, which must be populated with the required handler prior to the DocumentMaps being registered. + /// The built column mapping. + IdColumnMapping Build(IPrimaryKeyHandlerRegistry primaryKeyHandlerRegistry); + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/IPrimaryKeyHandler.cs b/source/Nevermore/Mapping/IPrimaryKeyHandler.cs new file mode 100644 index 00000000..32f54db9 --- /dev/null +++ b/source/Nevermore/Mapping/IPrimaryKeyHandler.cs @@ -0,0 +1,38 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Data.SqlClient.Server; + +namespace Nevermore.Mapping +{ + public interface IPrimaryKeyHandler + { + /// + /// The property type this handler is responsible for. + /// + Type Type { get; } + + /// + /// Gets the SqlMetaData to use for keys of this type. This is used for TableValueParameters. + /// + /// The name of the column. + SqlMetaData GetSqlMetaData(string name); + + /// + /// Convert the given id value to the underlying primitive type required for Sql command parameters. + /// + /// The id to convert. + /// The converted id. + [return: NotNullIfNotNull("id")] + object? ConvertToPrimitiveValue(object? id); + + /// + /// Get the next key for the given table. + /// + /// The keyAllocator has to be passed here as it is tied to the RelationalStore instance, and thus can't be specified at configuration time in the constructor. + /// The key allocator to use getting the next id from. + /// The table name the key is required for. + /// The next key, as the type that matches the model object's Id property type. ConvertToPrimitiveValue should be called with this value if it is to be used as a Sql parameter. + object GetNextKey(IKeyAllocator keyAllocator, string tableName); + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/IdColumnMapping.cs b/source/Nevermore/Mapping/IdColumnMapping.cs new file mode 100644 index 00000000..a396efcd --- /dev/null +++ b/source/Nevermore/Mapping/IdColumnMapping.cs @@ -0,0 +1,91 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Nevermore.Mapping +{ + public class IdColumnMapping : ColumnMapping + { + internal IdColumnMapping(IdColumnMappingBuilder idColumn, IPrimaryKeyHandler primaryKeyHandler) + : base(idColumn.ColumnName, idColumn.Type, idColumn.PropertyHandler, idColumn.Property) + { + IsIdentity = idColumn.IsIdentity; + PrimaryKeyHandler = primaryKeyHandler; + Direction = idColumn.Direction; + MaxLength = idColumn.MaxLength; + } + + public bool IsIdentity { get; } + + public IPrimaryKeyHandler PrimaryKeyHandler { get; } + } + + public class IdColumnMappingBuilder : ColumnMapping, IIdColumnMappingBuilder + { + static readonly HashSet ValidIdentityTypes = new HashSet + { + typeof(short), + typeof(int), + typeof(long) + }; + + bool hasCustomPropertyHandler; + + internal IdColumnMappingBuilder(string columnName, Type type, IPropertyHandler handler, PropertyInfo property) : base(columnName, type, handler, property) + { + } + + public bool IsIdentity { get; private set; } + + IPrimaryKeyHandler? PrimaryKeyHandler { get; set; } + + /// + public IIdColumnMappingBuilder Identity() + { + ValidateForIdentityUse(); + + IsIdentity = true; + Direction = ColumnDirection.FromDatabase; + + return this; + } + + public IIdColumnMappingBuilder KeyHandler(IPrimaryKeyHandler primaryKeyHandler) + { + PrimaryKeyHandler = primaryKeyHandler; + return this; + } + + void ValidateForIdentityUse() + { + if (!ValidIdentityTypes.Contains(Type)) + throw new InvalidOperationException($"The type {Type.Name} is not supported for Identity columns. Identity columns must be one of 'short', 'int' or 'long'."); + + if (hasCustomPropertyHandler) + throw new InvalidOperationException("Unable to configure an Identity Id column with a custom PropertyHandler"); + } + + protected override void SetCustomPropertyHandler(IPropertyHandler propertyHandler) + { + if (Direction == ColumnDirection.FromDatabase) + throw new InvalidOperationException("Unable to configure an Identity Id column with a custom PropertyHandler"); + + hasCustomPropertyHandler = true; + base.SetCustomPropertyHandler(propertyHandler); + } + + public IdColumnMapping Build(IPrimaryKeyHandlerRegistry primaryKeyHandlerRegistry) + { + var primaryKeyHandler = PrimaryKeyHandler; + if (primaryKeyHandler is null) + primaryKeyHandler = primaryKeyHandlerRegistry.Resolve(Type); + + if (primaryKeyHandler is null) + throw new InvalidOperationException($"Unable to determine a primary key handler for type {Type.Name}. This could happen if the custom PrimaryKeyHandlers are not registered prior to registering the DocumentMaps"); + + var mapping = new IdColumnMapping(this, primaryKeyHandler); + return mapping; + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/IntPrimaryKeyHandler.cs b/source/Nevermore/Mapping/IntPrimaryKeyHandler.cs new file mode 100644 index 00000000..218234bf --- /dev/null +++ b/source/Nevermore/Mapping/IntPrimaryKeyHandler.cs @@ -0,0 +1,16 @@ +using System.Data; +using Microsoft.Data.SqlClient.Server; + +namespace Nevermore.Mapping +{ + public sealed class IntPrimaryKeyHandler : PrimaryKeyHandler + { + public override SqlMetaData GetSqlMetaData(string name) + => new SqlMetaData(name, SqlDbType.Int); + + public override object GetNextKey(IKeyAllocator keyAllocator, string tableName) + { + return keyAllocator.NextId(tableName); + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/LongPrimaryKeyHandler.cs b/source/Nevermore/Mapping/LongPrimaryKeyHandler.cs new file mode 100644 index 00000000..91fc04d6 --- /dev/null +++ b/source/Nevermore/Mapping/LongPrimaryKeyHandler.cs @@ -0,0 +1,16 @@ +using System.Data; +using Microsoft.Data.SqlClient.Server; + +namespace Nevermore.Mapping +{ + public sealed class LongPrimaryKeyHandler : PrimaryKeyHandler + { + public override SqlMetaData GetSqlMetaData(string name) + => new SqlMetaData(name, SqlDbType.BigInt); + + public override object GetNextKey(IKeyAllocator keyAllocator, string tableName) + { + return keyAllocator.NextId(tableName); + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/PrimaryKeyHandlerRegistry.cs b/source/Nevermore/Mapping/PrimaryKeyHandlerRegistry.cs new file mode 100644 index 00000000..32b38345 --- /dev/null +++ b/source/Nevermore/Mapping/PrimaryKeyHandlerRegistry.cs @@ -0,0 +1,68 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Nevermore.Mapping +{ + public interface IPrimaryKeyHandlerRegistry + { + /// + /// Registers a mapping of key type to PrimaryKeyHandler type. StringPrimaryKeyHandler, IntPrimaryKeyHandler, LongPrimaryKeyHandler, and GuidPrimaryKeyHandler + /// are registered by default, with default settings. Calling register again for them will overwrite the default, you can use this if you want to override a + /// global default. + /// + void Register(IPrimaryKeyHandler handler); + + IPrimaryKeyHandler? Resolve(Type columnType); + } + + class PrimaryKeyHandlerRegistry : IPrimaryKeyHandlerRegistry + { + // maps key type to IPrimaryKeyHandler concrete type + readonly ConcurrentDictionary mappings = new ConcurrentDictionary(); + + public PrimaryKeyHandlerRegistry() + { + mappings.TryAdd(typeof(string), new StringPrimaryKeyHandler()); + mappings.TryAdd(typeof(int), new IntPrimaryKeyHandler()); + mappings.TryAdd(typeof(long), new LongPrimaryKeyHandler()); + mappings.TryAdd(typeof(Guid), new GuidPrimaryKeyHandler()); + } + + public void Register(IPrimaryKeyHandler handler) + { + mappings[handler.Type] = handler; + } + + public IPrimaryKeyHandler? Resolve(Type columnType) + { + var idType = columnType; + + var maps = new List(); + + // Walk up the inheritance chain and make sure there's only one map for the document. + var currentType = idType; + + while (true) + { + if (mappings.TryGetValue(currentType, out var m)) + { + maps.Add(m); + } + + currentType = currentType.GetTypeInfo().BaseType; + if (currentType == typeof(object) || currentType == null) + break; + } + + if (maps.Count > 1) + throw new InvalidOperationException($"More than one key allocation strategy is registered against the type '{idType.FullName}'. The following maps could apply: " + string.Join(", ", maps.Select(m => m.GetType().FullName))); + + var strategy = maps.SingleOrDefault(); + return strategy; + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/PrimitivePrimaryKeyHandler.cs b/source/Nevermore/Mapping/PrimitivePrimaryKeyHandler.cs new file mode 100644 index 00000000..57e2eea6 --- /dev/null +++ b/source/Nevermore/Mapping/PrimitivePrimaryKeyHandler.cs @@ -0,0 +1,22 @@ +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Data.SqlClient.Server; + +namespace Nevermore.Mapping +{ + public abstract class PrimaryKeyHandler : IPrimaryKeyHandler + { + public Type Type => typeof(T); + + public abstract SqlMetaData GetSqlMetaData(string name); + + [return: NotNullIfNotNull("id")] + public virtual object? ConvertToPrimitiveValue(object? id) + { + return id; + } + + public abstract object GetNextKey(IKeyAllocator keyAllocator, string tableName); + } +} \ No newline at end of file diff --git a/source/Nevermore/Mapping/StringPrimaryKeyHandler.cs b/source/Nevermore/Mapping/StringPrimaryKeyHandler.cs new file mode 100644 index 00000000..36de3e8d --- /dev/null +++ b/source/Nevermore/Mapping/StringPrimaryKeyHandler.cs @@ -0,0 +1,29 @@ +#nullable enable +using System; +using System.Data; +using Microsoft.Data.SqlClient.Server; + +namespace Nevermore.Mapping +{ + public sealed class StringPrimaryKeyHandler : PrimaryKeyHandler + { + readonly Func<(string idPrefix, int key), string> format; + + public StringPrimaryKeyHandler(string? idPrefix = null, Func<(string idPrefix, int key), string>? format = null) + { + IdPrefix = idPrefix; + this.format = format ?? (x => $"{x.idPrefix}-{x.key}"); + } + + public override SqlMetaData GetSqlMetaData(string name) + => new SqlMetaData(name, SqlDbType.NVarChar, 300); + + public string? IdPrefix { get; private set; } + + public override object GetNextKey(IKeyAllocator keyAllocator, string tableName) + { + var nextKey = keyAllocator.NextId(tableName); + return format((IdPrefix ?? $"{tableName}s", nextKey)); + } + } +} \ No newline at end of file diff --git a/source/Nevermore/Nevermore.csproj b/source/Nevermore/Nevermore.csproj index af38f423..a41eb14b 100644 --- a/source/Nevermore/Nevermore.csproj +++ b/source/Nevermore/Nevermore.csproj @@ -27,7 +27,7 @@ true $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb netstandard2.1 - 8 + 9 CS1591 diff --git a/source/Nevermore/RelationalStoreConfiguration.cs b/source/Nevermore/RelationalStoreConfiguration.cs index c05a9620..97322d60 100644 --- a/source/Nevermore/RelationalStoreConfiguration.cs +++ b/source/Nevermore/RelationalStoreConfiguration.cs @@ -27,7 +27,6 @@ public RelationalStoreConfiguration(string connectionString) : this(() => connec public RelationalStoreConfiguration(Func connectionStringFunc) { CommandFactory = new SqlCommandFactory(); - DocumentMaps = new DocumentMapRegistry(); KeyBlockSize = NevermoreDefaults.DefaultKeyBlockSize; InstanceTypeResolvers = new InstanceTypeRegistry(); RelatedDocumentStore = new EmptyRelatedDocumentStore(); @@ -46,6 +45,10 @@ public RelationalStoreConfiguration(Func connectionStringFunc) TypeHandlers = new TypeHandlerRegistry(); + PrimaryKeyHandlers = new PrimaryKeyHandlerRegistry(); + + DocumentMaps = new DocumentMapRegistry(PrimaryKeyHandlers); + AllowSynchronousOperations = true; QueryLogger = new DefaultQueryLogger(); @@ -81,6 +84,8 @@ public RelationalStoreConfiguration(Func connectionStringFunc) public ITypeHandlerRegistry TypeHandlers { get; } public IInstanceTypeRegistry InstanceTypeResolvers { get; } + public IPrimaryKeyHandlerRegistry PrimaryKeyHandlers { get; } + /// /// MARS: https://docs.microsoft.com/en-us/sql/relational-databases/native-client/features/using-multiple-active-result-sets-mars?view=sql-server-ver15 /// diff --git a/source/Nevermore/Transient/DbCommandExtensions.cs b/source/Nevermore/Transient/DbCommandExtensions.cs index 6556b43f..4336f1d0 100644 --- a/source/Nevermore/Transient/DbCommandExtensions.cs +++ b/source/Nevermore/Transient/DbCommandExtensions.cs @@ -48,57 +48,45 @@ public static Task ExecuteNonQueryWithRetryAsync(this DbCommand command, Re public static DbDataReader ExecuteReaderWithRetry(this DbCommand command, RetryPolicy commandRetryPolicy, CommandBehavior behavior = CommandBehavior.Default, RetryPolicy connectionRetryPolicy = null, string operationName = "ExecuteReader") { - try + GuardConnectionIsNotNull(command); + var effectiveCommandRetryPolicy = (commandRetryPolicy ?? RetryPolicy.NoRetry).LoggingRetries(operationName); + return effectiveCommandRetryPolicy.ExecuteAction(() => { - GuardConnectionIsNotNull(command); - var effectiveCommandRetryPolicy = (commandRetryPolicy ?? RetryPolicy.NoRetry).LoggingRetries(operationName); - return effectiveCommandRetryPolicy.ExecuteAction(() => + var weOwnTheConnectionLifetime = EnsureValidConnection(command, connectionRetryPolicy); + try { - var weOwnTheConnectionLifetime = EnsureValidConnection(command, connectionRetryPolicy); - try - { - return command.ExecuteReader(behavior); - } - catch (Exception) - { - if (weOwnTheConnectionLifetime && command.Connection?.State == ConnectionState.Open) - command.Connection.Close(); - throw; - } - }); - } - catch (Exception ex) - { - throw new Exception($"Exception occurred while executing a reader for `{command.CommandText}`", ex); - } + return command.ExecuteReader(behavior); + } + catch (Exception) + { + if (weOwnTheConnectionLifetime && command.Connection != null && + command.Connection.State == ConnectionState.Open) + command.Connection.Close(); + throw; + } + }); } - - public static async Task ExecuteReaderWithRetryAsync(this DbCommand command, RetryPolicy commandRetryPolicy, CommandBehavior commandBehavior, RetryPolicy connectionRetryPolicy = null, string operationName = "ExecuteReader", CancellationToken cancellationToken = default) + + public static async Task ExecuteReaderWithRetryAsync(this DbCommand command, RetryPolicy commandRetryPolicy, CommandBehavior commandBehavior, CancellationToken cancellationToken, RetryPolicy connectionRetryPolicy = null, string operationName = "ExecuteReader") { - try + GuardConnectionIsNotNull(command); + var effectiveCommandRetryPolicy = + (commandRetryPolicy ?? RetryPolicy.NoRetry).LoggingRetries(operationName); + return await effectiveCommandRetryPolicy.ExecuteActionAsync(async () => { - GuardConnectionIsNotNull(command); - var effectiveCommandRetryPolicy = - (commandRetryPolicy ?? RetryPolicy.NoRetry).LoggingRetries(operationName); - return await effectiveCommandRetryPolicy.ExecuteActionAsync(async () => + var weOwnTheConnectionLifetime = await EnsureValidConnectionAsync(command, connectionRetryPolicy, cancellationToken); + try { - var weOwnTheConnectionLifetime = await EnsureValidConnectionAsync(command, connectionRetryPolicy, cancellationToken); - try - { - return await command.ExecuteReaderAsync(commandBehavior, cancellationToken); - } - catch (Exception) - { - if (weOwnTheConnectionLifetime && command.Connection?.State == ConnectionState.Open) - await command.Connection.CloseAsync(); - throw; - } - }); - } - catch (Exception ex) - { - throw new Exception($"Exception occurred while executing a reader for `{command.CommandText}`", ex); - } + return await command.ExecuteReaderAsync(commandBehavior, cancellationToken); + } + catch (Exception) + { + if (weOwnTheConnectionLifetime && command.Connection != null && + command.Connection.State == ConnectionState.Open) + await command.Connection.CloseAsync(); + throw; + } + }); } public static object ExecuteScalarWithRetry(this DbCommand command, RetryPolicy commandRetryPolicy, RetryPolicy connectionRetryPolicy = null, string operationName = "ExecuteScalar") @@ -119,7 +107,7 @@ public static object ExecuteScalarWithRetry(this DbCommand command, RetryPolicy } }); } - + public static async Task ExecuteScalarWithRetryAsync(this DbCommand command, RetryPolicy commandRetryPolicy, RetryPolicy connectionRetryPolicy = null, string operationName = "ExecuteScalar", CancellationToken cancellationToken = default) { GuardConnectionIsNotNull(command); @@ -160,7 +148,7 @@ static bool EnsureValidConnection(DbCommand command, RetryPolicy retryPolicy) command.Connection.OpenWithRetry(retryPolicy); return true; } - + static async Task EnsureValidConnectionAsync(DbCommand command, RetryPolicy retryPolicy, CancellationToken cancellationToken) { if (command == null) return false; diff --git a/source/Nevermore/Util/DataModificationQueryBuilder.cs b/source/Nevermore/Util/DataModificationQueryBuilder.cs index b9a166f6..d65ce099 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; @@ -39,6 +39,9 @@ public PreparedCommand PrepareInsert(IReadOnlyList documents, InsertOpti options ??= InsertOptions.Default; var mapping = GetMapping(documents); + if (mapping.IdColumn?.Direction == ColumnDirection.FromDatabase && options.CustomAssignedId != null) + throw new InvalidOperationException($"{nameof(InsertOptions)}.{nameof(InsertOptions.CustomAssignedId)} is not supported for identity Id columns."); + 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); @@ -77,8 +80,12 @@ public PreparedCommand PrepareUpdate(object document, UpdateOptions options = nu throw new ArgumentOutOfRangeException(); } - var rowVersionCheckStatement = mapping.IsRowVersioningEnabled ? $" AND [{mapping.RowVersionColumn.ColumnName}] = @{mapping.RowVersionColumn.ColumnName}" : string.Empty; - var returnRowVersionStatement = mapping.IsRowVersioningEnabled ? $" OUTPUT inserted.{mapping.RowVersionColumn.ColumnName}" : string.Empty; + var rowVersionCheckStatement = mapping.IsRowVersioningEnabled + ? $" AND [{mapping.RowVersionColumn.ColumnName}] = @{mapping.RowVersionColumn.ColumnName}" + : string.Empty; + var returnRowVersionStatement = mapping.IsRowVersioningEnabled + ? $" OUTPUT inserted.{mapping.RowVersionColumn.ColumnName}" + : string.Empty; var updates = string.Join(", ", updateStatements); @@ -125,11 +132,13 @@ private PreparedCommand PrepareDelete(DocumentMap mapping, object id, DeleteOpti var statement = new StringBuilder(); statement.AppendLine($"DELETE FROM [{actualSchemaName}].[{actualTableName}] WITH (ROWLOCK) WHERE [{mapping.IdColumn.ColumnName}] = @{IdVariableName}"); - foreach (var relMap in mapping.RelatedDocumentsMappings.Select(m => (tableName: m.TableName, schema: configuration.GetSchemaNameOrDefault(m), idColumnName: m.IdColumnName)).Distinct()) + foreach (var relMap in mapping.RelatedDocumentsMappings.Select(m => (tableName: m.TableName, + schema: configuration.GetSchemaNameOrDefault(m), idColumnName: m.IdColumnName)).Distinct()) statement.AppendLine($"DELETE FROM [{relMap.schema}].[{relMap.tableName}] WITH (ROWLOCK) WHERE [{relMap.idColumnName}] = @{IdVariableName}"); var parameters = new CommandParameterValues {{IdVariableName, id}}; - return new PreparedCommand(statement.ToString(), parameters, RetriableOperation.Delete, mapping, options.CommandTimeout); + return new PreparedCommand(statement.ToString(), parameters, RetriableOperation.Delete, mapping, + options.CommandTimeout); } public PreparedCommand PrepareDelete(Type documentType, Where where, CommandParameterValues parameters, DeleteOptions options = null) @@ -145,7 +154,7 @@ public PreparedCommand PrepareDelete(DocumentMap mapping, Where where, CommandPa var actualSchemaName = options.SchemaName ?? configuration.GetSchemaNameOrDefault(mapping); if (!mapping.RelatedDocumentsMappings.Any()) - return new PreparedCommand($"DELETE FROM [{actualSchemaName}].[{actualTableName}]{options.Hint??""} {where.GenerateSql()}", parameters, RetriableOperation.Delete, mapping, options.CommandTimeout); + return new PreparedCommand($"DELETE FROM [{actualSchemaName}].[{actualTableName}]{options.Hint ?? ""} {where.GenerateSql()}", parameters, RetriableOperation.Delete, mapping, options.CommandTimeout); var statement = new StringBuilder(); statement.AppendLine("DECLARE @Ids as TABLE (Id nvarchar(400))"); @@ -180,7 +189,7 @@ void AppendInsertStatement(StringBuilder sb, DocumentMap mapping, string tableNa { var columns = new List(); - if (includeDefaultModelColumns) + if (includeDefaultModelColumns && !(mapping.IdColumn is null) && !mapping.IsIdentityId) columns.Add(mapping.IdColumn.ColumnName); columns.AddRange(mapping.WritableIndexedColumns().Select(c => c.ColumnName)); @@ -208,9 +217,29 @@ void AppendInsertStatement(StringBuilder sb, DocumentMap mapping, string tableNa var actualTableName = tableName ?? mapping.TableName; var actualSchemaName = schemaName ?? configuration.GetSchemaNameOrDefault(mapping); - var returnRowVersionStatement = mapping.IsRowVersioningEnabled ? $" OUTPUT inserted.{mapping.RowVersionColumn.ColumnName}" : string.Empty; + //do we have any + string outputStatement = null; + string outputVariable = null; + string outputSelect = null; + if (mapping.HasModificationOutputs) + { + var outputColumns = new Dictionary(); + + if (mapping.IsRowVersioningEnabled) + outputColumns.Add(mapping.RowVersionColumn.ColumnName, "binary(8)"); + + if (mapping.IsIdentityId) + outputColumns.Add(mapping.IdColumn.ColumnName, mapping.IdColumn.Type.GetIdentityIdTypeName()); - sb.AppendLine($"INSERT INTO [{actualSchemaName}].[{actualTableName}] {tableHint} ({columnNames}){returnRowVersionStatement} VALUES "); + outputStatement = $"OUTPUT {string.Join(",", outputColumns.Select(kvp => $"inserted.[{kvp.Key}]"))} INTO @InsertedRows"; + outputVariable = $"DECLARE @InsertedRows TABLE ({string.Join(", ", outputColumns.Select(kvp => $"[{kvp.Key}] {kvp.Value}"))})"; + outputSelect = $"SELECT {string.Join(",", outputColumns.Select(kvp => $"[{kvp.Key}]"))} FROM @InsertedRows"; + } + + if (outputVariable != null) + sb.AppendLine(outputVariable); + + sb.AppendLine($"INSERT INTO [{actualSchemaName}].[{actualTableName}] {tableHint} ({columnNames}) {outputStatement} VALUES "); void Append(string prefix) { @@ -221,19 +250,23 @@ void Append(string prefix) if (numberOfInstances == 1) { Append(""); - return; } - - for (var x = 0; x < numberOfInstances; x++) + else { - if (x > 0) - sb.Append(","); + for (var x = 0; x < numberOfInstances; x++) + { + if (x > 0) + sb.Append(","); - Append($"{x}__"); + Append($"{x}__"); + } } + + if (outputSelect != null) + sb.AppendLine(outputSelect); } - 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 +293,36 @@ 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) { + if (mapping.IdColumn is null) + throw new InvalidOperationException($"Map for {mapping.Type.Name} do not specify an Id column"); + 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 && customAssignedId != id) + customAssignedId != null && id != null && !customAssignedId.Equals(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)) + var result = new CommandParameterValues(); + + // we never want to allocate id's if the Id column is an Identity + if (!mapping.IdColumn.IsIdentity) { - id = string.IsNullOrWhiteSpace(customAssignedId as string) ? allocateId(mapping) : customAssignedId; - mapping.IdColumn.PropertyHandler.Write(document, id); + // check whether the object's Id has already been provided, if not then we'll either use the one from the InsertOptions or we'll generate one + if (id == null) + { + id = customAssignedId == null || (customAssignedId is string assignedId && string.IsNullOrWhiteSpace(assignedId)) ? allocateId(mapping) : customAssignedId; + mapping.IdColumn.PropertyHandler.Write(document, id); + } } - var result = new CommandParameterValues - { - [$"{prefix}{mapping.IdColumn.ColumnName}"] = id - }; + var keyHandler = mapping.IdColumn.PrimaryKeyHandler; + var primitiveValue = keyHandler.ConvertToPrimitiveValue(id); + result[$"{prefix}{mapping.IdColumn.ColumnName}"] = primitiveValue; switch (mapping.JsonStorageFormat) { @@ -420,7 +465,7 @@ IReadOnlyList GetRelatedDocumentTableData(DocumentMap : documents.Select((i, idx) => (idVariable: $"{idx}__{IdVariableName}", document: i)); var groupedByTable = from m in mapping.RelatedDocumentsMappings - group m by new { Table = m.TableName, Schema = configuration.GetSchemaNameOrDefault(m) } + group m by new {Table = m.TableName, Schema = configuration.GetSchemaNameOrDefault(m)} into g let related = ( from m in g @@ -457,7 +502,19 @@ class RelatedDocumentTableData internal static class DataModificationQueryBuilderExtensions { + static readonly Dictionary IdentityIdTypeMap = new Dictionary + { + [typeof(short)] = "smallint", + [typeof(int)] = "int", + [typeof(long)] = "bigint" + }; + public static IEnumerable WritableIndexedColumns(this DocumentMap doc) => doc.Columns.Where(c => c.Direction == ColumnDirection.Both || c.Direction == ColumnDirection.ToDatabase); + + public static string GetIdentityIdTypeName(this Type type) + { + return IdentityIdTypeMap[type]; + } } } \ No newline at end of file