diff --git a/src/Hrimsoft.SqlBulk.PostgreSql/Extensions/PropertyProfileExtensions.cs b/src/Hrimsoft.SqlBulk.PostgreSql/Extensions/PropertyProfileExtensions.cs index 303900b..7d1f0e8 100644 --- a/src/Hrimsoft.SqlBulk.PostgreSql/Extensions/PropertyProfileExtensions.cs +++ b/src/Hrimsoft.SqlBulk.PostgreSql/Extensions/PropertyProfileExtensions.cs @@ -11,6 +11,26 @@ namespace Hrimsoft.SqlBulk.PostgreSql /// public static class PropertyProfileExtensions { + /// + /// Will the property value getter execute dynamic invoke or not. + /// + /// Returns True if it will use dynamic invoke, otherwise returns False. + public static bool IsDynamicallyInvoked(this PropertyProfile profile) + { + switch (profile.DbColumnType) + { + case NpgsqlDbType.Bigint: + case NpgsqlDbType.Integer: + case NpgsqlDbType.Real: + case NpgsqlDbType.Double: + case NpgsqlDbType.Numeric: + case NpgsqlDbType.Boolean: + case NpgsqlDbType.Smallint: + return false; + } + return true; + } + /// Calculates property values of item /// item with values /// information about which property it is needed to get value diff --git a/src/Hrimsoft.SqlBulk.PostgreSql/IoC/BulkServiceExtensions.cs b/src/Hrimsoft.SqlBulk.PostgreSql/IoC/BulkServiceExtensions.cs index 6dc3857..9acb364 100644 --- a/src/Hrimsoft.SqlBulk.PostgreSql/IoC/BulkServiceExtensions.cs +++ b/src/Hrimsoft.SqlBulk.PostgreSql/IoC/BulkServiceExtensions.cs @@ -27,7 +27,9 @@ public static IServiceCollection AddPostgreSqlBulkService(this IServiceCollectio services.AddSingleton(options); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); services.AddTransient(); services.AddTransient( diff --git a/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/DeleteSqlCommandMediator.cs b/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/DeleteSqlCommandMediator.cs new file mode 100644 index 0000000..317fa2b --- /dev/null +++ b/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/DeleteSqlCommandMediator.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Logging; + +namespace Hrimsoft.SqlBulk.PostgreSql +{ + /// + /// Mediator selects the best sql-command builder for the given entity type + /// + public class DeleteSqlCommandMediator : IDeleteSqlCommandBuilder + { + private readonly SimpleDeleteSqlCommandBuilder _simpleBuilder; + private readonly WhereInDeleteSqlCommandBuilder _whereInBuilder; + private readonly ILogger _logger; + + public DeleteSqlCommandMediator(SimpleDeleteSqlCommandBuilder simpleBuilder, + WhereInDeleteSqlCommandBuilder whereInBuilder, + ILogger logger) + { + _simpleBuilder = simpleBuilder; + _whereInBuilder = whereInBuilder; + _logger = logger; + } + + /// + public IList Generate(ICollection elements, EntityProfile entityProfile, CancellationToken cancellationToken) where TEntity : class + { + if (elements == null) + throw new ArgumentNullException(nameof(elements)); + if (entityProfile == null) + throw new ArgumentNullException(nameof(entityProfile)); + + if (elements.Count == 0) + throw new ArgumentException("There is no elements in the collection. At least one element must be.", nameof(elements)); + + var privateKeys = entityProfile.Properties + .Values + .Where(x => x.IsPrivateKey) + .ToList(); + if (privateKeys.Count == 0) + throw new ArgumentException($"Entity {entityProfile.EntityType.FullName} must have at least one private key.", + nameof(entityProfile)); + if (privateKeys.Count > 1) + { + _logger.LogDebug($"Simple sql-delete command builder has been selected as there are more than one private keys in entity {entityProfile.EntityType.FullName}"); + return _simpleBuilder.Generate(elements, entityProfile, cancellationToken); + } + _logger.LogDebug($"WhereIn sql-delete command builder has been selected as there is only one private key in entity {entityProfile.EntityType.FullName}"); + return _whereInBuilder.Generate(elements, entityProfile, cancellationToken); + } + } +} \ No newline at end of file diff --git a/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/SimpleDeleteSqlCommandBuilder.cs b/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/SimpleDeleteSqlCommandBuilder.cs index 28e559e..5f400d4 100644 --- a/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/SimpleDeleteSqlCommandBuilder.cs +++ b/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/SimpleDeleteSqlCommandBuilder.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Text; using System.Threading; -using JetBrains.Annotations; using Microsoft.Extensions.Logging; using Npgsql; @@ -15,6 +14,8 @@ public class SimpleDeleteSqlCommandBuilder : IDeleteSqlCommandBuilder { private readonly ILogger _logger; + private const int MAX_PARAMS_PER_CMD = 65_535; + /// public SimpleDeleteSqlCommandBuilder(ILoggerFactory loggerFactory) { @@ -42,8 +43,9 @@ public IList Generate(ICollection ele _logger.LogTrace($"Generating delete sql for {elements.Count} elements."); - var result = ""; - var allItemsParameters = new List(); + var result = new List(); + var resultCommand = ""; + var parameters = new List(); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -66,17 +68,13 @@ public IList Generate(ICollection ele if (thereIsMoreElements) { allElementsAreNull = false; - var (commandForOneItem, itemParameters) - = GenerateForItem(entityProfile, elementsEnumerator.Current, null, elementIndex); - allItemsParameters.AddRange(itemParameters); - - var entireCommandLength = commandForOneItem.Length * elements.Count; - if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug($"approximate entire command length: {entireCommandLength}"); + var (commandForOneItem, itemParameters) = GenerateForItem(entityProfile, elementsEnumerator.Current, null, elementIndex); + parameters.AddRange(itemParameters); - var resultBuilder = new StringBuilder(entireCommandLength); - resultBuilder.AppendLine(commandForOneItem); + var paramsPerElement = itemParameters.Count; + var commandBuilder = new StringBuilder(commandForOneItem.Length * elements.Count - elementIndex); + commandBuilder.AppendLine(commandForOneItem); while (elementsEnumerator.MoveNext()) { @@ -84,30 +82,35 @@ public IList Generate(ICollection ele // ignore all null items if (elementsEnumerator.Current == null) continue; - (commandForOneItem, itemParameters) - = GenerateForItem(entityProfile, elementsEnumerator.Current, resultBuilder, elementIndex); - - allItemsParameters.AddRange(itemParameters); - resultBuilder.AppendLine(commandForOneItem); + if (parameters.Count + paramsPerElement > MAX_PARAMS_PER_CMD) + { + result.Add(new SqlCommandBuilderResult + { + Command = commandBuilder.ToString(), + Parameters = parameters, + IsThereReturningClause = false + }); + commandBuilder.Clear(); + parameters.Clear(); + } + (commandForOneItem, itemParameters) = GenerateForItem(entityProfile, elementsEnumerator.Current, commandBuilder, elementIndex); + parameters.AddRange(itemParameters); + commandBuilder.AppendLine(commandForOneItem); } - result = resultBuilder.ToString(); + resultCommand = commandBuilder.ToString(); if (_logger.IsEnabled(LogLevel.Debug)) - _logger.LogDebug($"result command: {result}"); + _logger.LogDebug($"result command: {resultCommand}"); } } - if (allElementsAreNull) throw new ArgumentException($"There is no elements in the collection. At least one element must be.", nameof(elements)); - - return new List - { - new SqlCommandBuilderResult + result.Add(new SqlCommandBuilderResult { - Command = result, - Parameters = allItemsParameters, + Command = resultCommand, + Parameters = parameters, IsThereReturningClause = false - } - }; + }); + return result; } /// @@ -118,8 +121,11 @@ public IList Generate(ICollection ele /// Builder to which the generated for an item command will be appended /// As this method is called for each item, this value will be added to the sql parameter name /// Returns named tuple with generated command and list of db parameters. - public (string Command, ICollection Parameters) GenerateForItem(EntityProfile entityProfile, - TEntity item, StringBuilder externalBuilder, int elementIndex) + public (string Command, ICollection Parameters) + GenerateForItem(EntityProfile entityProfile, + TEntity item, + StringBuilder externalBuilder, + int elementIndex) where TEntity : class { if (item == null) diff --git a/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/WhereInDeleteSqlCommandBuilder.cs b/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/WhereInDeleteSqlCommandBuilder.cs new file mode 100644 index 0000000..5a55209 --- /dev/null +++ b/src/Hrimsoft.SqlBulk.PostgreSql/SqlCommandBuilders/Delete/WhereInDeleteSqlCommandBuilder.cs @@ -0,0 +1,148 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace Hrimsoft.SqlBulk.PostgreSql +{ + /// + /// Generates bulk delete sql command with where clause using in operand + /// + public class WhereInDeleteSqlCommandBuilder : IDeleteSqlCommandBuilder + { + private readonly ILogger _logger; + + private const int MAX_IN_CLAUSE_IDS = 1000; + private const int MAX_PARAMS_PER_CMD = 65_535; + + /// + public WhereInDeleteSqlCommandBuilder(ILogger logger) + { + _logger = logger; + } + + /// Generates bulk delete sql command with where clause using in operand + /// + /// elements that have to be deleted + /// elements type profile (contains mapping and other options) + /// + /// Returns a text of an sql delete command and collection of database parameters + public IList Generate(ICollection elements, EntityProfile entityProfile, + CancellationToken cancellationToken) + where TEntity : class + { + if (elements == null) + throw new ArgumentNullException(nameof(elements)); + if (entityProfile == null) + throw new ArgumentNullException(nameof(entityProfile)); + + if (elements.Count == 0) + throw new ArgumentException($"There is no elements in the collection. At least one element must be.", nameof(elements)); + + var privateKeys = entityProfile.Properties + .Values + .Where(x => x.IsPrivateKey) + .ToList(); + if (privateKeys.Count == 0) + throw new ArgumentException($"Entity {entityProfile.EntityType.FullName} must have at least one private key.", + nameof(entityProfile)); + if (privateKeys.Count > 1) + throw new ArgumentException( + $"Cannot generate delete sql command with collapsed where clause as there are more than one private keys in the entity {entityProfile.EntityType.FullName}", + nameof(entityProfile)); + + _logger.LogTrace($"Generating delete sql for {elements.Count} elements."); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug($"{nameof(TEntity)}: {typeof(TEntity).FullName}"); + _logger.LogDebug($"{nameof(elements)}.Count: {elements.Count}"); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var commandHeader = $"delete from {entityProfile.TableName} where \"{privateKeys[0].DbColumnName}\" in ("; + + var result = new List(); + var cmdBuilder = new StringBuilder(); + var parameters = privateKeys[0].IsDynamicallyInvoked() + ? new List(Math.Min(elements.Count, MAX_PARAMS_PER_CMD)) + : null; + cmdBuilder.Append(commandHeader); + + var elementAbsIndex = -1; + var elementIndex = -1; + using (var elementsEnumerator = elements.GetEnumerator()) + { + while (elementsEnumerator.MoveNext()) + { + elementAbsIndex++; + if (elementsEnumerator.Current == null) + continue; + try + { + elementIndex++; + var whereDelimiter = elementIndex == 0 ? "" : ","; + if (parameters != null && parameters.Count + 1 > MAX_PARAMS_PER_CMD) + { + cmdBuilder.AppendLine(");"); + result.Add(new SqlCommandBuilderResult + { + Command = cmdBuilder.ToString(), + Parameters = parameters, + IsThereReturningClause = false + }); + cmdBuilder.Clear(); + cmdBuilder.Append(commandHeader); + parameters.Clear(); + whereDelimiter = ""; + } + else if (elementIndex == MAX_IN_CLAUSE_IDS + 1) + { + cmdBuilder.AppendLine(");"); + cmdBuilder.Append(commandHeader); + whereDelimiter = ""; + } + var propValue = privateKeys[0].GetPropertyValueAsString(elementsEnumerator.Current); + if (parameters != null) + { + var paramName = $"@param_{privateKeys[0].DbColumnName}_{elementIndex}"; + cmdBuilder.Append($"{whereDelimiter}{paramName}"); + var value = privateKeys[0].GetPropertyValue(elementsEnumerator.Current); + if (value == null) + throw new ArgumentException($"Private key must not be null. property: {privateKeys[0].DbColumnName}, item index: {elementAbsIndex}", + nameof(elements)); + parameters.Add(new NpgsqlParameter(paramName, privateKeys[0].DbColumnType) + { + Value = value + }); + } + else + { + cmdBuilder.Append($"{whereDelimiter}{propValue}"); + } + } + catch (Exception ex) + { + var message = $"an error occurred while calculating {privateKeys[0].DbColumnName} of item at index {elementAbsIndex}"; + throw new SqlGenerationException(SqlOperation.Delete, message, ex); + } + } + } + if (elementIndex == -1) + throw new ArgumentException($"There is no elements in the collection. At least one element must be.", nameof(elements)); + + cmdBuilder.AppendLine(");"); + result.Add(new SqlCommandBuilderResult + { + Command = cmdBuilder.ToString(), + Parameters = parameters ?? new List(), + IsThereReturningClause = false + }); + return result; + } + } +} \ No newline at end of file diff --git a/src/tests/Hrimsoft.SqlBulk.PostgreSql.IntegrationTests/BulkDelete/SimpleBulkDeleteIntegrationTests.cs b/src/tests/Hrimsoft.SqlBulk.PostgreSql.IntegrationTests/BulkDelete/SimpleBulkDeleteIntegrationTests.cs index e75c339..cea3d34 100644 --- a/src/tests/Hrimsoft.SqlBulk.PostgreSql.IntegrationTests/BulkDelete/SimpleBulkDeleteIntegrationTests.cs +++ b/src/tests/Hrimsoft.SqlBulk.PostgreSql.IntegrationTests/BulkDelete/SimpleBulkDeleteIntegrationTests.cs @@ -61,11 +61,9 @@ public async Task SimpleDelete_should_update_autogenerated_fields() new TestEntity {RecordId = "rec-01", SensorId = "sens-01", Value = 127}, new TestEntity {RecordId = "rec-02", SensorId = "sens-01", Value = 128} }; - using (var connection = new NpgsqlConnection(_configuration.ConnectionString)) - { - await _testService.InsertAsync(connection, elements, CancellationToken.None); - await _testService.DeleteAsync(connection, elements, CancellationToken.None); - } + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile, new[] {elements[0].Id, elements[1].Id}); Assert.AreEqual(0, countOfRows); @@ -81,11 +79,9 @@ public async Task SimpleDelete_should_split_correctly_even_number_of_elements() new TestEntity {RecordId = "rec-01", SensorId = "sens-02", Value = 227}, new TestEntity {RecordId = "rec-02", SensorId = "sens-02", Value = 228} }; - using (var connection = new NpgsqlConnection(_configuration.ConnectionString)) - { - await _testService.InsertAsync(connection, elements, CancellationToken.None); - await _testService.DeleteAsync(connection, elements, CancellationToken.None); - } + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile, new[] {elements[0].Id, elements[1].Id, elements[2].Id, elements[3].Id}); Assert.AreEqual(0, countOfRows); @@ -100,11 +96,9 @@ public async Task SimpleDelete_should_split_correctly_odd_number_of_elements() new TestEntity {RecordId = "rec-02", SensorId = "sens-01", Value = 128}, new TestEntity {RecordId = "rec-01", SensorId = "sens-02", Value = 227}, }; - using (var connection = new NpgsqlConnection(_configuration.ConnectionString)) - { - await _testService.InsertAsync(connection, elements, CancellationToken.None); - await _testService.DeleteAsync(connection, elements, CancellationToken.None); - } + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile, new[] {elements[0].Id, elements[1].Id, elements[2].Id}); Assert.AreEqual(0, countOfRows); @@ -150,5 +144,23 @@ public async Task SimpleDelete_should_delete_elements_with_multiple_pk() var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile, new[] {elements[0].Id, elements[1].Id, elements[2].Id}); Assert.AreEqual(0, countOfRows); } + + [Test] + public async Task SimpleDelete_should_delete_more_than_65K_elements() + { + const int MAX_ELEMENTS_COUNT = 70_000; + + var elements = new List(); + for (var i = 0; i < MAX_ELEMENTS_COUNT; i++) + { + elements.Add(new TestEntity {RecordId = $"rec-{i}", SensorId = $"sens-{i}", Value = 127+i}); + } + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); + + var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile,elements.Select(x => x.Id).ToArray()); + Assert.AreEqual(0, countOfRows); + } } } \ No newline at end of file diff --git a/src/tests/Hrimsoft.SqlBulk.PostgreSql.IntegrationTests/BulkDelete/WhereInDeleteIntegrationTests.cs b/src/tests/Hrimsoft.SqlBulk.PostgreSql.IntegrationTests/BulkDelete/WhereInDeleteIntegrationTests.cs new file mode 100644 index 0000000..38b807e --- /dev/null +++ b/src/tests/Hrimsoft.SqlBulk.PostgreSql.IntegrationTests/BulkDelete/WhereInDeleteIntegrationTests.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Hrimsoft.SqlBulk.PostgreSql.IntegrationTests.TestModels; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Npgsql; +using NUnit.Framework; + +namespace Hrimsoft.SqlBulk.PostgreSql.IntegrationTests.BulkDelete +{ + public class WhereInDeleteIntegrationTests + { + private readonly TestConfiguration _configuration; + private readonly TestUtils _testUtils; + + private SimpleEntityProfile _entityProfile; + private NpgsqlCommandsBulkService _testService; + + public WhereInDeleteIntegrationTests() + { + _configuration = new TestConfiguration(); + _testUtils = new TestUtils(_configuration); + } + + [SetUp] + public async Task SetUp() + { + var truncateTableCmd = "truncate \"unit_tests\".\"simple_test_entity\";"; + var resetIdSequenceCmd = "ALTER SEQUENCE \"unit_tests\".\"simple_test_entity_id_seq\" RESTART WITH 1;"; + + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await using var command = new NpgsqlCommand($"{truncateTableCmd}{resetIdSequenceCmd}", connection); + await connection.OpenAsync(); + await command.ExecuteNonQueryAsync(); + + var bulkServiceOptions = new BulkServiceOptions(); + _entityProfile = new SimpleEntityProfile(); + bulkServiceOptions.AddEntityProfile(_entityProfile); + + var insertCommandBuilder = new InsertSqlCommandBuilder(NullLoggerFactory.Instance); + var updateCommandBuilder = new Mock().Object; + var deleteCommandBuilder = new WhereInDeleteSqlCommandBuilder(NullLogger.Instance); + var upsertCommandBuilder = new Mock().Object; + + _testService = new NpgsqlCommandsBulkService( + bulkServiceOptions, + NullLoggerFactory.Instance, + insertCommandBuilder, + updateCommandBuilder, + deleteCommandBuilder, + upsertCommandBuilder); + } + + [Test] + public async Task Should_update_autogenerated_fields() + { + var elements = new List + { + new TestEntity {RecordId = "rec-01", SensorId = "sens-01", Value = 127}, + new TestEntity {RecordId = "rec-02", SensorId = "sens-01", Value = 128} + }; + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); + + var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile, new[] {elements[0].Id, elements[1].Id}); + Assert.AreEqual(0, countOfRows); + } + + [Test] + public async Task Should_split_correctly_even_number_of_elements() + { + var elements = new List + { + new TestEntity {RecordId = "rec-01", SensorId = "sens-01", Value = 127}, + new TestEntity {RecordId = "rec-02", SensorId = "sens-01", Value = 128}, + new TestEntity {RecordId = "rec-01", SensorId = "sens-02", Value = 227}, + new TestEntity {RecordId = "rec-02", SensorId = "sens-02", Value = 228} + }; + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); + + var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile, new[] {elements[0].Id, elements[1].Id, elements[2].Id, elements[3].Id}); + Assert.AreEqual(0, countOfRows); + } + + [Test] + public async Task Should_split_correctly_odd_number_of_elements() + { + var elements = new List + { + new TestEntity {RecordId = "rec-01", SensorId = "sens-01", Value = 127}, + new TestEntity {RecordId = "rec-02", SensorId = "sens-01", Value = 128}, + new TestEntity {RecordId = "rec-01", SensorId = "sens-02", Value = 227}, + }; + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); + + var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile, new[] {elements[0].Id, elements[1].Id, elements[2].Id}); + Assert.AreEqual(0, countOfRows); + } + + [Test] + public async Task Should_delete_elements_with_multiple_pk() + { + var bulkServiceOptions = new BulkServiceOptions(); + var profile = new EntityProfile(typeof(TestEntity)); + profile.HasProperty(x => x.Id) + .ThatIsPrivateKey() + .ThatIsAutoGenerated(); + profile.HasProperty(x => x.RecordId) + .ThatIsPrivateKey(); + profile.HasProperty(x => x.Value); + profile.ToTable("simple_test_entity", "unit_tests"); + bulkServiceOptions.AddEntityProfile(profile); + + var insertCommandBuilder = new InsertSqlCommandBuilder(NullLoggerFactory.Instance); + var updateCommandBuilder = new Mock().Object; + var deleteCommandBuilder = new WhereInDeleteSqlCommandBuilder(NullLogger.Instance); + var upsertCommandBuilder = new Mock().Object; + + _testService = new NpgsqlCommandsBulkService( + bulkServiceOptions, + NullLoggerFactory.Instance, + insertCommandBuilder, + updateCommandBuilder, + deleteCommandBuilder, + upsertCommandBuilder); + + var elements = new List + { + new TestEntity {RecordId = "rec-01", SensorId = "sens-01", Value = 127}, + new TestEntity {RecordId = "rec-02", SensorId = "sens-01", Value = 128}, + new TestEntity {RecordId = "rec-01", SensorId = "sens-02", Value = 227}, + }; + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + var ex = Assert.ThrowsAsync>(() => _testService.DeleteAsync(connection, elements, CancellationToken.None)); + Assert.IsTrue(ex.InnerException is ArgumentException); + } + + [Test] + public async Task Should_delete_more_than_65K_elements() + { + const int MAX_ELEMENTS_COUNT = 70_000; + + var elements = new List(); + for (var i = 0; i < MAX_ELEMENTS_COUNT; i++) + { + elements.Add(new TestEntity {RecordId = $"rec-{i}", SensorId = $"sens-{i}", Value = 127+i}); + } + await using var connection = new NpgsqlConnection(_configuration.ConnectionString); + await _testService.InsertAsync(connection, elements, CancellationToken.None); + await _testService.DeleteAsync(connection, elements, CancellationToken.None); + + var countOfRows = await _testUtils.HowManyRowsWithIdsAsync(_entityProfile,elements.Select(x => x.Id).ToArray()); + Assert.AreEqual(0, countOfRows); + } + } +} \ No newline at end of file diff --git a/src/tests/Hrimsoft.SqlBulk.PostgreSql.Tests/DeleteSqlCommandBuilderTests/WhereInDeleteSqlCommandBuilderTests.cs b/src/tests/Hrimsoft.SqlBulk.PostgreSql.Tests/DeleteSqlCommandBuilderTests/WhereInDeleteSqlCommandBuilderTests.cs new file mode 100644 index 0000000..46b3a02 --- /dev/null +++ b/src/tests/Hrimsoft.SqlBulk.PostgreSql.Tests/DeleteSqlCommandBuilderTests/WhereInDeleteSqlCommandBuilderTests.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using Hrimsoft.SqlBulk.PostgreSql.Tests.TestModels; +using Microsoft.Extensions.Logging.Abstractions; +using NUnit.Framework; + +namespace Hrimsoft.SqlBulk.PostgreSql.Tests +{ + public class WhereInDeleteSqlCommandBuilderTests + { + private WhereInDeleteSqlCommandBuilder _testService; + private const string DELETE_PURE_PATTERN = + @"(delete\s+from\s+(""\w+"".)?""\w+""\s+where\s+""\w+""\s*in\s*\(\d+\s*(,\s*\d+)*\s*\);)+"; + private const string DELETE_PARAM_PATTERN = + @"(delete\s+from\s+(""\w+"".)?""\w+""\s+where\s+""\w+""\s*in\s*\(@param_\w+_\d+\s*(,\s*@param_\w+_\d+)*\s*\);)+"; + + [SetUp] + public void SetUp() + { + _testService = new WhereInDeleteSqlCommandBuilder(NullLogger.Instance); + } + + [Test] + public void Should_match_delete_cmd_without_returning_clause() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.Id) + .ThatIsPrivateKey(); + entityProfile.HasProperty(x => x.RecordId); + entityProfile.HasProperty(x => x.SensorId); + entityProfile.ToTable("test_entity", "custom"); + + var elements = new List + { + new TestEntity{ Id=12, RecordId = "rec-01", SensorId = "sens-01", IntValue = 127 }, + }; + var commands = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(commands); + Assert.AreEqual(1, commands.Count); + var commandResult = commands.First(); + Assert.NotNull(commandResult.Command); + + Assert.IsTrue(Regex.IsMatch(commandResult.Command, DELETE_PURE_PATTERN, RegexOptions.IgnoreCase)); + } + + [Test] + public void Should_not_allow_generate_cmd_without_private_key() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.Id); + entityProfile.HasProperty(x => x.RecordId); + entityProfile.HasProperty(x => x.SensorId); + entityProfile.ToTable("test_entity", "custom"); + + var elements = new List + { + new TestEntity{ Id=12, RecordId = "rec-01", SensorId = "sens-01", IntValue = 127 }, + }; + Assert.Throws(() => _testService.Generate(elements, entityProfile, CancellationToken.None)); + } + + [Test] + public void Should_match_delete_cmd_for_entities_with_returning_clause() + { + var entityProfile = new ReturningEntityProfile(); + var elements = new List + { + new TestEntity{ Id=12, RecordId = "rec-01", SensorId = "sens-01", IntValue = 127 }, + }; + var commands = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(commands); + Assert.AreEqual(1, commands.Count); + var commandResult = commands.First(); + + Assert.NotNull(commandResult.Command); + + Assert.IsTrue(Regex.IsMatch(commandResult.Command, DELETE_PURE_PATTERN, RegexOptions.IgnoreCase)); + } + + [Test] + public void Should_match_delete_cmd_of_one_element() + { + var entityProfile = new ReturningEntityProfile(); + var elements = new List + { + new TestEntity{ RecordId = "rec-01", SensorId = "sens-01", IntValue = 127 }, + }; + var commands = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(commands); + Assert.AreEqual(1, commands.Count); + var commandResult = commands.First(); + Assert.NotNull(commandResult.Command); + + Assert.IsTrue(Regex.IsMatch(commandResult.Command, DELETE_PURE_PATTERN, RegexOptions.IgnoreCase)); + } + + [Test] + public void Should_match_delete_cmd_of_many_elements() + { + var entityProfile = new ReturningEntityProfile(); + var elements = new List + { + new TestEntity{ Id=1,RecordId = "rec-01", SensorId = "sens-01", IntValue = 127 }, + new TestEntity{ Id=2,RecordId = "rec-02", SensorId = "sens-01", IntValue = 128 }, + new TestEntity{ Id=3,RecordId = "rec-01", SensorId = "sens-02", IntValue = 227 }, + new TestEntity{ Id=4,RecordId = "rec-02", SensorId = "sens-02", IntValue = 228 } + }; + var commands = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(commands); + Assert.AreEqual(1, commands.Count); + var commandResult = commands.First(); + Assert.NotNull(commandResult.Command); + + Assert.IsTrue(Regex.IsMatch(commandResult.Command, DELETE_PURE_PATTERN, RegexOptions.IgnoreCase)); + } + + [Test] + public void Should_not_generate_cmd_for_entities_with_multiple_pk() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.Id) + .ThatIsPrivateKey(); + entityProfile.HasProperty(x => x.RecordId) + .ThatIsPrivateKey(); + var elements = new List + { + new TestEntity {Id = 12, RecordId = "rec-01", SensorId = "sen-0"} + }; + Assert.Throws(()=> _testService.Generate(elements, + entityProfile, + CancellationToken.None)); + } + + [Test] + public void Should_build_correct_id_property_name() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.IntValue); + entityProfile.HasProperty(x => x.RecordId) + .ThatIsPrivateKey(); + var elements = new List + { + new TestEntity {IntValue = 12, RecordId = "rec-01", SensorId = "sen-0"}, + new TestEntity {IntValue = 13, RecordId = "rec-02", SensorId = "sen-1"} + }; + var result = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(result); + var commandResult = result.FirstOrDefault(); + Assert.NotNull(commandResult); + + Assert.IsTrue(commandResult.Command.Contains("where \"record_id\" in")); + } + + [Test] + public void Should_build_params_for_string_id() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.IntValue); + entityProfile.HasProperty(x => x.RecordId) + .ThatIsPrivateKey(); + var elements = new List + { + new TestEntity {IntValue = 12, RecordId = "rec-01", SensorId = "sen-0"}, + new TestEntity {IntValue = 13, RecordId = "rec-02", SensorId = "sen-1"} + }; + var result = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(result); + var commandResult = result.FirstOrDefault(); + Assert.NotNull(commandResult); + Assert.NotNull(commandResult.Parameters); + Assert.AreEqual(2, commandResult.Parameters.Count); + + Assert.IsTrue(Regex.IsMatch(commandResult.Command, DELETE_PARAM_PATTERN, RegexOptions.IgnoreCase)); + } + + [Test] + public void Should_not_build_params_for_int_id() + { + var entityProfile = new SimpleEntityProfile(); + var elements = new List + { + new TestEntity {Id=10, RecordId = "rec-01", SensorId = "sens-02", IntValue = 12} + }; + var commands = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(commands); + Assert.AreEqual(1, commands.Count); + var commandResult = commands.First(); + Assert.NotNull(commandResult.Command); + Assert.NotNull(commandResult.Parameters); + Assert.IsEmpty(commandResult.Parameters); + } + + [Test] + public void Should_include_scheme_and_table_name() + { + var entityProfile = new SimpleEntityProfile(); + var elements = new List + { + new TestEntity {RecordId = "rec-01"} + }; + var commands = _testService.Generate(elements, entityProfile, CancellationToken.None); + Assert.NotNull(commands); + Assert.AreEqual(1, commands.Count); + var commandResult = commands.First(); + + Assert.NotNull(commandResult.Command); + var pattern = @"delete\s+from\s+""unit_tests"".""simple_test_entity""\s+"; + Assert.IsTrue(Regex.IsMatch(commandResult.Command, pattern, RegexOptions.IgnoreCase)); + } + + [Test] + public void Should_throw_exception_for_empty_elements() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.Id).ThatIsPrivateKey(); + entityProfile.ToTable("test_entity", "custom"); + var elements = new List(); + Assert.Throws(() => _testService.Generate(elements, entityProfile, CancellationToken.None)); + } + + [Test] + public void Should_throw_exception_when_elements_collection_items_are_all_null() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.Id).ThatIsPrivateKey(); + entityProfile.ToTable("test_entity", "custom"); + var elements = new List { null, null }; + Assert.Throws(() => _testService.Generate(elements, entityProfile, CancellationToken.None)); + } + + [Test] + public void Should_not_throw_exception_when_at_least_one_item_is_not_null() + { + var entityProfile = new EntityProfile(typeof(TestEntity)); + entityProfile.HasProperty(x => x.Id) + .ThatIsPrivateKey(); + entityProfile.ToTable("test_entity", "custom"); + var elements = new List + { + null, + new TestEntity {RecordId = "rec-01"}, + null + }; + Assert.DoesNotThrow(() => _testService.Generate(elements, entityProfile, CancellationToken.None)); + } + } +} \ No newline at end of file