From 858f7d112e02413d4b681c948ee8cce84519fab5 Mon Sep 17 00:00:00 2001 From: pengweiqhca Date: Wed, 4 Nov 2026 09:46:03 +0800 Subject: [PATCH 1/2] fix: accept child transactions below a parent transaction that reports the isolation level Unspecified Some providers do not report the isolation level of a transaction that was started without an explicit level. MySqlConnector returns IsolationLevel.Unspecified in this case. The nested transaction compared the requested level of a child transaction against that value, so every child transaction with an explicit isolation level failed with "The isolation level 'Unspecified' of the parent transaction is not compatible", although the root transaction ran with the default of the server. A parent that reports Unspecified is now treated as REPEATABLE READ, which is the default of MySQL and MariaDB. Child transactions that request REPEATABLE READ or a weaker level are accepted, stricter levels are still rejected. Parents that report a concrete level behave as before. --- .../Storage/NestedDbContextTransaction.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Thinktecture.EntityFrameworkCore.Relational/EntityFrameworkCore/Storage/NestedDbContextTransaction.cs b/src/Thinktecture.EntityFrameworkCore.Relational/EntityFrameworkCore/Storage/NestedDbContextTransaction.cs index 1fa4151..46c62c1 100644 --- a/src/Thinktecture.EntityFrameworkCore.Relational/EntityFrameworkCore/Storage/NestedDbContextTransaction.cs +++ b/src/Thinktecture.EntityFrameworkCore.Relational/EntityFrameworkCore/Storage/NestedDbContextTransaction.cs @@ -93,6 +93,11 @@ private void EnsureIsolationLevelCompatible(IsolationLevel isolationLevel) var underlyingTransaction = GetUnderlyingTransaction(); var currentIsolationLevel = underlyingTransaction.IsolationLevel; + // Some providers (for example MySqlConnector) report Unspecified for a transaction that runs with the default of the server. + // The default of MySQL and MariaDB is REPEATABLE READ. + if (currentIsolationLevel == IsolationLevel.Unspecified) + currentIsolationLevel = IsolationLevel.RepeatableRead; + switch (isolationLevel) { case IsolationLevel.Unspecified: From e8232d87c5c56f09e6b8b724c0957471945e0049 Mon Sep 17 00:00:00 2001 From: pengweiqhca Date: Wed, 4 Nov 2026 09:46:30 +0800 Subject: [PATCH 2/2] feat: add MySQL provider (Thinktecture.EntityFrameworkCore.MySql) The new package builds on Microting.EntityFrameworkCore.MySql, the Pomelo fork for EF Core 10, and brings the features of the other providers to MySQL and MariaDB: bulk insert, update and upsert, bulk insert and update from a query, temp tables, truncate, collection parameters, window functions, bitwise aggregates and nested transactions. It is registered with AddBulkOperationSupport, AddWindowFunctionsSupport and AddCollectionParameterSupport on the MySqlDbContextOptionsBuilder. Window functions require MySQL 8.0 (MariaDB 10.2) and collection parameters require MySQL 8.0.4 (MariaDB 10.6); the configured server version is validated and an InvalidOperationException is thrown otherwise. Table hints, tenant databases and the migration helpers are not available. Bulk insert writes multi-row INSERT statements (BatchSize, default 500, capped by the limit of 65,535 parameters; CommandTimeout) and creates the parameters through the type mapping of each column. Without an outer transaction the executor opens its own, so a failing batch leaves nothing behind. UseBulkCopy opts into MySqlBulkCopy (LOAD DATA LOCAL INFILE) and fails on warnings such as dropped duplicate keys. Owned types in separate tables are supported for insert. Bulk update stages the rows in a temp table and runs one UPDATE with a JOIN, or updates row by row inside a TransactionScope because MySQL rejects temp tables in XA transactions. Bulk upsert uses INSERT ... ON DUPLICATE KEY UPDATE. MySQL fires the clause for every unique index, so each assignment is guarded by the key properties: key columns are never updated and a row that collides with a different row on another unique index is skipped. The result is the number of processed input rows, because the affected-row counts of MySQL differ for inserted, updated and unchanged rows. MySQL 8.0.19 and later use the row alias instead of the deprecated VALUES(col). Truncate uses DELETE FROM inside a transaction (TRUNCATE commits implicitly) and for tables that are referenced by a foreign key (error 1701). Temp tables are connection-scoped and carry the character set and collation of the model for text columns only, because MySQL rejects them for json columns and fails joins with error 1267 otherwise. MySQL cannot reference a temp table twice in one statement (error 1137). Collection parameters are read with JSON_TABLE. MySQL parses JSON numbers as DOUBLE and rejects the .NET formats of TimeSpan and DateTime, so decimal, TimeSpan and DateTime are serialized as strings, and converters that EF Core infers from the provider type are applied. The SQL of window functions has no space before the opening parenthesis, ROW_NUMBER is cast to signed, and the bitwise aggregates cast to SIGNED and return NULL for a group without values. The tests run against a pinned mysql:8.4 container (Testcontainers) and use the shared entities of Thinktecture.EntityFrameworkCore.TestHelpers together with the three-context pattern and command capturing of the other providers. Known problems of the shared translator and of the Pomelo SQL helper are documented by skipped tests: the table name override of query-based updates is a textual replace, DateTime constants lose their fractional seconds in an UPDATE, and the schema of IBulkOperationTableNameProvider.ForRootEntity is ignored. README and CLAUDE.md describe the provider; the wiki and the agent skill are updated separately. --- .claude/CLAUDE.md | 66 +- Directory.Packages.props | 2 + README.md | 23 +- Thinktecture.EntityFrameworkCore.slnx | 2 + ...positeTempTableEntityPropertiesProvider.cs | 134 ++ .../MySqlBulkInsertFromQueryOptions.cs | 36 + .../BulkOperations/MySqlBulkInsertOptions.cs | 75 + .../MySqlBulkInsertOrUpdateOptions.cs | 81 + .../MySqlBulkOperationExecutor.cs | 937 ++++++++ .../MySqlBulkUpdateFromQueryOptions.cs | 36 + .../BulkOperations/MySqlBulkUpdateOptions.cs | 71 + .../MySqlTempTableBulkInsertOptions.cs | 95 + .../MySqlDbContextOptionsExtension.cs | 248 ++ .../MySqlDbContextOptionsExtensionOptions.cs | 40 + .../MySqlCollectionParameterConvention.cs | 54 + ...lCollectionParameterConventionSetPlugin.cs | 23 + .../MySqlCollectionParameterFactory.cs | 199 ++ ...ySqlAggregateMethodCallTranslatorPlugin.cs | 22 + ...SqlBitwiseAggregateMethodCallTranslator.cs | 66 + .../MySqlDbFunctionsTranslator.cs | 135 ++ .../MySqlMethodCallTranslatorPlugin.cs | 35 + ...nctionEvaluatableExpressionFilterPlugin.cs | 18 + ...ktectureMySqlParameterBasedSqlProcessor.cs | 37 + ...eMySqlParameterBasedSqlProcessorFactory.cs | 34 + .../ThinktectureMySqlQuerySqlGenerator.cs | 118 + ...inktectureMySqlQuerySqlGeneratorFactory.cs | 39 + ...yableMethodTranslatingExpressionVisitor.cs | 49 + ...thodTranslatingExpressionVisitorFactory.cs | 42 + ...hinktectureMySqlSqlNullabilityProcessor.cs | 42 + .../TempTables/MySqlTempTableCreator.cs | 194 ++ .../TempTables/MySqlTempTableReference.cs | 121 + .../MySqlAggregateDbFunctionsExtensions.cs | 44 + .../MySqlBulkOperationsDbSetExtensions.cs | 77 + .../MySqlDbContextOptionsBuilderExtensions.cs | 99 + .../Extensions/MySqlDbFunctionsExtensions.cs | 502 ++++ ...SqlWindowAggregateDbFunctionsExtensions.cs | 200 ++ ...nktecture.EntityFrameworkCore.MySql.csproj | 11 + .../BulkInsertAsync.cs | 2025 +++++++++++++++++ .../BulkInsertFromQueryAsync.cs | 969 ++++++++ .../BulkInsertIntoTempTableAsync.cs | 935 ++++++++ .../BulkInsertOrUpdateAsync.cs | 1612 +++++++++++++ .../BulkUpdateAsync.cs | 1359 +++++++++++ .../BulkUpdateFromQueryAsync.cs | 36 + .../BulkUpdateFromQueryAsyncTests.cs | 1602 +++++++++++++ .../MySqlBulkCopyTests.cs | 64 + .../MySqlBulkInsertOrUpdateTests.cs | 132 ++ .../MySqlBulkInsertTests.cs | 80 + .../MySqlBulkOperationBehaviorTests.cs | 243 ++ .../MySqlBulkOperationSemanticsTests.cs | 144 ++ .../MySqlBulkUpdateTests.cs | 107 + .../MySqlQueryBasedBulkIntegrationTests.cs | 157 ++ .../MySqlTableNameProviderTests.cs | 73 + .../MySqlTempTableBulkInsertTests.cs | 191 ++ .../MySqlTransactionScopeTests.cs | 89 + .../MySqlTruncateTableTests.cs | 107 + .../OwnedTypeBulkOperationTests.cs | 28 + .../QueryBasedBulkOperationsTests.cs | 94 + .../MySqlDbContextOptionsExtensionTests.cs | 87 + .../MySqlIntegrationTestsBase.cs | 126 + .../CollectionParameterExecutionTests.cs | 134 ++ .../Parameters/CollectionParameterTests.cs | 77 + .../Query/MySqlAggregateExecutionTests.cs | 101 + .../Query/MySqlAggregateTests.cs | 46 + .../Query/MySqlNtileTests.cs | 35 + .../MySqlQueryableMethodTranslationTests.cs | 28 + .../MySqlWindowFunctionExecutionTests.cs | 268 +++ .../Query/WindowFunctionTests.cs | 104 + .../Storage/MySqlNestedTransactionTests.cs | 86 + .../Storage/NestedTransactionTests.cs | 375 +++ .../CreateTempTableAsync.cs | 1437 ++++++++++++ .../ModelCharSetTests.cs | 66 + .../MySqlTempTableInspector.cs | 191 ++ .../PrimitiveValueTests.cs | 56 + .../TempTableQueryTests.cs | 38 + ...InsertValuesIntoTempTableAsync_1_Column.cs | 1061 +++++++++ ...ollectionParameterJsonSerializerOptions.cs | 19 + .../CreateComplexCollectionParameter.cs | 314 +++ .../CreateScalarCollectionParameter.cs | 560 +++++ .../TempTable_DateOnly_Repro.cs | 116 + .../AverageWindowFunction.cs | 410 ++++ .../MaxWindowFunction.cs | 461 ++++ .../MinWindowFunction.cs | 461 ++++ .../RowNumber.cs | 464 ++++ .../SumWindowFunction.cs | 458 ++++ .../WindowFunctionTests.cs | 289 +++ .../Json/ConvertibleClassConverter.cs | 18 + .../MySqlContainerFixture.cs | 55 + .../MySqlTestDbContext.cs | 251 ++ .../TestDatabaseContext/MyParameter.cs | 3 + ...ure.EntityFrameworkCore.MySql.Tests.csproj | 17 + 90 files changed, 22239 insertions(+), 27 deletions(-) create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/CompositeTempTableEntityPropertiesProvider.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertFromQueryOptions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOptions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOrUpdateOptions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutor.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateFromQueryOptions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateOptions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlTempTableBulkInsertOptions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtension.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionOptions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConvention.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConventionSetPlugin.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterFactory.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlAggregateMethodCallTranslatorPlugin.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlBitwiseAggregateMethodCallTranslator.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlDbFunctionsTranslator.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlMethodCallTranslatorPlugin.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/MySqlWindowFunctionEvaluatableExpressionFilterPlugin.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessor.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessorFactory.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGenerator.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGeneratorFactory.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitorFactory.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlSqlNullabilityProcessor.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableCreator.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableReference.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlAggregateDbFunctionsExtensions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlBulkOperationsDbSetExtensions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbContextOptionsBuilderExtensions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlWindowAggregateDbFunctionsExtensions.cs create mode 100644 src/Thinktecture.EntityFrameworkCore.MySql/Thinktecture.EntityFrameworkCore.MySql.csproj create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertAsync.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertFromQueryAsync.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertIntoTempTableAsync.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertOrUpdateAsync.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateAsync.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsync.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsyncTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkCopyTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertOrUpdateTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationBehaviorTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationSemanticsTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkUpdateTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlQueryBasedBulkIntegrationTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTableNameProviderTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTempTableBulkInsertTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTransactionScopeTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTruncateTableTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/OwnedTypeBulkOperationTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/QueryBasedBulkOperationsTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/MySqlIntegrationTestsBase.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterExecutionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateExecutionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlNtileTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlQueryableMethodTranslationTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlWindowFunctionExecutionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/WindowFunctionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/MySqlNestedTransactionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/NestedTransactionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/CreateTempTableAsync.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/ModelCharSetTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/MySqlTempTableInspector.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/PrimitiveValueTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/TempTableQueryTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/BulkInsertValuesIntoTempTableAsync_1_Column.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CollectionParameterJsonSerializerOptions.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateComplexCollectionParameter.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateScalarCollectionParameter.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/TempTable_DateOnly_Repro.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/AverageWindowFunction.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MaxWindowFunction.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MinWindowFunction.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/RowNumber.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/SumWindowFunction.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/WindowFunctionTests.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Json/ConvertibleClassConverter.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlContainerFixture.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlTestDbContext.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/TestDatabaseContext/MyParameter.cs create mode 100644 tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Thinktecture.EntityFrameworkCore.MySql.Tests.csproj diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0b3891b..092f933 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -80,10 +80,12 @@ dotnet test tests/Thinktecture.EntityFrameworkCore.SqlServer.Tests/ -c Release | Language | C# 14.0, nullable enabled, implicit usings | | EF Core | 10.0.2 (`Microsoft.EntityFrameworkCore.*`) | | PostgreSQL | Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 | +| MySQL | Microting.EntityFrameworkCore.MySql 10.0.12 | | Testing | xUnit 2.9.3 + AwesomeAssertions 9.3.0 + NSubstitute 5.3.0 | | Logging | Serilog.Extensions.Logging 10.0.0 + Serilog.Sinks.XUnit 3.0.19 | | SQL Server tests | Testcontainers.MsSql 4.10.0 (Docker) or configured connection string | | PostgreSQL tests | Testcontainers.PostgreSql 4.10.0 (Docker) or configured connection string | +| MySQL tests | Testcontainers.MySql (Docker, always) | | Root namespace | `Thinktecture` (set in Directory.Build.props) | | Package versions | Centralized in `Directory.Packages.props` (no versions in `.csproj`) | @@ -103,6 +105,7 @@ src/ # Runtime packages (9 projects) Thinktecture.EntityFrameworkCore.SqlServer/ # SQL Server implementation Thinktecture.EntityFrameworkCore.SqlServer.Testing/ # SQL Server test utilities Thinktecture.EntityFrameworkCore.PostgreSQL/ # PostgreSQL implementation (Npgsql) + Thinktecture.EntityFrameworkCore.MySql/ # MySQL/MariaDB implementation (Microting) Thinktecture.EntityFrameworkCore.Sqlite.Core/ # SQLite foundation Thinktecture.EntityFrameworkCore.Sqlite/ # SQLite full package Thinktecture.EntityFrameworkCore.Sqlite.Testing/ # SQLite test utilities @@ -140,6 +143,7 @@ Relational (foundation) └─ BulkOperations (abstractions) ├─ SqlServer (SQL Server impl) → SqlServer.Testing ├─ PostgreSQL (PostgreSQL/Npgsql impl) + ├─ MySql (MySQL/MariaDB impl) └─ Sqlite.Core → Sqlite → Sqlite.Testing Testing (shared test infrastructure, used by *.Testing packages) ``` @@ -227,34 +231,57 @@ private NpgsqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService` | All three providers; SQL Server: `UPDATE t SET ... FROM target INNER JOIN (subquery) AS s ON ...`; PostgreSQL/SQLite: `UPDATE target AS t SET ... FROM (subquery) AS s WHERE ...`; fluent `Set` builder; accepts `IQueryable` as source; optional `filter` parameter (`Expression>`) restricts which rows are updated (can reference both target and source properties) | | **Bulk Insert from Query** | `SqlServerBulkOperationsDbSetExtensions.BulkInsertAsync`, `NpgsqlBulkOperationsDbSetExtensions.BulkInsertAsync`, `SqliteBulkOperationsDbSetExtensions.BulkInsertAsync`, `InsertPropertyBuilder` | All three providers; generates `INSERT INTO target (cols) SELECT s.cols FROM (subquery) AS s`; fluent `Map` builder; accepts `IQueryable` as source | | **Temp Tables** | `ITempTableCreator`, `ITempTableReference`, `ITempTableQuery`, `CreateTempTableAsync`, `BulkInsertIntoTempTableAsync`, `BulkInsertValuesIntoTempTableAsync` | Two workflows: all-in-one (create+insert via `BulkInsertIntoTempTableAsync`) or two-step (`CreateTempTableAsync` for empty table + insert later); auto-cleanup via `IAsyncDisposable`; name conflict prevention via `TempTableSuffixLeasing` | | **Window Functions** | `EF.Functions.RowNumber()`, `.NTile()`, `.Average()`, etc. | Fluent `PartitionBy()`/`OrderBy()`; all three providers. `NTile` is per-provider (SQL Server requires ORDER BY and returns `long`/bigint; PG/SQLite return `int` and ORDER BY is optional) and lives in `{Provider}DbFunctionsExtensions`. | -| **Bitwise Aggregates** | `EF.Functions.BitOr`, `.BitAnd`, `.BitXor` | PostgreSQL only; GROUP BY aggregate over integer/`[Flags]`-enum columns; native `bit_or`/`bit_and`/`bit_xor`, no cast | +| **Bitwise Aggregates** | `EF.Functions.BitOr`, `.BitAnd`, `.BitXor` | PostgreSQL (native, no cast) and MySQL (`CAST(BIT_xx(x) AS SIGNED)`, NULL for empty group); GROUP BY aggregate over integer/`[Flags]`-enum columns | | **Table Hints** | `query.WithTableHints(SqlServerTableHint.NoLock)` | SQL Server only | | **Nested Transactions** | `NestedRelationalTransactionManager` | Root = real transaction; children = logical | -| **Collection Parameters** | `ScalarCollectionParameter`, `JsonCollectionParameter`, `NpgsqlJsonCollectionParameter` | Provider-specific factories | +| **Collection Parameters** | `ScalarCollectionParameter`, `JsonCollectionParameter`, `NpgsqlJsonCollectionParameter` | Provider-specific factories (MySQL: `MySqlCollectionParameterFactory` via `JSON_TABLE`) | | **Tenant Databases** | `ITenantDatabaseProvider` | Per-tenant DB names; cache-key aware | ### Provider Feature Matrix -| Feature | SQL Server | PostgreSQL | SQLite | -|---------|:---:|:---:|:---:| -| Bulk Insert | Y | Y | Y | -| Bulk Update | Y | Y | Y | -| Bulk Insert-or-Update (Upsert) | Y | Y | Y | -| Temp Tables | Y | Y | Y | -| Window Functions | Y | Y | Y | -| Bitwise Aggregates (`bit_or`/`bit_and`/`bit_xor`) | - | Y | - | -| Collection Parameters | Y | Y | - | -| Nested Transactions | Y | Y | Y | -| Tenant Database Support | Y | - | - | -| Table Hints | Y | - | - | -| Bulk Update from Query | Y | Y | Y | -| Bulk Insert from Query | Y | Y | Y | -| Truncate Table (dedicated) | Y | Y | Y | +| Feature | SQL Server | PostgreSQL | MySQL | SQLite | +|---------|:---:|:---:|:---:|:---:| +| Bulk Insert | Y | Y | Y | Y | +| Bulk Update | Y | Y | Y | Y | +| Bulk Insert-or-Update (Upsert) | Y | Y | Y | Y | +| Temp Tables | Y | Y | Y | Y | +| Window Functions | Y | Y | Y | Y | +| Bitwise Aggregates (`bit_or`/`bit_and`/`bit_xor`) | - | Y | Y | - | +| Collection Parameters | Y | Y | Y | - | +| Nested Transactions | Y | Y | Y | Y | +| Tenant Database Support | Y | - | - | - | +| Table Hints | Y | - | - | - | +| Bulk Update from Query | Y | Y | Y | Y | +| Bulk Insert from Query | Y | Y | Y | Y | +| Truncate Table (dedicated) | Y | Y | Y | Y | + +### MySQL specifics + + + +- Package `Thinktecture.EntityFrameworkCore.MySql` (Microting.EntityFrameworkCore.MySql). Registration via `AddBulkOperationSupport()`, `AddWindowFunctionsSupport()`, `AddCollectionParameterSupport()` on `MySqlDbContextOptionsBuilder`; `MySqlDbContextOptionsExtension.Validate` rejects window functions below MySQL 8.0/MariaDB 10.2 and collection parameters (`JSON_TABLE`) below MySQL 8.0.4/MariaDB 10.6. + +- **Bulk insert**: parameterized multi-row `INSERT` (`BatchSize`, default 500, capped by the 65,535 parameter limit; `CommandTimeout`). Parameters are created through the provider-type mapping of each column. `MySqlBulkInsertOptions.UseBulkCopy` opts into `MySqlBulkCopy`/`LOAD DATA LOCAL INFILE`; the connection needs `AllowLoadLocalInfile=true`, the server needs `local_infile=ON`, and the input is buffered into a `DataTable`. Without an outer transaction the executor opens its own, so a failing batch leaves nothing behind; inside a `TransactionScope` no own transaction is started. `UseBulkCopy` fails on warnings (e.g. dropped duplicate keys). Owned types in separate tables are supported for insert (the owner entities must be tracked); update and upsert reject them. + +- **Upsert**: `INSERT ... ON DUPLICATE KEY UPDATE col = IF(keys <=> VALUES(keys), VALUES(col), col)`. MySQL fires the clause on any unique index, so the update is guarded by `KeyProperties`; key columns are never updated and a row colliding on a different unique index is skipped. Key properties must be part of the inserted properties. The primary key is never updated by default. MySQL 8.0.19+ uses the row alias (`AS new_row`) instead of the deprecated `VALUES(col)`; MariaDB keeps `VALUES(col)`. The return value is the number of processed input rows (MySQL reports 1/2/0 per row). + +- **Bulk update**: stages the rows in a temp table and runs one `UPDATE target INNER JOIN temp ... SET` statement (same transaction rule as insert); inside a `TransactionScope` it updates row by row because MySQL XA rejects temp tables. Throws `ArgumentException` when no non-key property is left to update. Staging tables of regular entities have no `AUTO_INCREMENT`. + +- **Truncate**: `TRUNCATE TABLE`, or `DELETE FROM` when a transaction (including an ambient `TransactionScope`) is active (TRUNCATE commits implicitly) or the table is referenced by a foreign key (error 1701). + +- **Temp tables**: connection-scoped `CREATE TEMPORARY TABLE`; string columns carry the charset/collation of the model (design-time model) to avoid error 1267 in joins. MySQL cannot reference a temp table twice in one statement (error 1137: self-join, `EXISTS`, `UNION`). + +- **Collection parameters**: `JSON_TABLE` reads JSON numbers as DOUBLE and rejects the .NET `TimeSpan`/`DateTime` formats, so `decimal`, `TimeSpan` and `DateTime` are serialized as MySQL-parsable strings (`MySqlCollectionParameterFactory`). Complex-object JSON paths honor `PropertyNamingPolicy` and `[JsonPropertyName]`. +- **Window functions**: `Sum/Average/Max/Min` each have 10 overloads (1-5 partition columns, with or without ordering); `NTile` supports 0-5 partition columns. No space between function name and `(` (`COUNT(*) OVER`); `ROW_NUMBER()` is wrapped in `CAST(... AS signed)` because MySQL returns BIGINT UNSIGNED. + +- **Bitwise aggregates**: `EF.Functions.BitOr/BitAnd/BitXor` (`MySqlAggregateDbFunctionsExtensions`) emit `CAST(BIT_xx(x) AS SIGNED)` and return `NULL` for an empty group; `DISTINCT` is dropped for OR/AND and rejected for XOR. + +- **Tests**: `tests/Thinktecture.EntityFrameworkCore.MySql.Tests` always uses Testcontainers (pinned `mysql:8.4` image, Docker required; the EF server version is `MySqlContainerFixture.ServerVersion`), collection `MySqlTests`. ### PostgreSQL `max`/`min` over `uuid` @@ -414,6 +441,7 @@ Key patterns (read templates for full code): | `BulkOperations.Tests` | Bulk ops abstractions | SQLite (in-memory) | | `SqlServer.Tests` | SQL Server features | SQL Server (Testcontainers or configured) | | `PostgreSQL.Tests` | PostgreSQL features | PostgreSQL (Testcontainers or configured) | +| `MySql.Tests` | MySQL features | MySQL Testcontainers | | `Sqlite.Tests` | SQLite features | SQLite (in-memory) | | `Testing.Tests` | Testing infrastructure | Varies | | `TestHelpers` | Shared entities & DbContext (not a test project) | N/A | @@ -423,10 +451,12 @@ Key patterns (read templates for full code): - Test methods: `Should_DoSomething` or `Should_DoSomething_When_Condition` - `[Fact]` for single cases, `[Theory]` + `[InlineData]` for parameterized - Always `async Task` return (not `void`) +- New provider suites must mirror the directory structure, test isolation, and feature-equivalent coverage of mature provider suites. Do not copy behavior that is explicitly provider-specific. ### AAA Pattern with Three DbContexts All integration tests use `ArrangeDbContext` (setup), `ActDbContext` (execute), `AssertDbContext` (verify) — same database, independent change trackers, provided by `TestCtxProvider`. +MySQL tests use the same three-context pattern through `MySqlIntegrationTestsBase` and capture executed SQL with `CommandCapturingInterceptor`. ### Provider-Specific Test Templates diff --git a/Directory.Packages.props b/Directory.Packages.props index 212d1de..3212365 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,11 +15,13 @@ + + diff --git a/README.md b/README.md index 2d0332d..8f7e7f4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ [![Thinktecture.EntityFrameworkCore.SqlServer](https://img.shields.io/nuget/vpre/Thinktecture.EntityFrameworkCore.SqlServer.svg?label=Thinktecture.EntityFrameworkCore.SqlServer&maxAge=3600)](https://www.nuget.org/packages/Thinktecture.EntityFrameworkCore.SqlServer/) [![Thinktecture.EntityFrameworkCore.SqlServer.Testing](https://img.shields.io/nuget/vpre/Thinktecture.EntityFrameworkCore.SqlServer.Testing.svg?label=Thinktecture.EntityFrameworkCore.SqlServer.Testing&maxAge=3600)](https://www.nuget.org/packages/Thinktecture.EntityFrameworkCore.SqlServer.Testing/) [DEPRECATED] [![Thinktecture.EntityFrameworkCore.PostgreSQL](https://img.shields.io/nuget/vpre/Thinktecture.EntityFrameworkCore.PostgreSQL.svg?label=Thinktecture.EntityFrameworkCore.PostgreSQL&maxAge=3600)](https://www.nuget.org/packages/Thinktecture.EntityFrameworkCore.PostgreSQL/) +[![Thinktecture.EntityFrameworkCore.MySql](https://img.shields.io/nuget/vpre/Thinktecture.EntityFrameworkCore.MySql.svg?label=Thinktecture.EntityFrameworkCore.MySql&maxAge=3600)](https://www.nuget.org/packages/Thinktecture.EntityFrameworkCore.MySql/) [![Thinktecture.EntityFrameworkCore.Sqlite.Core](https://img.shields.io/nuget/vpre/Thinktecture.EntityFrameworkCore.Sqlite.Core.svg?label=Thinktecture.EntityFrameworkCore.Sqlite.Core&maxAge=3600)](https://www.nuget.org/packages/Thinktecture.EntityFrameworkCore.Sqlite.Core/) [![Thinktecture.EntityFrameworkCore.Sqlite](https://img.shields.io/nuget/vpre/Thinktecture.EntityFrameworkCore.Sqlite.svg?label=Thinktecture.EntityFrameworkCore.Sqlite&maxAge=3600)](https://www.nuget.org/packages/Thinktecture.EntityFrameworkCore.Sqlite/) [![Thinktecture.EntityFrameworkCore.Sqlite.Testing](https://img.shields.io/nuget/vpre/Thinktecture.EntityFrameworkCore.Sqlite.Testing.svg?label=Thinktecture.EntityFrameworkCore.Sqlite.Testing&maxAge=3600)](https://www.nuget.org/packages/Thinktecture.EntityFrameworkCore.Sqlite.Testing/) [DEPRECATED] @@ -20,26 +21,30 @@ These libraries extend [Entity Framework Core](https://docs.microsoft.com/en-us/ |----------|------------------------------------------------| | SQL Server | `Thinktecture.EntityFrameworkCore.SqlServer` | | PostgreSQL (Npgsql) | `Thinktecture.EntityFrameworkCore.PostgreSQL` | +| MySQL / MariaDB (Microting) | `Thinktecture.EntityFrameworkCore.MySql` | | SQLite | `Thinktecture.EntityFrameworkCore.Sqlite` | ## Performance -* [Temp-Tables](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Temp-Tables) (SQL Server, PostgreSQL, SQLite) -* [Bulk-Insert](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bulk-Insert) (SQL Server, PostgreSQL, SQLite) -* [Bulk-Update](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bulk-Update) (SQL Server, PostgreSQL, SQLite) -* [Bulk-Upsert (Insert-or-Update)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bulk-Upsert-(Insert-or-Update)) (SQL Server, PostgreSQL, SQLite) -* [Truncate Tables](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Truncate-Tables) (SQL Server, PostgreSQL, SQLite) +* [Temp-Tables](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Temp-Tables) (SQL Server, PostgreSQL, MySQL, SQLite) +* [Bulk-Insert](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bulk-Insert) (SQL Server, PostgreSQL, MySQL, SQLite) +* [Bulk-Update](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bulk-Update) (SQL Server, PostgreSQL, MySQL, SQLite) +* [Bulk-Upsert (Insert-or-Update)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bulk-Upsert-(Insert-or-Update)) (SQL Server, PostgreSQL, MySQL, SQLite) +* [Truncate Tables](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Truncate-Tables) (SQL Server, PostgreSQL, MySQL, SQLite) ## Features -* [Collection Parameters (temp-tables *light*)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Collection-Parameters-(temp-tables-light)) (SQL Server, PostgreSQL) -* [Window Functions Support (RowNumber, Sum, Average, Min, Max)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Window-Functions-Support-(RowNumber,-Sum,-Average,-Min,-Max)) (SQL Server, PostgreSQL, SQLite*) -* [Bitwise Aggregates (BitOr, BitAnd, BitXor)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bitwise-Aggregates-(PostgreSQL)) (PostgreSQL) +* [Collection Parameters (temp-tables *light*)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Collection-Parameters-(temp-tables-light)) (SQL Server, PostgreSQL, MySQL) +* [Window Functions Support (RowNumber, Sum, Average, Min, Max)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Window-Functions-Support-(RowNumber,-Sum,-Average,-Min,-Max)) (SQL Server, PostgreSQL, MySQL, SQLite*) +* [Bitwise Aggregates (BitOr, BitAnd, BitXor)](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Bitwise-Aggregates-(PostgreSQL)) (PostgreSQL, MySQL) * [Nested (virtual) Transactions](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Nested-(virtual)-Transactions) * [Table Hints](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Table-Hints-(SQL-Server)) (SQL Server) * [Queries across multiple databases](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Queries-accross-multiple-databases-(SQL-Server)) (SQL Server) * [Changing default schema at runtime](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Changing-default-schema-at-runtime) * [If-Exists / If-Not-Exists checks in migrations](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/If-(Not-)Exists-checks-in-migrations) (SQL Server, PostgreSQL) -> \* SQLite supports RowNumber only; Sum, Average, Min, Max are available on SQL Server and PostgreSQL. +> \* SQLite supports RowNumber only; Sum, Average, Min, Max are available on SQL Server, PostgreSQL and MySQL. + +For MySQL bulk inserts, `MySqlBulkInsertOptions.UseBulkCopy` opts into `LOAD DATA LOCAL INFILE`. The connection must use `AllowLoadLocalInfile=true` and the server must have `local_infile=ON`; this strategy buffers the source rows in memory. +MySQL bulk upserts return the number of processed input rows, rather than MySQL's provider-specific affected-row count. ## Convenience * [Migrations: include-columns](https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/wiki/Migrations-Include-columns) (SQL Server, PostgreSQL) diff --git a/Thinktecture.EntityFrameworkCore.slnx b/Thinktecture.EntityFrameworkCore.slnx index 59883f7..4cacbfc 100644 --- a/Thinktecture.EntityFrameworkCore.slnx +++ b/Thinktecture.EntityFrameworkCore.slnx @@ -29,6 +29,7 @@ + @@ -41,6 +42,7 @@ + diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/CompositeTempTableEntityPropertiesProvider.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/CompositeTempTableEntityPropertiesProvider.cs new file mode 100644 index 0000000..302a09a --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/CompositeTempTableEntityPropertiesProvider.cs @@ -0,0 +1,134 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Thinktecture.EntityFrameworkCore.Data; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +internal abstract class CompositeTempTableEntityPropertiesProvider : IEntityPropertiesProvider +{ + private readonly IEntityPropertiesProvider? _keyPropertiesProvider; + + protected CompositeTempTableEntityPropertiesProvider(IEntityPropertiesProvider? keyPropertiesProvider) + { + _keyPropertiesProvider = keyPropertiesProvider; + } + + public static IEntityPropertiesProvider CreateForInsertOrUpdate( + IEntityPropertiesProvider propertiesToInsert, + IEntityPropertiesProvider propertiesToUpdate, + IEntityPropertiesProvider? keyPropertiesProvider) + { + return new PropertiesProviderForInsertOrUpdate(propertiesToInsert, propertiesToUpdate, keyPropertiesProvider); + } + + public static IEntityPropertiesProvider CreateForUpdate( + IEntityPropertiesProvider propertiesToUpdate, + IEntityPropertiesProvider? keyPropertiesProvider) + { + return new PropertiesProviderForUpdate(propertiesToUpdate, keyPropertiesProvider); + } + + public abstract IReadOnlyList GetPropertiesForTempTable(IEntityType entityType); + public abstract IReadOnlyList GetPropertiesForInsert(IEntityType entityType, bool? inlinedOwnTypes); + public abstract IReadOnlyList GetPropertiesForUpdate(IEntityType entityType, bool? inlinedOwnTypes); + + public IReadOnlyList GetKeyProperties(IEntityType entityType) + { + return _keyPropertiesProvider.DetermineKeyProperties(entityType); + } + + private IReadOnlyList GetProperties( + IEntityType entityType, + IReadOnlyList propertiesToInsert, + IReadOnlyList propertiesToUpdate) + { + return _keyPropertiesProvider.DetermineKeyProperties(entityType) + .Union(propertiesToInsert) + .Union(propertiesToUpdate) + .ToList(); + } + + private IReadOnlyList GetPropertiesWithNavigations( + IEntityType entityType, + IReadOnlyList propertiesToInsert, + IReadOnlyList propertiesToUpdate) + { + return _keyPropertiesProvider.DetermineKeyProperties(entityType).Select(p => new PropertyWithNavigations(p, Array.Empty())) + .Union(propertiesToInsert) + .Union(propertiesToUpdate) + .ToList(); + } + + private class PropertiesProviderForInsertOrUpdate : CompositeTempTableEntityPropertiesProvider + { + private readonly IEntityPropertiesProvider _propertiesToInsert; + private readonly IEntityPropertiesProvider _propertiesToUpdate; + + public PropertiesProviderForInsertOrUpdate( + IEntityPropertiesProvider propertiesToInsert, + IEntityPropertiesProvider propertiesToUpdate, + IEntityPropertiesProvider? keyPropertiesProvider) + : base(keyPropertiesProvider) + { + _propertiesToInsert = propertiesToInsert; + _propertiesToUpdate = propertiesToUpdate; + } + + public override IReadOnlyList GetPropertiesForTempTable(IEntityType entityType) + { + return GetProperties(entityType, + _propertiesToInsert.GetPropertiesForTempTable(entityType), + _propertiesToUpdate.GetPropertiesForTempTable(entityType)); + } + + public override IReadOnlyList GetPropertiesForInsert(IEntityType entityType, bool? inlinedOwnTypes) + { + return GetPropertiesWithNavigations(entityType, + _propertiesToInsert.GetPropertiesForInsert(entityType, inlinedOwnTypes), + _propertiesToUpdate.GetPropertiesForInsert(entityType, inlinedOwnTypes)); + } + + public override IReadOnlyList GetPropertiesForUpdate(IEntityType entityType, bool? inlinedOwnTypes) + { + return GetPropertiesWithNavigations(entityType, + _propertiesToInsert.GetPropertiesForUpdate(entityType, inlinedOwnTypes), + _propertiesToUpdate.GetPropertiesForUpdate(entityType, inlinedOwnTypes)); + } + } + + private class PropertiesProviderForUpdate : CompositeTempTableEntityPropertiesProvider + { + private readonly IEntityPropertiesProvider _propertiesToUpdate; + + public PropertiesProviderForUpdate( + IEntityPropertiesProvider propertiesToUpdate, + IEntityPropertiesProvider? keyPropertiesProvider) + : base(keyPropertiesProvider) + { + _propertiesToUpdate = propertiesToUpdate; + } + + public override IReadOnlyList GetPropertiesForTempTable(IEntityType entityType) + { + return GetProperties(entityType, + Array.Empty(), + _propertiesToUpdate.GetPropertiesForTempTable(entityType)); + } + + public override IReadOnlyList GetPropertiesForInsert(IEntityType entityType, bool? inlinedOwnTypes) + { + return GetPropertiesWithNavigations(entityType, + Array.Empty(), + _propertiesToUpdate.GetPropertiesForInsert(entityType, inlinedOwnTypes)); + } + + public override IReadOnlyList GetPropertiesForUpdate(IEntityType entityType, bool? inlinedOwnTypes) + { + return GetPropertiesWithNavigations(entityType, + Array.Empty(), + _propertiesToUpdate.GetPropertiesForUpdate(entityType, inlinedOwnTypes)); + } + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertFromQueryOptions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertFromQueryOptions.cs new file mode 100644 index 0000000..5a2dfba --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertFromQueryOptions.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using Thinktecture.EntityFrameworkCore.BulkOperations.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +/// +/// Options for MySQL query-based bulk inserts. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class MySqlBulkInsertFromQueryOptions : IBulkOperationFromQueryOptions +{ + private readonly BulkOperationTableNameOptions _tableNames = new(); + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? TableName + { + get => _tableNames.TableName; + set => _tableNames.TableName = value; + } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? Schema + { + get => _tableNames.Schema; + set => _tableNames.Schema = value; + } + + /// + public IBulkOperationTableNameProvider TableNameProvider + { + get => _tableNames.TableNameProvider; + set => _tableNames.TableNameProvider = value; + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOptions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOptions.cs new file mode 100644 index 0000000..0495bf9 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOptions.cs @@ -0,0 +1,75 @@ +using System.Diagnostics.CodeAnalysis; +using Thinktecture.EntityFrameworkCore.BulkOperations.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +/// +/// MySQL bulk insert options. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class MySqlBulkInsertOptions : IBulkInsertOptions +{ + private readonly BulkOperationTableNameOptions _tableNames; + + /// + public IEntityPropertiesProvider? PropertiesToInsert { get; set; } + + /// + /// Maximum number of rows per INSERT statement. The default is 500; the value is reduced to stay below the parameter limit of 65,535. Ignored when is enabled. + /// + public int? BatchSize { get; set; } + + /// + /// Timeout of the generated commands. null uses the default of the connection. + /// + public TimeSpan? CommandTimeout { get; set; } + + /// + /// Uses (LOAD DATA LOCAL INFILE) instead of multi-row INSERT. + /// The connection must enable AllowLoadLocalInfile=true, and the server must enable local_infile. + /// This path buffers the input in memory before copying it. + /// + public bool UseBulkCopy { get; set; } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? TableName + { + get => _tableNames.TableName; + set => _tableNames.TableName = value; + } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? Schema + { + get => _tableNames.Schema; + set => _tableNames.Schema = value; + } + + /// + public IBulkOperationTableNameProvider TableNameProvider + { + get => _tableNames.TableNameProvider; + set => _tableNames.TableNameProvider = value; + } + + /// + /// Initializes a new instance of . + /// + /// Options to copy. + public MySqlBulkInsertOptions(IBulkInsertOptions? optionsToInitializeFrom = null) + { +#pragma warning disable CS0618 + _tableNames = new BulkOperationTableNameOptions(optionsToInitializeFrom?.TableName, optionsToInitializeFrom?.Schema, optionsToInitializeFrom?.TableNameProvider); +#pragma warning restore CS0618 + PropertiesToInsert = optionsToInitializeFrom?.PropertiesToInsert; + + if (optionsToInitializeFrom is MySqlBulkInsertOptions mySqlOptions) + { + BatchSize = mySqlOptions.BatchSize; + CommandTimeout = mySqlOptions.CommandTimeout; + UseBulkCopy = mySqlOptions.UseBulkCopy; + } + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOrUpdateOptions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOrUpdateOptions.cs new file mode 100644 index 0000000..23b5794 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkInsertOrUpdateOptions.cs @@ -0,0 +1,81 @@ +using System.Diagnostics.CodeAnalysis; +using Thinktecture.EntityFrameworkCore.BulkOperations.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +/// +/// MySQL upsert options. MySQL fires ON DUPLICATE KEY on any primary or unique index conflict, so the update +/// is guarded by the key properties: only a row whose key columns equal the incoming key is updated; key columns are never +/// updated. An incoming row that collides with a different row on another unique index is skipped silently. +/// The key properties must be part of the properties to insert. The update uses the inserted values of the incoming row, so +/// must be a subset of the properties to insert. If is not set, +/// the inserted properties (except the keys) are updated. +/// The result is the number of processed input rows (MySQL's own affected-row counts differ for inserted, updated and unchanged rows). +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class MySqlBulkInsertOrUpdateOptions : IBulkInsertOrUpdateOptions +{ + private readonly BulkOperationTableNameOptions _tableNames; + + /// + public IEntityPropertiesProvider? PropertiesToInsert { get; set; } + + /// + public IEntityPropertiesProvider? PropertiesToUpdate { get; set; } + + /// + public IEntityPropertiesProvider? KeyProperties { get; set; } + + /// + /// Maximum number of rows per statement. The default is 500; the value is reduced to stay below the parameter limit of 65,535. + /// + public int? BatchSize { get; set; } + + /// + /// Timeout of the generated commands. null uses the default of the connection. + /// + public TimeSpan? CommandTimeout { get; set; } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? TableName + { + get => _tableNames.TableName; + set => _tableNames.TableName = value; + } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? Schema + { + get => _tableNames.Schema; + set => _tableNames.Schema = value; + } + + /// + public IBulkOperationTableNameProvider TableNameProvider + { + get => _tableNames.TableNameProvider; + set => _tableNames.TableNameProvider = value; + } + + /// + /// Initializes a new instance of . + /// + /// Options to copy. + public MySqlBulkInsertOrUpdateOptions(IBulkInsertOrUpdateOptions? optionsToInitializeFrom = null) + { +#pragma warning disable CS0618 + _tableNames = new BulkOperationTableNameOptions(optionsToInitializeFrom?.TableName, optionsToInitializeFrom?.Schema, optionsToInitializeFrom?.TableNameProvider); +#pragma warning restore CS0618 + PropertiesToInsert = optionsToInitializeFrom?.PropertiesToInsert; + PropertiesToUpdate = optionsToInitializeFrom?.PropertiesToUpdate; + KeyProperties = optionsToInitializeFrom?.KeyProperties; + + if (optionsToInitializeFrom is MySqlBulkInsertOrUpdateOptions mySqlOptions) + { + BatchSize = mySqlOptions.BatchSize; + CommandTimeout = mySqlOptions.CommandTimeout; + } + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutor.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutor.cs new file mode 100644 index 0000000..a260d20 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutor.cs @@ -0,0 +1,937 @@ +using System.Data.Common; +using System.Linq.Expressions; +using System.Text; +using System.Transactions; +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Internal; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using Microting.EntityFrameworkCore.MySql.Infrastructure; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.BulkOperations.Internal; +using Thinktecture.EntityFrameworkCore.Data; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +/// +/// Executes MySQL bulk inserts. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class MySqlBulkOperationExecutor : IBulkInsertExecutor, ITempTableBulkInsertExecutor, IBulkUpdateExecutor, IBulkInsertOrUpdateExecutor, ITruncateTableExecutor +{ + private const int _MAX_PARAMETERS = 65_535; + private const int _MAX_ROWS_PER_BATCH = 500; + + private readonly DbContext _context; + private readonly ISqlGenerationHelper _sqlGenerationHelper; + private readonly IRelationalTypeMappingSource _typeMappingSource; + private readonly bool _useRowAlias; + + /// + /// Initializes a new instance of . + /// + /// Current database context. + /// SQL generation helper. + /// Type mapping source used to create command parameters. + /// MySQL provider options. + public MySqlBulkOperationExecutor( + ICurrentDbContext context, + ISqlGenerationHelper sqlGenerationHelper, + IRelationalTypeMappingSource typeMappingSource, + IMySqlOptions mySqlOptions) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(mySqlOptions); + _context = context.Context ?? throw new ArgumentNullException(nameof(context)); + _sqlGenerationHelper = sqlGenerationHelper ?? throw new ArgumentNullException(nameof(sqlGenerationHelper)); + _typeMappingSource = typeMappingSource ?? throw new ArgumentNullException(nameof(typeMappingSource)); + + // VALUES(col) is deprecated since MySQL 8.0.20; the row alias syntax exists since 8.0.19 and is unsupported by MariaDB. + _useRowAlias = mySqlOptions.ServerVersion.Type == ServerType.MySql && mySqlOptions.ServerVersion.Version >= new Version(8, 0, 19); + } + + /// + public IBulkInsertOptions CreateOptions(IEntityPropertiesProvider? propertiesToInsert = null) + { + return new MySqlBulkInsertOptions { PropertiesToInsert = propertiesToInsert }; + } + + /// + IBulkInsertOptions ITempTableBulkInsertExecutor.CreateBulkInsertOptions(IEntityPropertiesProvider? propertiesToInsert) + { + return new MySqlBulkInsertOptions { PropertiesToInsert = propertiesToInsert }; + } + + /// + ITempTableBulkInsertOptions ITempTableBulkInsertExecutor.CreateOptions(IEntityPropertiesProvider? propertiesToInsert) + { + return new MySqlTempTableBulkInsertOptions { PropertiesToInsert = propertiesToInsert }; + } + + /// + IBulkUpdateOptions IBulkUpdateExecutor.CreateOptions(IEntityPropertiesProvider? propertiesToUpdate, IEntityPropertiesProvider? keyProperties) + { + return new MySqlBulkUpdateOptions + { + PropertiesToUpdate = propertiesToUpdate, + KeyProperties = keyProperties + }; + } + + /// + IBulkInsertOrUpdateOptions IBulkInsertOrUpdateExecutor.CreateOptions( + IEntityPropertiesProvider? propertiesToInsert, + IEntityPropertiesProvider? propertiesToUpdate, + IEntityPropertiesProvider? keyProperties) + { + return new MySqlBulkInsertOrUpdateOptions + { + PropertiesToInsert = propertiesToInsert, + PropertiesToUpdate = propertiesToUpdate, + KeyProperties = keyProperties + }; + } + + /// + public Task> BulkInsertIntoTempTableAsync( + IEnumerable entities, + ITempTableBulkInsertOptions options, + CancellationToken cancellationToken = default) + where T : class + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(options); + + var mySqlOptions = options as MySqlTempTableBulkInsertOptions ?? new MySqlTempTableBulkInsertOptions(options); + return BulkInsertIntoTempTableAsync(entities, mySqlOptions, static query => query, cancellationToken); + } + + /// + public Task> BulkInsertValuesIntoTempTableAsync( + IEnumerable values, + ITempTableBulkInsertOptions options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(options); + + var mySqlOptions = options as MySqlTempTableBulkInsertOptions ?? new MySqlTempTableBulkInsertOptions(options); + var entities = values.Select(value => new TempTable(value)); + return BulkInsertIntoTempTableAsync, TColumn1>(entities, mySqlOptions, static query => query.Select(row => row.Column1), cancellationToken); + } + + /// + public Task BulkInsertValuesIntoTempTableAsync( + IEnumerable values, + ITempTableReference tempTable, + ITempTableBulkInsertOptions? options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(tempTable); + var mySqlOptions = options as MySqlTempTableBulkInsertOptions ?? new MySqlTempTableBulkInsertOptions(options); + return BulkInsertValuesIntoTempTableAsync(values, tempTable, mySqlOptions.GetBulkInsertOptions(), cancellationToken); + } + + /// + public Task BulkInsertValuesIntoTempTableAsync( + IEnumerable values, + ITempTableReference tempTable, + IBulkInsertOptions? options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(tempTable); + return BulkInsertIntoExistingTempTableAsync(values.Select(value => new TempTable(value)), tempTable, options, cancellationToken); + } + + /// + public Task BulkInsertIntoTempTableAsync( + IEnumerable entities, + ITempTableReference tempTable, + ITempTableBulkInsertOptions? options, + CancellationToken cancellationToken) + where T : class + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(tempTable); + var mySqlOptions = options as MySqlTempTableBulkInsertOptions ?? new MySqlTempTableBulkInsertOptions(options); + return BulkInsertIntoExistingTempTableAsync(entities, tempTable, mySqlOptions.GetBulkInsertOptions(), cancellationToken); + } + + /// + public Task BulkInsertIntoTempTableAsync( + IEnumerable entities, + ITempTableReference tempTable, + IBulkInsertOptions? options, + CancellationToken cancellationToken) + where T : class + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(tempTable); + return BulkInsertIntoExistingTempTableAsync(entities, tempTable, options, cancellationToken); + } + + /// + public async Task BulkInsertAsync(IEnumerable entities, IBulkInsertOptions options, CancellationToken cancellationToken = default) + where T : class + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(options); + + var mySqlOptions = options as MySqlBulkInsertOptions ?? new MySqlBulkInsertOptions(options); + var entityType = _context.Model.GetEntityTypeOrTempTableEntityType(); + var properties = mySqlOptions.PropertiesToInsert.DeterminePropertiesForInsert(entityType, null); + properties.EnsureNoSeparateOwnedTypesInsideCollectionOwnedType(); + + return await ExecuteInTransactionAsync(token => InsertAsync(entityType, entities, properties, mySqlOptions, token), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task BulkUpdateAsync(IEnumerable entities, IBulkUpdateOptions options, CancellationToken cancellationToken = default) + where T : class + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(options); + + var mySqlOptions = options as MySqlBulkUpdateOptions ?? new MySqlBulkUpdateOptions(options); + var entityType = _context.Model.GetEntityTypeOrTempTableEntityType(); + + // Resolved first: a temp table entity without a table name is reported as such, not as a missing key. + var table = mySqlOptions.TableNameProvider.GetValidTableName(entityType); + var propertiesToUpdate = mySqlOptions.PropertiesToUpdate.DeterminePropertiesForUpdate(entityType, null); + var keyProperties = mySqlOptions.KeyProperties.DetermineKeyProperties(entityType); + + if (keyProperties.Count == 0) + throw new InvalidOperationException($"The entity type '{entityType.Name}' needs a primary key to perform bulk update."); + + propertiesToUpdate.EnsureNoSeparateOwnedTypesInsideCollectionOwnedType(); + EnsureNoSeparateOwnedTypes(entityType); + + var propertiesToSet = propertiesToUpdate.Where(property => !keyProperties.Contains(property.Property)).ToList(); + + if (propertiesToSet.Count == 0) + throw new ArgumentException("The number of properties to update cannot be 0."); + + var tempTableOptions = new MySqlTempTableBulkInsertOptions + { + PropertiesToInsert = mySqlOptions.PropertiesToUpdate is null + ? null + : CompositeTempTableEntityPropertiesProvider.CreateForUpdate(mySqlOptions.PropertiesToUpdate, mySqlOptions.KeyProperties), + BatchSize = mySqlOptions.BatchSize, + CommandTimeout = mySqlOptions.CommandTimeout, + // The update keys may not be the primary key. + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveEntityTypeConfiguration + }; + + return await ExecuteInTransactionAsync(async token => + { + // MySQL rejects temp tables inside the XA transaction of a TransactionScope. + if (Transaction.Current is not null) + return await UpdateRowByRowAsync(entities, table, propertiesToSet, keyProperties, mySqlOptions.CommandTimeout, token).ConfigureAwait(false); + + // Staging in a temp table turns N round trips into one set-based UPDATE. + await using var tempTable = await BulkInsertIntoTempTableAsync(entities, tempTableOptions, static query => query, token).ConfigureAwait(false); + var statement = CreateUpdateStatement(table, tempTable.Name, propertiesToSet, keyProperties); + + await using var command = CreateCommand(mySqlOptions.CommandTimeout); + command.CommandText = statement; + return await command.ExecuteNonQueryAsync(token).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(false); + } + + /// + public async Task BulkInsertOrUpdateAsync(IEnumerable entities, IBulkInsertOrUpdateOptions options, CancellationToken cancellationToken = default) + where T : class + { + ArgumentNullException.ThrowIfNull(entities); + ArgumentNullException.ThrowIfNull(options); + + var mySqlOptions = options as MySqlBulkInsertOrUpdateOptions ?? new MySqlBulkInsertOrUpdateOptions(options); + var entityType = _context.Model.GetEntityTypeOrTempTableEntityType(); + + // Resolved first: a temp table entity without a table name is reported as such, not as a missing key. + var table = mySqlOptions.TableNameProvider.GetValidTableName(entityType); + var propertiesToInsert = mySqlOptions.PropertiesToInsert.DeterminePropertiesForInsert(entityType, null); + var propertiesToUpdate = mySqlOptions.PropertiesToUpdate.DeterminePropertiesForUpdate(entityType, true); + var keyProperties = mySqlOptions.KeyProperties.DetermineKeyProperties(entityType); + + propertiesToInsert.EnsureNoSeparateOwnedTypesInsideCollectionOwnedType(); + propertiesToUpdate.EnsureNoSeparateOwnedTypesInsideCollectionOwnedType(); + EnsureNoSeparateOwnedTypes(entityType); + + // Without an explicit selection the inserted properties are updated. The update can only use the values that are inserted + // (the incoming row), so properties that are explicitly selected for update must be inserted as well (checked below). + if (mySqlOptions.PropertiesToUpdate is null) + propertiesToUpdate = propertiesToUpdate.Where(propertiesToInsert.Contains).ToList(); + + foreach (var property in propertiesToUpdate) + { + if (!propertiesToInsert.Contains(property)) + throw new NotSupportedException($"MySQL upsert requires update property '{property.Property.Name}' to also be selected for insertion."); + } + + foreach (var keyProperty in keyProperties) + { + if (!propertiesToInsert.Any(property => property.Property == keyProperty)) + throw new NotSupportedException($"MySQL upsert requires key property '{keyProperty.Name}' to be selected for insertion."); + } + + var tableIdentifier = _sqlGenerationHelper.DelimitIdentifier(table.Name, table.Schema); + var insertColumns = GetColumnNames(propertiesToInsert); + var mappings = GetProviderMappings(propertiesToInsert); + // The primary key is never rewritten by default, even if other properties are used as the upsert key. + IReadOnlyList primaryKeyProperties = mySqlOptions.PropertiesToUpdate is null + ? entityType.FindPrimaryKey()?.Properties ?? [] + : []; + var updateColumns = GetColumnNames(propertiesToUpdate.Where(property => !keyProperties.Contains(property.Property) && !primaryKeyProperties.Contains(property.Property)).ToList()); + var keyColumns = GetColumnNames(keyProperties.Select(property => new PropertyWithNavigations(property, Array.Empty())).ToList()); + var maximumRows = GetMaximumRows(mySqlOptions.BatchSize, propertiesToInsert.Count); + + return await ExecuteInTransactionAsync(async token => + { + using var reader = _context.GetService().Create(_context, entities, propertiesToInsert, ensureReadEntitiesCollection: false); + return await InsertRowsAsync(reader, + tableIdentifier, + insertColumns, + mappings, + maximumRows, + mySqlOptions.CommandTimeout, + updateColumns, + keyColumns, + false, + token).ConfigureAwait(false); + }, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Performs a bulk update using a server-side query as the source. + /// + /// Type of the entity to update. + /// Type of the source query's entity. + /// Type of the join key. + /// Query providing the source rows. + /// Selects the key of the target entity. + /// Selects the key of the source entity. + /// Configures the property assignments. + /// Optional condition restricting the updated rows. + /// Optional options. + /// Cancellation token. + /// The number of affected rows. + public async Task BulkUpdateAsync( + IQueryable sourceQuery, + Expression> targetKeySelector, + Expression> sourceKeySelector, + Func, SetPropertyBuilder> setPropertyCalls, + Expression>? filter = null, + MySqlBulkUpdateFromQueryOptions? options = null, + CancellationToken cancellationToken = default) + where TTarget : class + where TSource : class + { + ArgumentNullException.ThrowIfNull(sourceQuery); + ArgumentNullException.ThrowIfNull(targetKeySelector); + ArgumentNullException.ThrowIfNull(sourceKeySelector); + ArgumentNullException.ThrowIfNull(setPropertyCalls); + + var setClauseBuilder = setPropertyCalls(new SetPropertyBuilder()); + + if (setClauseBuilder.Entries.Count == 0) + throw new ArgumentException("At least one property assignment is required.", nameof(setPropertyCalls)); + + var targetKeyMembers = targetKeySelector.ExtractMembers(); + var sourceKeyMembers = sourceKeySelector.ExtractMembers(); + + if (targetKeyMembers.Count != sourceKeyMembers.Count) + throw new ArgumentException($"The number of target key properties ({targetKeyMembers.Count}) must match the number of source key properties ({sourceKeyMembers.Count})."); + + var logger = _context.GetService().CreateLogger(); + var translator = new EfCoreValueExpressionTranslator(logger, _context); + var (sql, parameters) = translator.TranslateUpdateFromQuery(sourceQuery, + targetKeySelector, + sourceKeySelector, + setClauseBuilder.Entries, + filter, + options?.TableNameProvider ?? IBulkOperationTableNameProvider.Default); + var sqlParameters = parameters.Select(parameter => (object)new MySqlParameter(parameter.Key, parameter.Value ?? DBNull.Value)); + return await _context.Database.ExecuteSqlRawAsync(EscapeFormatString(sql), sqlParameters, cancellationToken).ConfigureAwait(false); + } + + /// + /// Performs a bulk insert using a server-side query as the source. + /// + /// Type of the entity to insert. + /// Type of the source query's entity. + /// Query providing the source rows. + /// Configures the column mappings. + /// Optional options. + /// Cancellation token. + /// The number of affected rows. + public async Task BulkInsertAsync( + IQueryable sourceQuery, + Func, InsertPropertyBuilder> mapPropertyCalls, + MySqlBulkInsertFromQueryOptions? options = null, + CancellationToken cancellationToken = default) + where TTarget : class + where TSource : class + { + ArgumentNullException.ThrowIfNull(sourceQuery); + ArgumentNullException.ThrowIfNull(mapPropertyCalls); + + var builder = mapPropertyCalls(new InsertPropertyBuilder()); + + if (builder.Entries.Count == 0) + throw new ArgumentException("At least one column mapping is required.", nameof(mapPropertyCalls)); + + var logger = _context.GetService().CreateLogger(); + var translator = new EfCoreValueExpressionTranslator(logger, _context); + var (sql, parameters) = translator.TranslateInsertFromQuery( + sourceQuery, + builder.Entries, + options?.TableNameProvider ?? IBulkOperationTableNameProvider.Default); + var sqlParameters = parameters.Select(parameter => (object)new MySqlParameter(parameter.Key, parameter.Value ?? DBNull.Value)); + return await _context.Database.ExecuteSqlRawAsync(EscapeFormatString(sql), sqlParameters, cancellationToken).ConfigureAwait(false); + } + + /// + public Task TruncateTableAsync(CancellationToken cancellationToken = default) + where T : class + { + return TruncateTableAsync(typeof(T), cancellationToken); + } + + /// + /// + /// Uses TRUNCATE TABLE, or DELETE FROM if a transaction is active (TRUNCATE commits implicitly) + /// or the table is referenced by a foreign key (TRUNCATE fails with error 1701). + /// DELETE does not reset AUTO_INCREMENT counters. + /// + public async Task TruncateTableAsync(Type type, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(type); + + var entityType = _context.Model.GetEntityType(type); + var tableName = entityType.GetTableName() + ?? throw new InvalidOperationException($"The entity '{entityType.Name}' has no table name."); + var delimitedTable = _sqlGenerationHelper.DelimitIdentifier(tableName, entityType.GetSchema()); + + // TRUNCATE commits implicitly and fails (1701) on tables referenced by foreign keys, even if the child is empty. + var useDelete = HasTransaction + || entityType.GetReferencingForeignKeys().Any(); + var sql = useDelete ? $"DELETE FROM {delimitedTable};" : $"TRUNCATE TABLE {delimitedTable};"; + + await _context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false); + } + + // ExecuteSqlRaw passes the SQL through string.Format. Braces of the generated SQL (for example in string literals) must not be treated as placeholders. + private static string EscapeFormatString(string sql) + { + return sql.Replace("{", "{{", StringComparison.Ordinal).Replace("}", "}}", StringComparison.Ordinal); + } + + private static void EnsureNoSeparateOwnedTypes(IEntityType entityType) + { + if (HasSeparateOwnedType(entityType, entityType.GetTableName(), entityType.GetSchema())) + throw new NotSupportedException("MySQL bulk update and upsert do not support owned entities mapped to separate tables."); + } + + // Owned types nested in inlined owned types are checked as well. + private static bool HasSeparateOwnedType(IEntityType entityType, string? tableName, string? schema) + { + return entityType.GetNavigations().Any(navigation => navigation.TargetEntityType.IsOwned() + && (navigation.TargetEntityType.GetTableName() != tableName + || navigation.TargetEntityType.GetSchema() != schema + || HasSeparateOwnedType(navigation.TargetEntityType, tableName, schema))); + } + + private async Task> BulkInsertIntoTempTableAsync( + IEnumerable entities, + MySqlTempTableBulkInsertOptions options, + Func, IQueryable> projection, + CancellationToken cancellationToken) + where TEntity : class + { + var entityTypeName = EntityNameProvider.GetTempTableName(typeof(TEntity)); + var entityType = _context.Model.GetEntityType(entityTypeName, typeof(TEntity)); + var tempTableReference = await _context.GetService() + .CreateTempTableAsync(entityType, options.GetTempTableCreationOptions(), cancellationToken) + .ConfigureAwait(false); + + try + { + var insertOptions = options.GetBulkInsertOptions(); + insertOptions.TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(tempTableReference.Name); + var insertedRows = await BulkInsertAsync(entities, insertOptions, cancellationToken).ConfigureAwait(false); + + // Regular (not temp-table-registered) entities are queried via their own entity type. + var dbSet = entityType.Name == entityTypeName + ? _context.Set(entityTypeName) + : _context.Set(); + var query = dbSet.FromTempTable(new TempTableInfo(tempTableReference.Name)); + + if (entityType.FindPrimaryKey() is { Properties.Count: > 0 }) + query = query.AsNoTracking(); + + return new TempTableQuery(projection(query), tempTableReference, insertedRows); + } + catch + { + await tempTableReference.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + private Task BulkInsertIntoExistingTempTableAsync( + IEnumerable entities, + ITempTableReference tempTable, + IBulkInsertOptions? options, + CancellationToken cancellationToken) + where T : class + { + // Copy: the caller's options must not be redirected to the temp table. + var insertOptions = new MySqlBulkInsertOptions(options) + { + TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(tempTable.Name) + }; + return BulkInsertAsync(entities, insertOptions, cancellationToken); + } + + private static string[] GetColumnNames(IReadOnlyList properties) + { + StoreObjectIdentifier? storeObject = null; + var columnNames = new string[properties.Count]; + + for (var i = 0; i < properties.Count; i++) + { + var property = properties[i]; + storeObject ??= property.GetStoreObject(); + columnNames[i] = property.Property.GetColumnName(storeObject.Value) + ?? throw new InvalidOperationException($"The property '{property.Property.Name}' has no column name."); + } + + return columnNames; + } + + private static int GetMaximumRows(int? batchSize, int numberOfColumns) + { + if (batchSize is <= 0) + throw new ArgumentOutOfRangeException(nameof(batchSize), "The batch size must be greater than 0."); + + return Math.Max(1, Math.Min(batchSize ?? _MAX_ROWS_PER_BATCH, _MAX_PARAMETERS / Math.Max(1, numberOfColumns))); + } + + // The data reader yields provider values (value converters already applied), so the mapping must describe the provider type. + private RelationalTypeMapping?[] GetProviderMappings(IReadOnlyList properties) + { + var mappings = new RelationalTypeMapping?[properties.Count]; + + for (var i = 0; i < mappings.Length; i++) + { + var property = properties[i].Property; + var mapping = property.GetRelationalTypeMapping(); + var converter = property.GetValueConverter() ?? mapping.Converter; + + mappings[i] = converter is null + ? mapping + : _typeMappingSource.FindMapping(converter.ProviderClrType, property.GetColumnType()); + } + + return mappings; + } + + private bool HasTransaction => _context.Database.CurrentTransaction is not null || Transaction.Current is not null; + + private async Task ExecuteInTransactionAsync(Func> action, CancellationToken cancellationToken) + { + await _context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + try + { + // Without an outer transaction a failing batch would leave the earlier batches committed. + // An ambient TransactionScope already provides atomicity and EF refuses to start its own transaction inside it. + await using var transaction = HasTransaction + ? null + : await _context.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + + var result = await action(cancellationToken).ConfigureAwait(false); + + if (transaction is not null) + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + + return result; + } + finally + { + await _context.Database.CloseConnectionAsync().ConfigureAwait(false); + } + } + + private DbCommand CreateCommand(TimeSpan? commandTimeout) + { + var command = _context.Database.GetDbConnection().CreateCommand(); + command.Transaction = _context.Database.CurrentTransaction?.GetDbTransaction(); + + if (commandTimeout is not null) + command.CommandTimeout = (int)Math.Ceiling(commandTimeout.Value.TotalSeconds); + + return command; + } + + private async Task InsertAsync( + IEntityType entityType, + IEnumerable entities, + IReadOnlyList properties, + MySqlBulkInsertOptions options, + CancellationToken cancellationToken) + { + var (ownProperties, externalProperties) = properties.SeparateProperties(); + var hasExternalProperties = externalProperties.Count != 0; + var table = options.TableNameProvider.GetValidTableName(entityType); + var tableIdentifier = _sqlGenerationHelper.DelimitIdentifier(table.Name, table.Schema); + var maximumRows = GetMaximumRows(options.BatchSize, ownProperties.Count); + + using var reader = _context.GetService().Create(_context, entities, ownProperties, ensureReadEntitiesCollection: hasExternalProperties); + var affectedRows = await InsertRowsAsync(reader, + tableIdentifier, + GetColumnNames(ownProperties), + GetProviderMappings(ownProperties), + maximumRows, + options.CommandTimeout, + null, + null, + options.UseBulkCopy, + cancellationToken).ConfigureAwait(false); + + if (!hasExternalProperties) + return affectedRows; + + // Owned types with their own table are inserted after their owners. + var readEntities = (IReadOnlyList)reader.GetReadEntities(); + + if (readEntities.Count == 0) + return affectedRows; + + foreach (var (navigation, ownedEntities, ownedProperties) in externalProperties.GroupExternalProperties(readEntities)) + { + affectedRows += await InsertAsync(navigation.TargetEntityType, ownedEntities, ownedProperties, options, cancellationToken).ConfigureAwait(false); + } + + return affectedRows; + } + + private async Task InsertRowsAsync( + IEntityDataReader reader, + string tableIdentifier, + IReadOnlyList columns, + IReadOnlyList mappings, + int maximumRows, + TimeSpan? commandTimeout, + IReadOnlyList? updateColumns, + IReadOnlyList? keyColumns, + bool useBulkCopy, + CancellationToken cancellationToken) + { + if (useBulkCopy) + { + var connection = (MySqlConnection)_context.Database.GetDbConnection(); + var transaction = _context.Database.CurrentTransaction?.GetDbTransaction() as MySqlTransaction; + var data = new System.Data.DataTable(); + + for (var i = 0; i < columns.Count; i++) + { + var clrType = mappings[i]?.ClrType ?? typeof(object); + data.Columns.Add(columns[i], Nullable.GetUnderlyingType(clrType) ?? clrType); + } + + while (reader.Read()) + { + var row = data.NewRow(); + + for (var i = 0; i < columns.Count; i++) + { + row[i] = reader.GetValue(i) ?? DBNull.Value; + } + + data.Rows.Add(row); + } + + var bulkCopy = new MySqlBulkCopy(connection, transaction) + { + DestinationTableName = tableIdentifier, + // 0 would mean "wait forever". + BulkCopyTimeout = commandTimeout is null + ? _context.Database.GetCommandTimeout() ?? 30 + : (int)Math.Ceiling(commandTimeout.Value.TotalSeconds) + }; + + for (var i = 0; i < columns.Count; i++) + { + bulkCopy.ColumnMappings.Add(new MySqlBulkCopyColumnMapping(i, columns[i])); + } + + var result = await bulkCopy.WriteToServerAsync(data, cancellationToken).ConfigureAwait(false); + + // LOAD DATA LOCAL implies IGNORE: duplicate keys and bad values become warnings and the rows are dropped. + if (result.Warnings.Count != 0 || result.RowsInserted != data.Rows.Count) + { + var details = String.Join("; ", result.Warnings.Take(5).Select(warning => $"{warning.Level} {warning.ErrorCode}: {warning.Message}")); + throw new InvalidOperationException($"MySqlBulkCopy inserted {result.RowsInserted} of {data.Rows.Count} rows. {details}"); + } + + return result.RowsInserted; + } + + var rows = new List(maximumRows); + var affectedRows = 0; + + while (reader.Read()) + { + var row = new object?[columns.Count]; + + for (var i = 0; i < row.Length; i++) + { + row[i] = reader.GetValue(i); + } + + rows.Add(row); + + if (rows.Count == maximumRows) + { + affectedRows += await ExecuteAndCountAsync().ConfigureAwait(false); + rows.Clear(); + } + } + + if (rows.Count != 0) + affectedRows += await ExecuteAndCountAsync().ConfigureAwait(false); + + return affectedRows; + + // An upsert reports 1/2/0 per row depending on insert/update/no-change; report the processed rows instead. + async Task ExecuteAndCountAsync() + { + var affected = await ExecuteBatchAsync(tableIdentifier, columns, mappings, rows, commandTimeout, cancellationToken, updateColumns, keyColumns).ConfigureAwait(false); + return updateColumns is null ? affected : rows.Count; + } + } + + private async Task ExecuteBatchAsync( + string tableIdentifier, + IReadOnlyList columnNames, + IReadOnlyList mappings, + IReadOnlyList rows, + TimeSpan? commandTimeout, + CancellationToken cancellationToken, + IReadOnlyList? updateColumnNames = null, + IReadOnlyList? keyColumnNames = null) + { + await using var command = CreateCommand(commandTimeout); + + var statement = new StringBuilder($"INSERT INTO {tableIdentifier} "); + + if (columnNames.Count == 0) + { + statement.Append("() VALUES "); + + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + if (rowIndex != 0) + statement.Append(", "); + + statement.Append("()"); + } + } + else + { + statement.Append('('); + + for (var columnIndex = 0; columnIndex < columnNames.Count; columnIndex++) + { + if (columnIndex != 0) + statement.Append(", "); + + statement.Append(_sqlGenerationHelper.DelimitIdentifier(columnNames[columnIndex])); + } + + statement.Append(") VALUES "); + + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + if (rowIndex != 0) + statement.Append(", "); + + statement.Append('('); + + for (var columnIndex = 0; columnIndex < columnNames.Count; columnIndex++) + { + if (columnIndex != 0) + statement.Append(", "); + + var parameterName = $"@p{rowIndex}_{columnIndex}"; + statement.Append(parameterName); + + var value = rows[rowIndex][columnIndex]; + DbParameter parameter; + + if (mappings[columnIndex] is { } mapping) + { + parameter = mapping.CreateParameter(command, parameterName, value); + } + else + { + parameter = command.CreateParameter(); + parameter.ParameterName = parameterName; + parameter.Value = value ?? DBNull.Value; + } + + command.Parameters.Add(parameter); + } + + statement.Append(')'); + } + } + + if (updateColumnNames is not null) + { + if (_useRowAlias) + statement.Append(" AS `new_row`"); + + statement.Append(" ON DUPLICATE KEY UPDATE "); + + if (keyColumnNames is not { Count: > 0 }) + throw new InvalidOperationException("At least one key column is required for MySQL upsert."); + + if (updateColumnNames.Count == 0) + { + var delimitedKeyColumn = _sqlGenerationHelper.DelimitIdentifier(keyColumnNames[0]); + statement.Append(delimitedKeyColumn).Append(" = ").Append(Existing(delimitedKeyColumn)); + } + else + { + // MySQL fires ON DUPLICATE KEY for any unique index; only rows matching the keys are updated. + var keyMatch = String.Join(" AND ", keyColumnNames.Select(key => + { + var column = _sqlGenerationHelper.DelimitIdentifier(key); + return $"{Existing(column)} <=> {Incoming(column)}"; + })); + + for (var i = 0; i < updateColumnNames.Count; i++) + { + if (i != 0) + statement.Append(", "); + + var column = _sqlGenerationHelper.DelimitIdentifier(updateColumnNames[i]); + statement.Append(column).Append(" = IF(").Append(keyMatch).Append(", ").Append(Incoming(column)).Append(", ").Append(Existing(column)).Append(')'); + } + } + } + + command.CommandText = statement.ToString(); + return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + string Existing(string column) => _useRowAlias ? $"{tableIdentifier}.{column}" : column; + string Incoming(string column) => _useRowAlias ? $"`new_row`.{column}" : $"VALUES({column})"; + } + + private async Task UpdateRowByRowAsync( + IEnumerable entities, + BulkOperationTableName table, + IReadOnlyCollection propertiesToSet, + IReadOnlyList keyProperties, + TimeSpan? commandTimeout, + CancellationToken cancellationToken) + { + var readerProperties = propertiesToSet.Concat(keyProperties.Select(property => new PropertyWithNavigations(property, Array.Empty()))).ToList(); + var columns = GetColumnNames(readerProperties); + var mappings = GetProviderMappings(readerProperties); + var statement = new StringBuilder($"UPDATE {_sqlGenerationHelper.DelimitIdentifier(table.Name, table.Schema)} SET "); + + for (var i = 0; i < propertiesToSet.Count; i++) + { + statement.Append(i == 0 ? "" : ", ").Append(_sqlGenerationHelper.DelimitIdentifier(columns[i])).Append(" = @p").Append(i); + } + + statement.Append(" WHERE "); + + for (var i = propertiesToSet.Count; i < columns.Length; i++) + { + statement.Append(i == propertiesToSet.Count ? "" : " AND ") + .Append(_sqlGenerationHelper.DelimitIdentifier(columns[i])) + .Append(keyProperties[i - propertiesToSet.Count].IsNullable ? " <=> @p" : " = @p") + .Append(i); + } + + await using var command = CreateCommand(commandTimeout); + command.CommandText = statement.ToString(); + + var parameters = new DbParameter[columns.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + parameters[i] = mappings[i]?.CreateParameter(command, $"@p{i}", null) ?? command.CreateParameter(); + parameters[i].ParameterName = $"@p{i}"; + command.Parameters.Add(parameters[i]); + } + + using var reader = _context.GetService().Create(_context, entities, readerProperties, ensureReadEntitiesCollection: false); + var affectedRows = 0; + + while (reader.Read()) + { + for (var i = 0; i < parameters.Length; i++) + { + parameters[i].Value = reader.GetValue(i) ?? DBNull.Value; + } + + affectedRows += await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + return affectedRows; + } + + private string CreateUpdateStatement( + BulkOperationTableName table, + string sourceTempTableName, + IReadOnlyList propertiesToSet, + IReadOnlyList keyProperties) + { + var setColumns = GetColumnNames(propertiesToSet); + var keyColumns = GetColumnNames(keyProperties.Select(property => new PropertyWithNavigations(property, Array.Empty())).ToList()); + var statement = new StringBuilder("UPDATE "); + + statement.Append(_sqlGenerationHelper.DelimitIdentifier(table.Name, table.Schema)) + .Append(" AS d INNER JOIN ") + .Append(_sqlGenerationHelper.DelimitIdentifier(sourceTempTableName)) + .Append(" AS s ON "); + + for (var i = 0; i < keyColumns.Length; i++) + { + if (i != 0) + statement.Append(" AND "); + + var column = _sqlGenerationHelper.DelimitIdentifier(keyColumns[i]); + statement.Append("d.").Append(column).Append(keyProperties[i].IsNullable ? " <=> s." : " = s.").Append(column); + } + + statement.Append(" SET "); + + for (var i = 0; i < setColumns.Length; i++) + { + if (i != 0) + statement.Append(", "); + + var column = _sqlGenerationHelper.DelimitIdentifier(setColumns[i]); + statement.Append("d.").Append(column).Append(" = s.").Append(column); + } + + statement.Append(';'); + return statement.ToString(); + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateFromQueryOptions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateFromQueryOptions.cs new file mode 100644 index 0000000..cf66736 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateFromQueryOptions.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; +using Thinktecture.EntityFrameworkCore.BulkOperations.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +/// +/// Options for MySQL query-based bulk updates. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class MySqlBulkUpdateFromQueryOptions : IBulkOperationFromQueryOptions +{ + private readonly BulkOperationTableNameOptions _tableNames = new(); + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? TableName + { + get => _tableNames.TableName; + set => _tableNames.TableName = value; + } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? Schema + { + get => _tableNames.Schema; + set => _tableNames.Schema = value; + } + + /// + public IBulkOperationTableNameProvider TableNameProvider + { + get => _tableNames.TableNameProvider; + set => _tableNames.TableNameProvider = value; + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateOptions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateOptions.cs new file mode 100644 index 0000000..9c1c164 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlBulkUpdateOptions.cs @@ -0,0 +1,71 @@ +using System.Diagnostics.CodeAnalysis; +using Thinktecture.EntityFrameworkCore.BulkOperations.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +/// +/// MySQL bulk update options. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class MySqlBulkUpdateOptions : IBulkUpdateOptions +{ + private readonly BulkOperationTableNameOptions _tableNames; + + /// + public IEntityPropertiesProvider? PropertiesToUpdate { get; set; } + + /// + public IEntityPropertiesProvider? KeyProperties { get; set; } + + /// + /// Maximum number of rows per INSERT statement into the staging temp table. The default is 500. + /// + public int? BatchSize { get; set; } + + /// + /// Timeout of the generated commands. null uses the default of the connection. + /// + public TimeSpan? CommandTimeout { get; set; } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? TableName + { + get => _tableNames.TableName; + set => _tableNames.TableName = value; + } + + /// + [Obsolete("Use 'TableNameProvider' instead.")] + public string? Schema + { + get => _tableNames.Schema; + set => _tableNames.Schema = value; + } + + /// + public IBulkOperationTableNameProvider TableNameProvider + { + get => _tableNames.TableNameProvider; + set => _tableNames.TableNameProvider = value; + } + + /// + /// Initializes a new instance of . + /// + /// Options to copy. + public MySqlBulkUpdateOptions(IBulkUpdateOptions? optionsToInitializeFrom = null) + { +#pragma warning disable CS0618 + _tableNames = new BulkOperationTableNameOptions(optionsToInitializeFrom?.TableName, optionsToInitializeFrom?.Schema, optionsToInitializeFrom?.TableNameProvider); +#pragma warning restore CS0618 + PropertiesToUpdate = optionsToInitializeFrom?.PropertiesToUpdate; + KeyProperties = optionsToInitializeFrom?.KeyProperties; + + if (optionsToInitializeFrom is MySqlBulkUpdateOptions mySqlOptions) + { + BatchSize = mySqlOptions.BatchSize; + CommandTimeout = mySqlOptions.CommandTimeout; + } + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlTempTableBulkInsertOptions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlTempTableBulkInsertOptions.cs new file mode 100644 index 0000000..97886c0 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/BulkOperations/MySqlTempTableBulkInsertOptions.cs @@ -0,0 +1,95 @@ +using Thinktecture.EntityFrameworkCore.TempTables; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations; + +/// +/// Options for MySQL bulk inserts into temporary tables. +/// +/// +/// MySQL does not allow a temporary table to be referenced more than once in a single statement +/// (self-join, EXISTS, UNION, ...); doing so fails with error 1137 "Can't reopen table". +/// Insert the data into a second temp table or materialize the query instead. +/// Temp tables are also unavailable inside a TransactionScope (MySQL XA transactions reject them). +/// +public sealed class MySqlTempTableBulkInsertOptions : ITempTableBulkInsertOptions +{ + /// + public bool TruncateTableIfExists { get; set; } + + /// + public bool DropTableOnDispose { get; set; } = true; + + /// + public ITempTableNameProvider? TableNameProvider { get; set; } + + /// + public IPrimaryKeyPropertiesProvider? PrimaryKeyCreation { get; set; } + + /// + public IEntityPropertiesProvider? PropertiesToInsert { get; set; } + + /// + /// Maximum number of rows per INSERT statement. The default is 500. Ignored when is enabled. + /// + public int? BatchSize { get; set; } + + /// + /// Timeout of the generated commands. null uses the default of the connection. + /// + public TimeSpan? CommandTimeout { get; set; } + + /// + /// Uses (LOAD DATA LOCAL INFILE) instead of multi-row INSERT. + /// The connection must enable AllowLoadLocalInfile=true, and the server must enable local_infile. + /// This path buffers the input in memory before copying it. + /// + public bool UseBulkCopy { get; set; } + + /// + /// Initializes a new instance of . + /// + /// Options to copy. + public MySqlTempTableBulkInsertOptions(ITempTableBulkInsertOptions? optionsToInitializeFrom = null) + { + if (optionsToInitializeFrom is null) + return; + + TruncateTableIfExists = optionsToInitializeFrom.TruncateTableIfExists; + DropTableOnDispose = optionsToInitializeFrom.DropTableOnDispose; + TableNameProvider = optionsToInitializeFrom.TableNameProvider; + PrimaryKeyCreation = optionsToInitializeFrom.PrimaryKeyCreation; + PropertiesToInsert = optionsToInitializeFrom.PropertiesToInsert; + + if (optionsToInitializeFrom is MySqlTempTableBulkInsertOptions mySqlOptions) + { + BatchSize = mySqlOptions.BatchSize; + CommandTimeout = mySqlOptions.CommandTimeout; + UseBulkCopy = mySqlOptions.UseBulkCopy; + } + } + + /// + /// Creates options for the temporary-table DDL. + /// + /// The temp table creation options. + public TempTableCreationOptions GetTempTableCreationOptions() + { + return new TempTableCreationOptions + { + TruncateTableIfExists = TruncateTableIfExists, + DropTableOnDispose = DropTableOnDispose, + TableNameProvider = TableNameProvider, + PrimaryKeyCreation = PrimaryKeyCreation, + PropertiesToInclude = PropertiesToInsert + }; + } + + /// + /// Creates options for the bulk insert. + /// + /// The bulk insert options. + public MySqlBulkInsertOptions GetBulkInsertOptions() + { + return new MySqlBulkInsertOptions { PropertiesToInsert = PropertiesToInsert, BatchSize = BatchSize, CommandTimeout = CommandTimeout, UseBulkCopy = UseBulkCopy }; + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtension.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtension.cs new file mode 100644 index 0000000..334a697 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtension.cs @@ -0,0 +1,248 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Microting.EntityFrameworkCore.MySql.Query.Internal; +using Thinktecture.EntityFrameworkCore.BulkOperations; +using Thinktecture.EntityFrameworkCore.Parameters; +using Thinktecture.EntityFrameworkCore.TempTables; +using Microting.EntityFrameworkCore.MySql.Query.ExpressionVisitors.Internal; +using Thinktecture.EntityFrameworkCore.Query; +using Thinktecture.EntityFrameworkCore.Query.ExpressionTranslators; + +namespace Thinktecture.EntityFrameworkCore.Infrastructure; + +/// +/// Extensions for MySQL DbContext options. +/// +public sealed class MySqlDbContextOptionsExtension : DbContextOptionsExtensionBase, IDbContextOptionsExtension +{ + private readonly RelationalDbContextOptionsExtension _relationalOptions; + private MySqlDbContextOptionsExtensionInfo? _info; + private JsonSerializerOptions? _collectionParameterJsonSerializerOptions; + private bool _addCollectionParameterSupport; + + /// + public DbContextOptionsExtensionInfo Info => _info ??= new MySqlDbContextOptionsExtensionInfo(this); + + /// + /// Enables support for window functions. + /// + public bool AddWindowFunctionsSupport + { + get => _relationalOptions.AddWindowFunctionsSupport; + set => _relationalOptions.AddWindowFunctionsSupport = value; + } + + /// + /// Enables support for MySQL bulk operations. + /// + public bool AddBulkOperationSupport { get; set; } + + private bool _addCustomQueryableMethodTranslatingExpressionVisitorFactory; + + /// + /// A custom factory is registered if true. + /// The factory is required to translate custom methods like . + /// + public bool AddCustomQueryableMethodTranslatingExpressionVisitorFactory + { + get => _addCustomQueryableMethodTranslatingExpressionVisitorFactory || AddBulkOperationSupport || AddWindowFunctionsSupport || _addCollectionParameterSupport; + set => _addCustomQueryableMethodTranslatingExpressionVisitorFactory = value; + } + + private bool _addCustomQuerySqlGeneratorFactory; + + /// + /// A custom factory is registered if true. + /// The factory is required for window functions and bulk operations. + /// + public bool AddCustomQuerySqlGeneratorFactory + { + get => _addCustomQuerySqlGeneratorFactory || AddBulkOperationSupport || AddWindowFunctionsSupport; + set => _addCustomQuerySqlGeneratorFactory = value; + } + + private bool _addCustomRelationalParameterBasedSqlProcessorFactory; + + /// + /// A custom factory is registered if true. + /// The factory is required for window functions and bulk operations. + /// + public bool AddCustomRelationalParameterBasedSqlProcessorFactory + { + get => _addCustomRelationalParameterBasedSqlProcessorFactory || AddBulkOperationSupport || AddWindowFunctionsSupport; + set => _addCustomRelationalParameterBasedSqlProcessorFactory = value; + } + + /// + /// Enables temp table conventions for primitive types. + /// + public bool ConfigureTempTablesForPrimitiveTypes { get; set; } + + /// + /// Enables temp table conventions for primitive collection parameter types. + /// + internal bool ConfigureCollectionParametersForPrimitiveTypes { get; private set; } + + /// + /// Initializes a new instance of . + /// + /// The relational options extension. + public MySqlDbContextOptionsExtension(RelationalDbContextOptionsExtension relationalOptions) + { + _relationalOptions = relationalOptions ?? throw new ArgumentNullException(nameof(relationalOptions)); + } + + /// + [SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] + public void ApplyServices(IServiceCollection services) + { + services.TryAddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + + services.Add(GetLifetime()); + services.Add(GetLifetime()); + + if (AddCustomQueryableMethodTranslatingExpressionVisitorFactory) + AddWithCheck(services); + + if (AddCustomQuerySqlGeneratorFactory) + AddWithCheck(services); + + if (AddCustomRelationalParameterBasedSqlProcessorFactory) + AddWithCheck(services); + + if (AddWindowFunctionsSupport) + { + var lifetime = GetLifetime(); + services.Add(ServiceDescriptor.Describe(typeof(IEvaluatableExpressionFilterPlugin), typeof(MySqlWindowFunctionEvaluatableExpressionFilterPlugin), lifetime)); + } + + if (AddBulkOperationSupport) + { + services.AddSingleton(provider => provider.GetRequiredService()); + services.Add(GetLifetime()); + + AddEntityDataReader(services); + services.TryAddScoped(); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(provider => provider.GetRequiredService()); + services.TryAddScoped(); + services.AddTempTableSuffixComponents(); + } + + if (_addCollectionParameterSupport) + { + var jsonSerializerOptions = _collectionParameterJsonSerializerOptions ?? new JsonSerializerOptions(); + services.AddSingleton(provider => ActivatorUtilities.CreateInstance(provider, jsonSerializerOptions)); + services.Add(GetLifetime()); + } + } + + /// + [SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] + public void Validate(IDbContextOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + var serverVersion = options.FindExtension()?.ServerVersion; + + if (serverVersion is null) + return; + + if (AddWindowFunctionsSupport && !serverVersion.Supports.WindowFunctions) + throw new InvalidOperationException($"Window functions require MySQL 8.0 or MariaDB 10.2, but the configured server version is {serverVersion}."); + + if (_addCollectionParameterSupport && !serverVersion.Supports.JsonTable) + throw new InvalidOperationException($"Collection parameters use JSON_TABLE, which requires MySQL 8.0.4 or MariaDB 10.6, but the configured server version is {serverVersion}."); + } + + /// + /// Configures MySQL collection parameter support. + /// + /// Indication whether to enable the feature. + /// JSON serialization options. + /// Indication whether to configure primitive CLR collection types. + /// This extension. + public MySqlDbContextOptionsExtension AddCollectionParameterSupport( + bool addCollectionParameterSupport, + JsonSerializerOptions? jsonSerializerOptions, + bool configureCollectionParametersForPrimitiveTypes) + { + _addCollectionParameterSupport = addCollectionParameterSupport; + _collectionParameterJsonSerializerOptions = jsonSerializerOptions; + ConfigureCollectionParametersForPrimitiveTypes = addCollectionParameterSupport && configureCollectionParametersForPrimitiveTypes; + return this; + } + + private sealed class MySqlDbContextOptionsExtensionInfo : DbContextOptionsExtensionInfo + { + private readonly MySqlDbContextOptionsExtension _extension; + public override bool IsDatabaseProvider => false; + + /// + public override string LogFragment => (_extension.AddBulkOperationSupport ? "BulkOperationSupport " : String.Empty) + + (_extension._addCollectionParameterSupport ? "CollectionParameterSupport " : String.Empty) + + (_extension.AddWindowFunctionsSupport ? "WindowFunctionsSupport " : String.Empty); + + public MySqlDbContextOptionsExtensionInfo(MySqlDbContextOptionsExtension extension) + : base(extension) + { + _extension = extension ?? throw new ArgumentNullException(nameof(extension)); + } + + /// + public override int GetServiceProviderHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(_extension.AddBulkOperationSupport); + hashCode.Add(_extension.ConfigureTempTablesForPrimitiveTypes); + hashCode.Add(_extension.AddWindowFunctionsSupport); + hashCode.Add(_extension._addCollectionParameterSupport); + hashCode.Add(_extension.ConfigureCollectionParametersForPrimitiveTypes); + hashCode.Add(_extension._collectionParameterJsonSerializerOptions); + hashCode.Add(_extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory); + hashCode.Add(_extension.AddCustomQuerySqlGeneratorFactory); + hashCode.Add(_extension.AddCustomRelationalParameterBasedSqlProcessorFactory); + return hashCode.ToHashCode(); + } + + /// + public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) + { + return other is MySqlDbContextOptionsExtensionInfo otherMySqlInfo + && _extension.AddBulkOperationSupport == otherMySqlInfo._extension.AddBulkOperationSupport + && _extension.ConfigureTempTablesForPrimitiveTypes == otherMySqlInfo._extension.ConfigureTempTablesForPrimitiveTypes + && _extension._addCollectionParameterSupport == otherMySqlInfo._extension._addCollectionParameterSupport + && _extension.ConfigureCollectionParametersForPrimitiveTypes == otherMySqlInfo._extension.ConfigureCollectionParametersForPrimitiveTypes + && _extension._collectionParameterJsonSerializerOptions == otherMySqlInfo._extension._collectionParameterJsonSerializerOptions + && _extension.AddWindowFunctionsSupport == otherMySqlInfo._extension.AddWindowFunctionsSupport + && _extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory == otherMySqlInfo._extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory + && _extension.AddCustomQuerySqlGeneratorFactory == otherMySqlInfo._extension.AddCustomQuerySqlGeneratorFactory + && _extension.AddCustomRelationalParameterBasedSqlProcessorFactory == otherMySqlInfo._extension.AddCustomRelationalParameterBasedSqlProcessorFactory; + } + + /// + public override void PopulateDebugInfo(IDictionary debugInfo) + { + debugInfo["Thinktecture:MySqlWindowFunctionsSupport"] = _extension.AddWindowFunctionsSupport.ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlBulkOperationSupport"] = _extension.AddBulkOperationSupport.ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlConfigureTempTablesForPrimitiveTypes"] = _extension.ConfigureTempTablesForPrimitiveTypes.ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlCollectionParameterSupport"] = _extension._addCollectionParameterSupport.ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlConfigureCollectionParametersForPrimitiveTypes"] = _extension.ConfigureCollectionParametersForPrimitiveTypes.ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlCollectionParameterJsonSerializerOptions"] = (_extension._collectionParameterJsonSerializerOptions?.GetHashCode() ?? 0).ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlCustomQueryableMethodTranslatingExpressionVisitorFactory"] = _extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory.ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlCustomQuerySqlGeneratorFactory"] = _extension.AddCustomQuerySqlGeneratorFactory.ToString(CultureInfo.InvariantCulture); + debugInfo["Thinktecture:MySqlCustomRelationalParameterBasedSqlProcessorFactory"] = _extension.AddCustomRelationalParameterBasedSqlProcessorFactory.ToString(CultureInfo.InvariantCulture); + } + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionOptions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionOptions.cs new file mode 100644 index 0000000..e5e7641 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionOptions.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.Infrastructure; + +/// +/// Singleton options used by MySQL bulk-operation conventions. +/// +public sealed class MySqlDbContextOptionsExtensionOptions : IBulkOperationsDbContextOptionsExtensionOptions +{ + /// + public bool ConfigureTempTablesForPrimitiveTypes { get; private set; } + + /// + /// Indicates whether primitive collection parameter entity types are configured by convention. + /// + public bool ConfigureCollectionParametersForPrimitiveTypes { get; private set; } + + /// + public void Initialize(IDbContextOptions options) + { + ConfigureTempTablesForPrimitiveTypes = GetExtension(options).ConfigureTempTablesForPrimitiveTypes; + ConfigureCollectionParametersForPrimitiveTypes = GetExtension(options).ConfigureCollectionParametersForPrimitiveTypes; + } + + /// + public void Validate(IDbContextOptions options) + { + if (GetExtension(options).ConfigureTempTablesForPrimitiveTypes != ConfigureTempTablesForPrimitiveTypes) + throw new InvalidOperationException($"The setting '{nameof(MySqlDbContextOptionsExtension.ConfigureTempTablesForPrimitiveTypes)}' has been changed."); + + if (GetExtension(options).ConfigureCollectionParametersForPrimitiveTypes != ConfigureCollectionParametersForPrimitiveTypes) + throw new InvalidOperationException($"The setting '{nameof(MySqlDbContextOptionsExtension.ConfigureCollectionParametersForPrimitiveTypes)}' has been changed."); + } + + private static MySqlDbContextOptionsExtension GetExtension(IDbContextOptions options) + { + return options.FindExtension() + ?? throw new InvalidOperationException($"{nameof(MySqlDbContextOptionsExtension)} not found in current '{nameof(IDbContextOptions)}'."); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConvention.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConvention.cs new file mode 100644 index 0000000..bb1415c --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConvention.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Thinktecture.Internal; + +namespace Thinktecture.EntityFrameworkCore.Parameters; + +internal sealed class MySqlCollectionParameterConvention : IModelInitializedConvention +{ + public static readonly IModelInitializedConvention Instance = new MySqlCollectionParameterConvention(); + + public void ProcessModelInitialized(IConventionModelBuilder modelBuilder, IConventionContext context) + { + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + AddScalarCollectionParameter(modelBuilder); + } + + private static void AddScalarCollectionParameter(IConventionModelBuilder modelBuilder) + { + var entityBuilder = modelBuilder.SharedTypeEntity(EntityNameProvider.GetCollectionParameterName(typeof(T), true), + typeof(ScalarCollectionParameter), + fromDataAnnotation: true); + + if (entityBuilder is null) + return; + + entityBuilder.ToTable(typeof(ScalarCollectionParameter).ShortDisplayName(), fromDataAnnotation: true); + entityBuilder.HasNoKey(fromDataAnnotation: true); + entityBuilder.ExcludeTableFromMigrations(true, fromDataAnnotation: true); + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConventionSetPlugin.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConventionSetPlugin.cs new file mode 100644 index 0000000..f511940 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterConventionSetPlugin.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore.Metadata.Conventions; +using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; +using Thinktecture.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.Parameters; + +internal sealed class MySqlCollectionParameterConventionSetPlugin : IConventionSetPlugin +{ + private readonly MySqlDbContextOptionsExtensionOptions _options; + + public MySqlCollectionParameterConventionSetPlugin(MySqlDbContextOptionsExtensionOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public ConventionSet ModifyConventions(ConventionSet conventionSet) + { + if (_options.ConfigureCollectionParametersForPrimitiveTypes) + conventionSet.ModelInitializedConventions.Add(MySqlCollectionParameterConvention.Instance); + + return conventionSet; + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterFactory.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterFactory.cs new file mode 100644 index 0000000..b733854 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Parameters/MySqlCollectionParameterFactory.cs @@ -0,0 +1,199 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using Thinktecture.Internal; + +namespace Thinktecture.EntityFrameworkCore.Parameters; + +/// +/// Creates collection parameter queries for MySQL using JSON_TABLE. +/// +public sealed class MySqlCollectionParameterFactory : ICollectionParameterFactory +{ + private readonly ConcurrentDictionary _cache = new(); + private readonly JsonSerializerOptions _jsonSerializerOptions; + private readonly ISqlGenerationHelper _sqlGenerationHelper; + private readonly IRelationalTypeMappingSource _typeMappingSource; + + /// + /// Initializes a new instance of . + /// + /// Options used to serialize the collection to JSON. + /// SQL generation helper. + /// Type mapping source. + public MySqlCollectionParameterFactory( + JsonSerializerOptions jsonSerializerOptions, + ISqlGenerationHelper sqlGenerationHelper, + IRelationalTypeMappingSource typeMappingSource) + { + ArgumentNullException.ThrowIfNull(jsonSerializerOptions); + + // MySQL parses JSON numbers as DOUBLE and rejects the .NET formats of TimeSpan/DateTime; these values are sent as strings instead. + _jsonSerializerOptions = new JsonSerializerOptions(jsonSerializerOptions); + _jsonSerializerOptions.Converters.Add(new DecimalStringConverter()); + _jsonSerializerOptions.Converters.Add(new TimeSpanStringConverter()); + _jsonSerializerOptions.Converters.Add(new DateTimeStringConverter()); + _sqlGenerationHelper = sqlGenerationHelper ?? throw new ArgumentNullException(nameof(sqlGenerationHelper)); + _typeMappingSource = typeMappingSource ?? throw new ArgumentNullException(nameof(typeMappingSource)); + } + + /// + public IQueryable CreateScalarQuery(DbContext ctx, IReadOnlyCollection values, bool applyDistinct) + { + var entityName = EntityNameProvider.GetCollectionParameterName(typeof(T), true); + var entityType = ctx.Model.GetEntityType(entityName); + var info = _cache.GetOrAdd(entityType, type => CreateScalarInfo(type)); + var property = entityType.GetProperties().Single(); + // GetValueConverter() returns an explicitly configured converter only; HasConversion() is resolved by the type mapping. + var json = Serialize(values, property.GetValueConverter() ?? property.GetRelationalTypeMapping().Converter); + + return ctx.Set>(entityName) + .FromSqlRaw(applyDistinct ? info.StatementWithDistinct : info.Statement, values.Count, json) + .Select(parameter => parameter.Value); + } + + /// + public IQueryable CreateComplexQuery(DbContext ctx, IReadOnlyCollection objects, bool applyDistinct) + where T : class + { + var entityName = EntityNameProvider.GetCollectionParameterName(typeof(T), false); + var entityType = ctx.Model.GetEntityType(entityName); + var info = _cache.GetOrAdd(entityType, CreateComplexInfo); + var json = JsonSerializer.Serialize(objects, _jsonSerializerOptions); + + return ctx.Set(entityName) + .FromSqlRaw(applyDistinct ? info.StatementWithDistinct : info.Statement, objects.Count, json); + } + + private CollectionParameterInfo CreateScalarInfo(IEntityType entityType) + { + var property = entityType.GetProperties().Single(); + var storeObject = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) + ?? throw new InvalidOperationException($"Could not create StoreObjectIdentifier for table '{entityType.Name}'."); + var columnName = property.GetColumnName(storeObject) + ?? throw new InvalidOperationException($"The property '{property.Name}' has no column name."); + var columnType = GetColumnType(property, storeObject); + var column = _sqlGenerationHelper.DelimitIdentifier(columnName); + var columns = $"{column} {columnType} PATH '$'"; + + return new CollectionParameterInfo( + $"SELECT {column} FROM JSON_TABLE({{1}}, '$[*]' COLUMNS ({columns})) AS `collection` LIMIT {{0}}", + $"SELECT DISTINCT {column} FROM JSON_TABLE({{1}}, '$[*]' COLUMNS ({columns})) AS `collection` LIMIT {{0}}"); + } + + private CollectionParameterInfo CreateComplexInfo(IEntityType entityType) + { + var storeObject = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table) + ?? throw new InvalidOperationException($"Could not create StoreObjectIdentifier for table '{entityType.Name}'."); + var columns = new StringBuilder(); + var selections = new StringBuilder(); + + foreach (var property in entityType.GetProperties()) + { + if (columns.Length != 0) + { + columns.Append(", "); + selections.Append(", "); + } + + var columnName = property.GetColumnName(storeObject) + ?? throw new InvalidOperationException($"The property '{property.Name}' has no column name."); + var jsonColumn = _sqlGenerationHelper.DelimitIdentifier(property.Name); + var jsonPath = _typeMappingSource.FindMapping(typeof(string)) + ?.GenerateSqlLiteral("$." + JsonSerializer.Serialize(GetJsonPropertyName(property), _jsonSerializerOptions)) + ?? throw new InvalidOperationException("MySQL string type mapping is unavailable."); + var columnType = GetColumnType(property, storeObject); + + columns.Append(jsonColumn).Append(' ').Append(columnType).Append(" PATH ").Append(jsonPath); + selections.Append("`collection`.").Append(jsonColumn).Append(" AS ").Append(_sqlGenerationHelper.DelimitIdentifier(columnName)); + } + + var from = $" FROM JSON_TABLE({{1}}, '$[*]' COLUMNS ({columns})) AS `collection` LIMIT {{0}}"; + return new CollectionParameterInfo($"SELECT {selections}{from}", $"SELECT DISTINCT {selections}{from}"); + } + + private string GetColumnType(IProperty property, StoreObjectIdentifier storeObject) + { + return property.GetColumnType(storeObject); + } + + private string GetJsonPropertyName(IProperty property) + { + return property.PropertyInfo?.GetCustomAttribute()?.Name + ?? _jsonSerializerOptions.PropertyNamingPolicy?.ConvertName(property.Name) + ?? property.Name; + } + + private string Serialize(IReadOnlyCollection values, Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter? converter) + { + if (converter is null) + return JsonSerializer.Serialize(values, _jsonSerializerOptions); + + var convertedValues = values.Select(value => converter.ConvertToProvider(value)).ToArray(); + return JsonSerializer.Serialize(convertedValues, _jsonSerializerOptions); + } + + private sealed record CollectionParameterInfo(string Statement, string StatementWithDistinct); + + private sealed class DecimalStringConverter : JsonConverter + { + public override decimal Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType == JsonTokenType.String + ? Decimal.Parse(reader.GetString()!, CultureInfo.InvariantCulture) + : reader.GetDecimal(); + } + + public override void Write(Utf8JsonWriter writer, decimal value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); + } + } + + private sealed class TimeSpanStringConverter : JsonConverter + { + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString()!; + var isNegative = value.StartsWith('-'); + var parts = value.TrimStart('-').Split(':'); + var secondsParts = parts[2].Split('.'); + var ticks = Int64.Parse(parts[0], CultureInfo.InvariantCulture) * TimeSpan.TicksPerHour + + Int64.Parse(parts[1], CultureInfo.InvariantCulture) * TimeSpan.TicksPerMinute + + Int64.Parse(secondsParts[0], CultureInfo.InvariantCulture) * TimeSpan.TicksPerSecond; + + if (secondsParts.Length > 1) + ticks += Int64.Parse(secondsParts[1].PadRight(7, '0')[..7], CultureInfo.InvariantCulture); + + return TimeSpan.FromTicks(isNegative ? -ticks : ticks); + } + + // MySQL TIME accepts hours beyond 23 (up to 838), unlike the .NET constant format "d.hh:mm:ss". + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + var ticks = Math.Abs(value.Ticks); + var text = String.Create(CultureInfo.InvariantCulture, + $"{(value.Ticks < 0 ? "-" : "")}{ticks / TimeSpan.TicksPerHour:00}:{ticks / TimeSpan.TicksPerMinute % 60:00}:{ticks / TimeSpan.TicksPerSecond % 60:00}.{ticks % TimeSpan.TicksPerSecond / 10:000000}"); + writer.WriteStringValue(text); + } + } + + private sealed class DateTimeStringConverter : JsonConverter + { + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateTime.Parse(reader.GetString()!, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + // .NET writes 7 fractional digits and a 'T' separator; MySQL DATETIME(6) takes 6 digits. + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString("yyyy-MM-dd HH:mm:ss.ffffff", CultureInfo.InvariantCulture)); + } + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlAggregateMethodCallTranslatorPlugin.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlAggregateMethodCallTranslatorPlugin.cs new file mode 100644 index 0000000..3f6e0b5 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlAggregateMethodCallTranslatorPlugin.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Query; + +namespace Thinktecture.EntityFrameworkCore.Query.ExpressionTranslators; + +/// +/// Registers MySQL aggregate method translators. +/// +public sealed class MySqlAggregateMethodCallTranslatorPlugin : IAggregateMethodCallTranslatorPlugin +{ + /// + public IEnumerable Translators { get; } + + /// + /// Initializes a new instance of . + /// + /// SQL expression factory. + public MySqlAggregateMethodCallTranslatorPlugin(ISqlExpressionFactory sqlExpressionFactory) + { + ArgumentNullException.ThrowIfNull(sqlExpressionFactory); + Translators = [new MySqlBitwiseAggregateMethodCallTranslator(sqlExpressionFactory)]; + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlBitwiseAggregateMethodCallTranslator.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlBitwiseAggregateMethodCallTranslator.cs new file mode 100644 index 0000000..626adcf --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlBitwiseAggregateMethodCallTranslator.cs @@ -0,0 +1,66 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Thinktecture.EntityFrameworkCore.Query.ExpressionTranslators; + +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +internal sealed class MySqlBitwiseAggregateMethodCallTranslator : IAggregateMethodCallTranslator +{ + private readonly ISqlExpressionFactory _sqlExpressionFactory; + + public MySqlBitwiseAggregateMethodCallTranslator(ISqlExpressionFactory sqlExpressionFactory) + { + _sqlExpressionFactory = sqlExpressionFactory ?? throw new ArgumentNullException(nameof(sqlExpressionFactory)); + } + + /// + public SqlExpression? Translate( + MethodInfo method, + EnumerableExpression source, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (method.DeclaringType != typeof(MySqlAggregateDbFunctionsExtensions)) + return null; + + var functionName = method.Name switch + { + nameof(MySqlAggregateDbFunctionsExtensions.BitOr) => "BIT_OR", + nameof(MySqlAggregateDbFunctionsExtensions.BitAnd) => "BIT_AND", + nameof(MySqlAggregateDbFunctionsExtensions.BitXor) => "BIT_XOR", + _ => null + }; + + if (functionName is null || source.Selector is not SqlExpression selector) + return null; + + if (source.Predicate is not null) + { + var nullValue = _sqlExpressionFactory.Constant(null, selector.Type, selector.TypeMapping); + selector = _sqlExpressionFactory.Case([new CaseWhenClause(source.Predicate, selector)], nullValue); + } + + // MySQL has no DISTINCT for BIT_*; it is a no-op for OR/AND but changes XOR. + if (source.IsDistinct && functionName == "BIT_XOR") + throw new NotSupportedException("MySQL does not support DISTINCT with BIT_XOR."); + + var typeMapping = ((SqlExpression)source.Selector).TypeMapping; + var returnType = method.ReturnType; + var nullableReturnType = returnType.IsValueType && Nullable.GetUnderlyingType(returnType) is null + ? typeof(Nullable<>).MakeGenericType(returnType) + : returnType; + + var bitAggregate = _sqlExpressionFactory.Function(functionName, [selector], nullable: true, argumentsPropagateNullability: [false], returnType: typeof(long)); + var signed = _sqlExpressionFactory.Convert(bitAggregate, nullableReturnType, typeMapping); + + // BIT_* returns BIGINT UNSIGNED (all ones on an empty group); an empty group yields NULL instead. + var count = _sqlExpressionFactory.Function("COUNT", [selector], nullable: false, argumentsPropagateNullability: [false], returnType: typeof(long)); + var isEmpty = _sqlExpressionFactory.Equal(count, _sqlExpressionFactory.Constant(0L)); + var emptyResult = _sqlExpressionFactory.Constant(null, nullableReturnType, typeMapping); + + return _sqlExpressionFactory.Case([new CaseWhenClause(isEmpty, emptyResult)], signed); + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlDbFunctionsTranslator.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlDbFunctionsTranslator.cs new file mode 100644 index 0000000..b8e4ba2 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlDbFunctionsTranslator.cs @@ -0,0 +1,135 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; +using Thinktecture.EntityFrameworkCore.Query.SqlExpressions; +using MySqlFunctions = Thinktecture.MySqlDbFunctionsExtensions; +using MySqlAggregateWindowFunctions = Thinktecture.MySqlWindowAggregateDbFunctionsExtensions; + +namespace Thinktecture.EntityFrameworkCore.Query.ExpressionTranslators; + +/// +/// Translates extension methods on . +/// +public sealed class MySqlDbFunctionsTranslator : IMethodCallTranslator +{ + private readonly ISqlExpressionFactory _sqlExpressionFactory; + private readonly IRelationalTypeMappingSource _typeMappingSource; + private readonly IModel _model; + + internal MySqlDbFunctionsTranslator( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource, + IModel model) + { + _sqlExpressionFactory = sqlExpressionFactory; + _typeMappingSource = typeMappingSource; + _model = model; + } + + /// + public SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(arguments); + + if (method.DeclaringType == typeof(MySqlAggregateWindowFunctions)) + return CreateAggregateWindowFunction(method, arguments); + + if (method.DeclaringType != typeof(MySqlFunctions)) + return null; + + return method.Name switch + { + nameof(MySqlFunctions.WindowFunction) => CreateWindowFunction(arguments), + nameof(MySqlFunctions.NTile) => CreateNTile(arguments), + _ => throw new InvalidOperationException($"Unexpected method '{method.Name}' in '{nameof(MySqlFunctions)}'.") + }; + } + + private SqlExpression CreateAggregateWindowFunction(MethodInfo method, IReadOnlyList arguments) + { + var functionName = method.Name switch + { + nameof(MySqlAggregateWindowFunctions.Sum) => "SUM", + nameof(MySqlAggregateWindowFunctions.Average) => "AVG", + nameof(MySqlAggregateWindowFunctions.Max) => "MAX", + nameof(MySqlAggregateWindowFunctions.Min) => "MIN", + _ => throw new InvalidOperationException($"Unexpected method '{method.Name}' in '{nameof(MySqlAggregateWindowFunctions)}'.") + }; + + var hasOrderBy = arguments[^1] is WindowFunctionOrderingsExpression; + var partitionEnd = hasOrderBy ? arguments.Count - 1 : arguments.Count; + var argument = _sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]); + var partitions = arguments.Skip(2).Take(partitionEnd - 2) + .Select(expression => _sqlExpressionFactory.ApplyDefaultTypeMapping(expression)) + .ToArray(); + var orderings = hasOrderBy ? ((WindowFunctionOrderingsExpression)arguments[^1]).Orderings : null; + + // MAX/MIN return the operand's own type; its mapping carries value converters (e.g. enum to string). + var typeMapping = functionName is "MAX" or "MIN" && argument.TypeMapping is not null + ? argument.TypeMapping + : _typeMappingSource.FindMapping(method.ReturnType, _model); + + return new WindowFunctionExpression(functionName, + false, + method.ReturnType, + typeMapping, + [argument], + partitions, + orderings); + } + + private SqlExpression CreateWindowFunction(IReadOnlyList arguments) + { + var function = (WindowFunction?)((SqlConstantExpression)arguments[1]).Value + ?? throw new ArgumentException("Window function must not be null."); + var orderings = arguments[^1] as WindowFunctionOrderingsExpression; + var partitionBy = arguments[^1] as WindowFunctionPartitionByExpression; + var numberOfArguments = arguments.Count - 2; + + if (orderings is not null) + { + partitionBy = arguments.Count > 3 ? arguments[^2] as WindowFunctionPartitionByExpression : null; + numberOfArguments = arguments.Count - (partitionBy is null ? 3 : 4); + } + else if (partitionBy is not null) + { + numberOfArguments = arguments.Count - 3; + } + + var functionArguments = numberOfArguments > 0 + ? arguments.Skip(2).Take(numberOfArguments).Select(argument => _sqlExpressionFactory.ApplyDefaultTypeMapping(argument)).ToArray() + : Array.Empty(); + + return new WindowFunctionExpression(function.Name, + function.UseAsteriskWhenNoArguments, + function.ReturnType, + _typeMappingSource.FindMapping(function.ReturnType, _model), + functionArguments, + partitionBy?.PartitionBy.Select(argument => _sqlExpressionFactory.ApplyDefaultTypeMapping(argument)).ToArray(), + orderings?.Orderings); + } + + private SqlExpression CreateNTile(IReadOnlyList arguments) + { + var bucketCount = _sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[1]); + var orderings = arguments[^1] as WindowFunctionOrderingsExpression; + var partitionCount = arguments.Count - (orderings is null ? 2 : 3); + var partitionBy = arguments.Skip(2).Take(partitionCount).Select(argument => _sqlExpressionFactory.ApplyDefaultTypeMapping(argument)).ToArray(); + + return new WindowFunctionExpression("NTILE", + false, + typeof(int), + _typeMappingSource.FindMapping(typeof(int), _model), + new[] { bucketCount }, + partitionBy, + orderings?.Orderings); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlMethodCallTranslatorPlugin.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlMethodCallTranslatorPlugin.cs new file mode 100644 index 0000000..ee77ffa --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ExpressionTranslators/MySqlMethodCallTranslatorPlugin.cs @@ -0,0 +1,35 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Storage; +using Thinktecture.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.Query.ExpressionTranslators; + +/// +/// Registers Thinktecture method translators for MySQL. +/// +public sealed class MySqlMethodCallTranslatorPlugin : IMethodCallTranslatorPlugin +{ + /// + public IEnumerable Translators { get; } + + /// + /// Initializes a new instance of . + /// + /// Relational options of the extension. + /// SQL expression factory. + /// Type mapping source. + /// The model. + public MySqlMethodCallTranslatorPlugin( + RelationalDbContextOptionsExtensionOptions options, + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource, + IModel model) + { + ArgumentNullException.ThrowIfNull(options); + + Translators = options.WindowFunctionsSupportEnabled + ? new IMethodCallTranslator[] { new MySqlDbFunctionsTranslator(sqlExpressionFactory, typeMappingSource, model) } + : Array.Empty(); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/MySqlWindowFunctionEvaluatableExpressionFilterPlugin.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/MySqlWindowFunctionEvaluatableExpressionFilterPlugin.cs new file mode 100644 index 0000000..a50d926 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/MySqlWindowFunctionEvaluatableExpressionFilterPlugin.cs @@ -0,0 +1,18 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Prevents MySQL window-function expressions from being evaluated on the client. +/// +public sealed class MySqlWindowFunctionEvaluatableExpressionFilterPlugin : IEvaluatableExpressionFilterPlugin +{ + /// + public bool IsEvaluatableExpression(Expression expression) + { + return expression is not MethodCallExpression methodCallExpression + || methodCallExpression.Method.DeclaringType != typeof(MySqlDbFunctionsExtensions) + || methodCallExpression.Method.Name is not (nameof(MySqlDbFunctionsExtensions.WindowFunction) or nameof(MySqlDbFunctionsExtensions.NTile)); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessor.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessor.cs new file mode 100644 index 0000000..923d7d3 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessor.cs @@ -0,0 +1,37 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Query; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Microting.EntityFrameworkCore.MySql.Query.Internal; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Extends the MySQL parameter-based SQL processor with Thinktecture expressions. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class ThinktectureMySqlParameterBasedSqlProcessor : MySqlParameterBasedSqlProcessor +{ + /// + /// Initializes a new instance of . + /// + /// Processor dependencies. + /// Processor parameters. + /// MySQL provider options. + public ThinktectureMySqlParameterBasedSqlProcessor( + RelationalParameterBasedSqlProcessorDependencies dependencies, + RelationalParameterBasedSqlProcessorParameters parameters, + IMySqlOptions mySqlOptions) + : base(dependencies, parameters, mySqlOptions) + { + } + + /// + protected override Expression ProcessSqlNullability(Expression selectExpression, ParametersCacheDecorator decorator) + { + ArgumentNullException.ThrowIfNull(selectExpression); + ArgumentNullException.ThrowIfNull(decorator); + + return new ThinktectureMySqlSqlNullabilityProcessor(Dependencies, Parameters).Process(selectExpression, decorator); + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessorFactory.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessorFactory.cs new file mode 100644 index 0000000..23507ed --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlParameterBasedSqlProcessorFactory.cs @@ -0,0 +1,34 @@ +using System.Diagnostics.CodeAnalysis; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.Query; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Creates MySQL parameter-based SQL processors with Thinktecture support. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class ThinktectureMySqlParameterBasedSqlProcessorFactory : IRelationalParameterBasedSqlProcessorFactory +{ + private readonly RelationalParameterBasedSqlProcessorDependencies _dependencies; + private readonly IMySqlOptions _mySqlOptions; + + /// + /// Initializes a new instance of . + /// + /// Processor dependencies. + /// MySQL provider options. + public ThinktectureMySqlParameterBasedSqlProcessorFactory( + RelationalParameterBasedSqlProcessorDependencies dependencies, + IMySqlOptions mySqlOptions) + { + _dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies)); + _mySqlOptions = mySqlOptions ?? throw new ArgumentNullException(nameof(mySqlOptions)); + } + + /// + public RelationalParameterBasedSqlProcessor Create(RelationalParameterBasedSqlProcessorParameters parameters) + { + return new ThinktectureMySqlParameterBasedSqlProcessor(_dependencies, parameters, _mySqlOptions); + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGenerator.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGenerator.cs new file mode 100644 index 0000000..d41fd7b --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGenerator.cs @@ -0,0 +1,118 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Microting.EntityFrameworkCore.MySql.Query.ExpressionVisitors.Internal; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.Storage; +using Thinktecture.EntityFrameworkCore.Internal; +using Thinktecture.EntityFrameworkCore.Query.SqlExpressions; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Extends the MySQL SQL generator with Thinktecture expressions. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class ThinktectureMySqlQuerySqlGenerator : MySqlQuerySqlGenerator +{ + /// + /// Initializes a new instance of . + /// + /// Generator dependencies. + /// Type mapping source. + /// MySQL provider options. + public ThinktectureMySqlQuerySqlGenerator( + QuerySqlGeneratorDependencies dependencies, + IRelationalTypeMappingSource typeMappingSource, + IMySqlOptions mySqlOptions) + : base(dependencies, typeMappingSource, mySqlOptions) + { + } + + /// + protected override Expression VisitExtension(Expression extensionExpression) + { + return extensionExpression switch + { + WindowFunctionExpression windowFunctionExpression => VisitWindowFunction(windowFunctionExpression), + _ => base.VisitExtension(extensionExpression) + }; + } + + /// + protected override Expression VisitRowNumber(RowNumberExpression rowNumberExpression) + { + // ROW_NUMBER() is BIGINT UNSIGNED in MySQL, which can't be read as long. + Sql.Append("CAST("); + base.VisitRowNumber(rowNumberExpression); + Sql.Append(" AS signed)"); + return rowNumberExpression; + } + + private Expression VisitWindowFunction(WindowFunctionExpression windowFunctionExpression) + { + // MySQL rejects a space between the name and '(' for built-ins like COUNT/SUM/MAX. + Sql.Append(windowFunctionExpression.Name).Append("("); + + for (var i = 0; i < windowFunctionExpression.Arguments.Count; i++) + { + if (i != 0) + Sql.Append(", "); + + Visit(windowFunctionExpression.Arguments[i]); + } + + if (windowFunctionExpression.Arguments.Count == 0 && windowFunctionExpression.UseAsteriskWhenNoArguments) + Sql.Append("*"); + + Sql.Append(") OVER ("); + + if (windowFunctionExpression.Partitions.Count != 0) + { + Sql.Append("PARTITION BY "); + + for (var i = 0; i < windowFunctionExpression.Partitions.Count; i++) + { + if (i != 0) + Sql.Append(", "); + + Visit(windowFunctionExpression.Partitions[i]); + } + } + + if (windowFunctionExpression.Orderings.Count != 0) + { + Sql.Append(" ORDER BY "); + + for (var i = 0; i < windowFunctionExpression.Orderings.Count; i++) + { + if (i != 0) + Sql.Append(", "); + + VisitOrdering(windowFunctionExpression.Orderings[i]); + } + } + + Sql.Append(")"); + return windowFunctionExpression; + } + + /// + protected override Expression VisitTable(TableExpression tableExpression) + { + ArgumentNullException.ThrowIfNull(tableExpression); + + var tempTable = tableExpression.FindAnnotation(ThinktectureBulkOperationsAnnotationNames.TEMP_TABLE); + + if (tempTable is null) + return base.VisitTable(tableExpression); + + var tempTableName = (string?)tempTable.Value ?? throw new InvalidOperationException("Temp table name cannot be null."); + Sql.Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tempTableName)) + .Append(AliasSeparator) + .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(tableExpression.Alias)); + + return tableExpression; + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGeneratorFactory.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGeneratorFactory.cs new file mode 100644 index 0000000..c4f633b --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQuerySqlGeneratorFactory.cs @@ -0,0 +1,39 @@ +using System.Diagnostics.CodeAnalysis; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Creates MySQL SQL generators with Thinktecture support. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class ThinktectureMySqlQuerySqlGeneratorFactory : IQuerySqlGeneratorFactory +{ + private readonly QuerySqlGeneratorDependencies _dependencies; + private readonly IRelationalTypeMappingSource _typeMappingSource; + private readonly IMySqlOptions _mySqlOptions; + + /// + /// Initializes a new instance of . + /// + /// Generator dependencies. + /// Type mapping source. + /// MySQL provider options. + public ThinktectureMySqlQuerySqlGeneratorFactory( + QuerySqlGeneratorDependencies dependencies, + IRelationalTypeMappingSource typeMappingSource, + IMySqlOptions mySqlOptions) + { + _dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies)); + _typeMappingSource = typeMappingSource ?? throw new ArgumentNullException(nameof(typeMappingSource)); + _mySqlOptions = mySqlOptions ?? throw new ArgumentNullException(nameof(mySqlOptions)); + } + + /// + public QuerySqlGenerator Create() + { + return new ThinktectureMySqlQuerySqlGenerator(_dependencies, _typeMappingSource, _mySqlOptions); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor.cs new file mode 100644 index 0000000..0d98a8f --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor.cs @@ -0,0 +1,49 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Microting.EntityFrameworkCore.MySql.Query.Internal; +using Microsoft.EntityFrameworkCore.Query; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Extends the MySQL queryable method translator with Thinktecture relational and bulk methods. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor : MySqlQueryableMethodTranslatingExpressionVisitor +{ + /// + /// Initializes a new instance of . + /// + /// Queryable translation dependencies. + /// Relational translation dependencies. + /// The query compilation context. + /// MySQL options. + public ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor( + QueryableMethodTranslatingExpressionVisitorDependencies dependencies, + RelationalQueryableMethodTranslatingExpressionVisitorDependencies relationalDependencies, + RelationalQueryCompilationContext queryCompilationContext, + IMySqlOptions mySqlOptions) + : base(dependencies, relationalDependencies, queryCompilationContext, mySqlOptions) + { + } + + private ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor(ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor parent) + : base(parent) + { + } + + /// + protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVisitor() + { + return new ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor(this); + } + + /// + protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) + { + return this.TranslateRelationalMethods(methodCallExpression) + ?? this.TranslateBulkMethods(methodCallExpression) + ?? base.VisitMethodCall(methodCallExpression); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitorFactory.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitorFactory.cs new file mode 100644 index 0000000..2bd87d6 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlQueryableMethodTranslatingExpressionVisitorFactory.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using Microting.EntityFrameworkCore.MySql.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.Query; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Creates MySQL queryable method translators with Thinktecture support. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class ThinktectureMySqlQueryableMethodTranslatingExpressionVisitorFactory : IQueryableMethodTranslatingExpressionVisitorFactory +{ + private readonly QueryableMethodTranslatingExpressionVisitorDependencies _dependencies; + private readonly RelationalQueryableMethodTranslatingExpressionVisitorDependencies _relationalDependencies; + private readonly IMySqlOptions _mySqlOptions; + + /// + /// Initializes a new instance of . + /// + /// Queryable translation dependencies. + /// Relational translation dependencies. + /// MySQL options. + public ThinktectureMySqlQueryableMethodTranslatingExpressionVisitorFactory( + QueryableMethodTranslatingExpressionVisitorDependencies dependencies, + RelationalQueryableMethodTranslatingExpressionVisitorDependencies relationalDependencies, + IMySqlOptions mySqlOptions) + { + _dependencies = dependencies ?? throw new ArgumentNullException(nameof(dependencies)); + _relationalDependencies = relationalDependencies ?? throw new ArgumentNullException(nameof(relationalDependencies)); + _mySqlOptions = mySqlOptions ?? throw new ArgumentNullException(nameof(mySqlOptions)); + } + + /// + public QueryableMethodTranslatingExpressionVisitor Create(QueryCompilationContext queryCompilationContext) + { + return new ThinktectureMySqlQueryableMethodTranslatingExpressionVisitor( + _dependencies, + _relationalDependencies, + (RelationalQueryCompilationContext)queryCompilationContext, + _mySqlOptions); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlSqlNullabilityProcessor.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlSqlNullabilityProcessor.cs new file mode 100644 index 0000000..cb3ed1d --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/Query/ThinktectureMySqlSqlNullabilityProcessor.cs @@ -0,0 +1,42 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Query; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microting.EntityFrameworkCore.MySql.Query.Internal; +using Thinktecture.EntityFrameworkCore.Query.SqlExpressions; + +namespace Thinktecture.EntityFrameworkCore.Query; + +/// +/// Extends the MySQL nullability processor with Thinktecture SQL expressions. +/// +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class ThinktectureMySqlSqlNullabilityProcessor : MySqlSqlNullabilityProcessor +{ + /// + /// Initializes a new instance of . + /// + /// Processor dependencies. + /// Processor parameters. + public ThinktectureMySqlSqlNullabilityProcessor( + RelationalParameterBasedSqlProcessorDependencies dependencies, + RelationalParameterBasedSqlProcessorParameters parameters) + : base(dependencies, parameters) + { + } + + /// + protected override SqlExpression VisitCustomSqlExpression(SqlExpression sqlExpression, bool allowOptimizedExpansion, out bool nullable) + { + switch (sqlExpression) + { + case INotNullableSqlExpression: + nullable = false; + return sqlExpression; + case WindowFunctionExpression: + nullable = true; + return sqlExpression; + default: + return base.VisitCustomSqlExpression(sqlExpression, allowOptimizedExpansion, out nullable); + } + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableCreator.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableCreator.cs new file mode 100644 index 0000000..ffbc6c9 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableCreator.cs @@ -0,0 +1,194 @@ +using System.Data; +using System.Data.Common; +using System.Text; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; +using Thinktecture.Internal; + +namespace Thinktecture.EntityFrameworkCore.TempTables; + +/// +/// Creates MySQL temporary tables. +/// +public sealed class MySqlTempTableCreator : ITempTableCreator +{ + private readonly DbContext _context; + private readonly IDiagnosticsLogger _logger; + private readonly ISqlGenerationHelper _sqlGenerationHelper; + private readonly IRelationalTypeMappingSource _typeMappingSource; + + /// + /// Initializes a new instance of . + /// + /// Current database context. + /// Logger. + /// SQL generation helper. + /// Type mapping source. + public MySqlTempTableCreator( + ICurrentDbContext context, + IDiagnosticsLogger logger, + ISqlGenerationHelper sqlGenerationHelper, + IRelationalTypeMappingSource typeMappingSource) + { + ArgumentNullException.ThrowIfNull(context); + _context = context.Context ?? throw new ArgumentNullException(nameof(context)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _sqlGenerationHelper = sqlGenerationHelper ?? throw new ArgumentNullException(nameof(sqlGenerationHelper)); + _typeMappingSource = typeMappingSource ?? throw new ArgumentNullException(nameof(typeMappingSource)); + } + + /// + public async Task CreateTempTableAsync( + IEntityType entityType, + ITempTableCreationOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(entityType); + ArgumentNullException.ThrowIfNull(options); + + if (entityType.IsOwned()) + throw new NotSupportedException("MySQL temporary tables do not support owned entities."); + + var nameLease = options.TableNameProvider.LeaseName(_context, entityType); + + try + { + var sql = CreateStatement(entityType, nameLease.Name, options); + await _context.Database.OpenConnectionAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (options.TruncateTableIfExists) + { + var dropSql = String.Concat("DROP TEMPORARY TABLE IF EXISTS ", _sqlGenerationHelper.DelimitIdentifier(nameLease.Name), ";"); + await _context.Database.ExecuteSqlRawAsync(dropSql, cancellationToken).ConfigureAwait(false); + } + + await _context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false); + } + catch + { + await _context.Database.CloseConnectionAsync().ConfigureAwait(false); + throw; + } + + return new MySqlTempTableReference(_context.Database, _logger, _sqlGenerationHelper, nameLease.Name, nameLease, options.DropTableOnDispose); + } + catch + { + nameLease.Dispose(); + throw; + } + } + + private string CreateStatement(IEntityType entityType, string tableName, ITempTableCreationOptions options) + { + var properties = options.PropertiesToInclude.DeterminePropertiesForTempTable(entityType); + var primaryKeys = options.PrimaryKeyCreation.GetPrimaryKeyProperties(entityType, properties); + var definitions = new StringBuilder(); + var designTimeModel = _context.GetService().Model; + var isTempTableEntity = entityType.ClrType != typeof(object) && entityType.Name == EntityNameProvider.GetTempTableName(entityType.ClrType); + StoreObjectIdentifier? storeObject = null; + + for (var i = 0; i < properties.Count; i++) + { + var property = properties[i]; + storeObject ??= property.GetStoreObject(); + + if (i != 0) + definitions.AppendLine(","); + + var columnName = property.GetColumnName(storeObject.Value) + ?? throw new InvalidOperationException($"The property '{property.Name}' has no column name."); + var columnType = property.GetColumnType(storeObject.Value); + + definitions.Append(_sqlGenerationHelper.DelimitIdentifier(columnName)) + .Append(' ') + .Append(columnType); + + // A differing collation/charset between a temp table and a regular table fails joins with error 1267. + // The charset and collation exist in the design-time model only. + // The store type decides, not the CLR type: a value-converted enum is a varchar, a string mapped to json has no charset. + var designTimeProperty = SupportsCharSet(columnType) + ? designTimeModel.FindEntityType(entityType.Name)?.FindProperty(property.Name) + : null; + + if (designTimeProperty is not null) + { + var charSet = designTimeProperty.GetCharSet() ?? designTimeModel.GetCharSet(); + var collation = designTimeProperty.GetCollation() ?? designTimeModel.GetCollation(); + + if (!String.IsNullOrEmpty(charSet)) + definitions.Append(" CHARACTER SET ").Append(charSet); + + if (!String.IsNullOrEmpty(collation)) + definitions.Append(" COLLATE ").Append(collation); + } + + definitions.Append(property.IsNullable ? " NULL" : " NOT NULL"); + + // Only registered temp-table entities generate values; a staging copy of a regular entity must keep explicit ids (0 would be renumbered). + if (isTempTableEntity && property.GetValueGenerationStrategy() == MySqlValueGenerationStrategy.IdentityColumn) + { + if (!primaryKeys.Contains(property)) + throw new NotSupportedException($"MySQL requires auto-increment property '{property.Name}' to be part of the temporary table primary key."); + + definitions.Append(" AUTO_INCREMENT"); + } + + var defaultValueSql = property.GetDefaultValueSql(storeObject.Value); + + if (!String.IsNullOrWhiteSpace(defaultValueSql)) + { + definitions.Append(" DEFAULT ").Append(defaultValueSql); + } + else if (property.TryGetDefaultValue(storeObject.Value, out var defaultValue) && defaultValue is not null) + { + var converter = property.GetValueConverter(); + if (converter is not null) + defaultValue = converter.ConvertToProvider(defaultValue); + + if (defaultValue is not null) + { + var mapping = _typeMappingSource.FindMapping(defaultValue.GetType(), columnType) + ?? _typeMappingSource.GetMappingForValue(defaultValue); + definitions.Append(" DEFAULT ").Append(mapping.GenerateSqlLiteral(defaultValue)); + } + } + } + + if (primaryKeys.Count != 0) + { + if (definitions.Length != 0) + definitions.AppendLine(","); + + definitions.Append("PRIMARY KEY (") + .Append(String.Join(", ", primaryKeys.Select(property => + { + var propertyStoreObject = property.GetStoreObject(); + var columnName = property.GetColumnName(propertyStoreObject) + ?? throw new InvalidOperationException($"The property '{property.Name}' has no column name."); + return _sqlGenerationHelper.DelimitIdentifier(columnName); + }))) + .Append(')'); + } + + var tableIdentifier = _sqlGenerationHelper.DelimitIdentifier(tableName); + var createTable = $"CREATE TEMPORARY TABLE {tableIdentifier} ({definitions});"; + + return createTable; + } + + // MySQL accepts CHARACTER SET / COLLATE for character and enumeration columns only (not for json, blob, etc.). + private static bool SupportsCharSet(string columnType) + { + var end = columnType.IndexOfAny(['(', ' ']); + var baseType = end < 0 ? columnType : columnType[..end]; + + return baseType.ToLowerInvariant() is "char" or "varchar" or "tinytext" or "text" or "mediumtext" or "longtext" or "enum" or "set"; + } +} + diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableReference.cs b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableReference.cs new file mode 100644 index 0000000..2625f23 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/EntityFrameworkCore/TempTables/MySqlTempTableReference.cs @@ -0,0 +1,121 @@ +using System.Data; +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; + +namespace Thinktecture.EntityFrameworkCore.TempTables; + +/// +/// Holds a reference to a MySQL temporary table and manages its session-scoped lifetime. +/// +public sealed partial class MySqlTempTableReference : ITempTableReference +{ + private readonly DatabaseFacade _database; + private readonly IDiagnosticsLogger _logger; + private readonly ISqlGenerationHelper _sqlGenerationHelper; + private readonly ITempTableNameLease _nameLease; + private readonly bool _dropTableOnDispose; + private int _isDisposed; + + /// + public string Name { get; } + + internal MySqlTempTableReference( + DatabaseFacade database, + IDiagnosticsLogger logger, + ISqlGenerationHelper sqlGenerationHelper, + string name, + ITempTableNameLease nameLease, + bool dropTableOnDispose) + { + _database = database ?? throw new ArgumentNullException(nameof(database)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _sqlGenerationHelper = sqlGenerationHelper ?? throw new ArgumentNullException(nameof(sqlGenerationHelper)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + _nameLease = nameLease ?? throw new ArgumentNullException(nameof(nameLease)); + _dropTableOnDispose = dropTableOnDispose; + } + + /// + public void Dispose() + { + if (Interlocked.Exchange(ref _isDisposed, 1) != 0) + return; + + try + { + using var command = TryCreateCleanupCommand(); + if (command is null) + return; + + try + { + command.ExecuteNonQuery(); + } + finally + { + _database.CloseConnection(); + } + } + catch (Exception exception) + { + _logger.Logger.LogWarning(exception, "Error during disposal of MySQL temporary table '{Name}'.", Name); + } + finally + { + _nameLease.Dispose(); + } + } + + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _isDisposed, 1) != 0) + return; + + try + { + await using var command = TryCreateCleanupCommand(); + if (command is null) + return; + + try + { + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + finally + { + await _database.CloseConnectionAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + LogTempTableDisposalError(_logger.Logger, ex, Name); + } + finally + { + _nameLease.Dispose(); + } + } + + private DbCommand? TryCreateCleanupCommand() + { + if (!_dropTableOnDispose) + return null; + + var connection = _database.GetDbConnection(); + if (connection.State != ConnectionState.Open) + return null; + + var command = connection.CreateCommand(); + command.CommandText = $"DROP TEMPORARY TABLE IF EXISTS {_sqlGenerationHelper.DelimitIdentifier(Name)};"; + command.Transaction = _database.CurrentTransaction?.GetDbTransaction(); + return command; + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Error during disposal of the temp table reference '{Name}'.")] + private static partial void LogTempTableDisposalError(ILogger logger, Exception ex, string name); +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlAggregateDbFunctionsExtensions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlAggregateDbFunctionsExtensions.cs new file mode 100644 index 0000000..cb255da --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlAggregateDbFunctionsExtensions.cs @@ -0,0 +1,44 @@ +// ReSharper disable once CheckNamespace +namespace Thinktecture; + +/// +/// MySQL aggregate function extensions for . +/// +public static class MySqlAggregateDbFunctionsExtensions +{ + /// + /// Translates to MySQL BIT_OR over the current group. + /// + /// Type of the aggregated values. + /// The EF functions marker. + /// The values of the current group. + /// The bitwise OR of the values in the group. + public static T BitOr(this DbFunctions functions, IEnumerable input) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Translates to MySQL BIT_AND over the current group. + /// + /// Type of the aggregated values. + /// The EF functions marker. + /// The values of the current group. + /// The bitwise AND of the values in the group. + public static T BitAnd(this DbFunctions functions, IEnumerable input) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Translates to MySQL BIT_XOR over the current group. + /// + /// Type of the aggregated values. + /// The EF functions marker. + /// The values of the current group. + /// The bitwise XOR of the values in the group. + public static T BitXor(this DbFunctions functions, IEnumerable input) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlBulkOperationsDbSetExtensions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlBulkOperationsDbSetExtensions.cs new file mode 100644 index 0000000..999f041 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlBulkOperationsDbSetExtensions.cs @@ -0,0 +1,77 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Thinktecture.EntityFrameworkCore.BulkOperations; + +// ReSharper disable once CheckNamespace +namespace Thinktecture; + +/// +/// MySQL query-based bulk-operation extensions for . +/// +public static class MySqlBulkOperationsDbSetExtensions +{ + /// + /// Updates a target set using values from a server-side source query. + /// + /// Type of the entity to update. + /// Type of the source query's entity. + /// Type of the join key. + /// The set to update. + /// Query providing the source rows. + /// Selects the key of the target entity. + /// Selects the key of the source entity. + /// Configures the property assignments. + /// Optional condition restricting the updated rows. + /// Optional options. + /// Cancellation token. + /// The number of affected rows. + public static Task BulkUpdateAsync( + this DbSet target, + IQueryable sourceQuery, + Expression> targetKeySelector, + Expression> sourceKeySelector, + Func, SetPropertyBuilder> setPropertyCalls, + Expression>? filter = null, + MySqlBulkUpdateFromQueryOptions? options = null, + CancellationToken cancellationToken = default) + where TTarget : class + where TSource : class + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(sourceQuery); + ArgumentNullException.ThrowIfNull(targetKeySelector); + ArgumentNullException.ThrowIfNull(sourceKeySelector); + ArgumentNullException.ThrowIfNull(setPropertyCalls); + + return target.GetService() + .BulkUpdateAsync(sourceQuery, targetKeySelector, sourceKeySelector, setPropertyCalls, filter, options, cancellationToken); + } + + /// + /// Inserts values from a server-side source query into the target set. + /// + /// Type of the entity to insert. + /// Type of the source query's entity. + /// The set to insert into. + /// Query providing the source rows. + /// Configures the column mappings. + /// Optional options. + /// Cancellation token. + /// The number of affected rows. + public static Task BulkInsertAsync( + this DbSet target, + IQueryable sourceQuery, + Func, InsertPropertyBuilder> mapPropertyCalls, + MySqlBulkInsertFromQueryOptions? options = null, + CancellationToken cancellationToken = default) + where TTarget : class + where TSource : class + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(sourceQuery); + ArgumentNullException.ThrowIfNull(mapPropertyCalls); + + return target.GetService() + .BulkInsertAsync(sourceQuery, mapPropertyCalls, options, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbContextOptionsBuilderExtensions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbContextOptionsBuilderExtensions.cs new file mode 100644 index 0000000..aea6420 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbContextOptionsBuilderExtensions.cs @@ -0,0 +1,99 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Thinktecture.EntityFrameworkCore.Infrastructure; + +// ReSharper disable once CheckNamespace +namespace Thinktecture; + +/// +/// Thinktecture extensions for . +/// +public static class MySqlDbContextOptionsBuilderExtensions +{ + /// + /// Adds support for window functions. + /// + /// The MySQL options builder. + /// Indication whether to enable the feature. + /// The provided . + public static MySqlDbContextOptionsBuilder AddWindowFunctionsSupport( + this MySqlDbContextOptionsBuilder builder, + bool addWindowFunctionsSupport = true) + { + return AddOrUpdateExtension(builder, extension => + { + extension.AddWindowFunctionsSupport = addWindowFunctionsSupport; + return extension; + }); + } + + /// + /// Adds the custom factory required to translate custom methods like . + /// + /// The MySQL options builder. + /// Indication whether to add the custom factory. + /// The provided . + public static MySqlDbContextOptionsBuilder AddCustomQueryableMethodTranslatingExpressionVisitorFactory( + this MySqlDbContextOptionsBuilder builder, + bool addCustomQueryableMethodTranslatingExpressionVisitorFactory = true) + { + return AddOrUpdateExtension(builder, extension => + { + extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory = addCustomQueryableMethodTranslatingExpressionVisitorFactory; + return extension; + }); + } + + /// + /// Adds support for MySQL bulk inserts. + /// + /// The MySQL options builder. + /// Indication whether to enable the feature. + /// Indication whether to configure temp table entities for primitive CLR types. + /// The provided . + public static MySqlDbContextOptionsBuilder AddBulkOperationSupport( + this MySqlDbContextOptionsBuilder builder, + bool addBulkOperationSupport = true, + bool configureTempTablesForPrimitiveTypes = true) + { + return AddOrUpdateExtension(builder, extension => + { + extension.AddBulkOperationSupport = addBulkOperationSupport; + extension.ConfigureTempTablesForPrimitiveTypes = addBulkOperationSupport && configureTempTablesForPrimitiveTypes; + return extension; + }); + } + + /// + /// Adds support for collection parameters using MySQL JSON_TABLE. + /// + /// The MySQL options builder. + /// JSON serialization options. + /// Indication whether to enable the feature. + /// Indication whether to configure primitive CLR collection types. + /// The provided . + public static MySqlDbContextOptionsBuilder AddCollectionParameterSupport( + this MySqlDbContextOptionsBuilder builder, + JsonSerializerOptions? jsonSerializerOptions = null, + bool addCollectionParameterSupport = true, + bool configureCollectionParametersForPrimitiveTypes = true) + { + return AddOrUpdateExtension(builder, extension => extension.AddCollectionParameterSupport( + addCollectionParameterSupport, + jsonSerializerOptions, + configureCollectionParametersForPrimitiveTypes)); + } + + private static MySqlDbContextOptionsBuilder AddOrUpdateExtension( + MySqlDbContextOptionsBuilder builder, + Func callback) + { + ArgumentNullException.ThrowIfNull(builder); + + var infrastructure = (IRelationalDbContextOptionsBuilderInfrastructure)builder; + var relationalOptions = infrastructure.OptionsBuilder.TryAddExtension(); + infrastructure.OptionsBuilder.AddOrUpdateExtension(callback, () => new MySqlDbContextOptionsExtension(relationalOptions)); + + return builder; + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs new file mode 100644 index 0000000..6d77e6f --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlDbFunctionsExtensions.cs @@ -0,0 +1,502 @@ +using Thinktecture.EntityFrameworkCore; + +// ReSharper disable once CheckNamespace +namespace Thinktecture; + +/// +/// MySQL window-function extensions for . +/// +public static class MySqlDbFunctionsExtensions +{ + /// + /// Defines a window function call. + /// + /// The result type. + /// The EF functions marker. + /// The window function. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines a window function call with ordering. + /// + /// The result type. + /// The EF functions marker. + /// The window function. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines a window function call with one argument. + /// + /// The argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The argument. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, TArgument argument) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function call with two arguments. + /// The first argument type. + /// The second argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function call with three arguments. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function call with four arguments. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function call with five arguments. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The fifth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The fifth argument. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4, T5 argument5) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines a window function call with partitioning and ordering. + /// + /// The result type. + /// The EF functions marker. + /// The window function. + /// The partitioning clause. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, WindowFunctionPartitionByClause partitionBy, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines a window function call with one argument, partitioning, and ordering. + /// + /// The argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The argument. + /// The partitioning clause. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, TArgument argument, WindowFunctionPartitionByClause partitionBy, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function partitioned by columns. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The partitioning clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, WindowFunctionPartitionByClause partitionBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with one argument partitioned by columns. + /// The argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The argument. + /// The partitioning clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, WindowFunctionPartitionByClause partitionBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with two arguments partitioned by columns. + /// The first argument type. + /// The second argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The partitioning clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, WindowFunctionPartitionByClause partitionBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with three arguments partitioned by columns. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The partitioning clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, WindowFunctionPartitionByClause partitionBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with four arguments partitioned by columns. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The partitioning clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4, WindowFunctionPartitionByClause partitionBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with five arguments partitioned by columns. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The fifth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The fifth argument. + /// The partitioning clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4, T5 argument5, WindowFunctionPartitionByClause partitionBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with one argument and ordering. + /// The argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The argument. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with two arguments and ordering. + /// The first argument type. + /// The second argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with three arguments and ordering. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with four arguments and ordering. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with five arguments and ordering. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The fifth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The fifth argument. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4, T5 argument5, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with two arguments, partitioning, and ordering. + /// The first argument type. + /// The second argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The partitioning clause. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, WindowFunctionPartitionByClause partitionBy, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with three arguments, partitioning, and ordering. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The partitioning clause. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, WindowFunctionPartitionByClause partitionBy, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with four arguments, partitioning, and ordering. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The partitioning clause. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4, WindowFunctionPartitionByClause partitionBy, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// Defines a window function with five arguments, partitioning, and ordering. + /// The first argument type. + /// The second argument type. + /// The third argument type. + /// The fourth argument type. + /// The fifth argument type. + /// The result type. + /// The EF functions marker. + /// The window function. + /// The first argument. + /// The second argument. + /// The third argument. + /// The fourth argument. + /// The fifth argument. + /// The partitioning clause. + /// The ordering clause. + /// The window function result. + public static TResult WindowFunction(this DbFunctions functions, WindowFunction windowFunction, T1 argument1, T2 argument2, T3 argument3, T4 argument4, T5 argument5, WindowFunctionPartitionByClause partitionBy, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines an NTILE window function call. + /// + /// The EF functions marker. + /// The number of buckets. + /// The ordering clause. + /// The bucket number. + public static int NTile(this DbFunctions functions, int bucketCount, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines an NTILE window function call without ordering. + /// + /// The EF functions marker. + /// The number of buckets. + /// The bucket number. + public static int NTile(this DbFunctions functions, int bucketCount) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines an NTILE window function call partitioned by one column. + /// + /// The partition column type. + /// The EF functions marker. + /// The number of buckets. + /// The partition column. + /// The bucket number. + public static int NTile(this DbFunctions functions, int bucketCount, TPartition partitionBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + /// Defines an NTILE window function call partitioned by one column and ordered by the provided clause. + /// + /// The partition column type. + /// The EF functions marker. + /// The number of buckets. + /// The partition column. + /// The ordering clause. + /// The bucket number. + public static int NTile(this DbFunctions functions, int bucketCount, TPartition partitionBy, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2, TPartition3 partitionBy3) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2, TPartition3 partitionBy3, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2, TPartition3 partitionBy3, TPartition4 partitionBy4) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2, TPartition3 partitionBy3, TPartition4 partitionBy4, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2, TPartition3 partitionBy3, TPartition4 partitionBy4, TPartition5 partitionBy5) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } + + /// + public static int NTile(this DbFunctions functions, int bucketCount, TPartition1 partitionBy1, TPartition2 partitionBy2, TPartition3 partitionBy3, TPartition4 partitionBy4, TPartition5 partitionBy5, WindowFunctionOrderByClause orderBy) + { + throw new InvalidOperationException("This method is for use with Entity Framework Core only."); + } +} \ No newline at end of file diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlWindowAggregateDbFunctionsExtensions.cs b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlWindowAggregateDbFunctionsExtensions.cs new file mode 100644 index 0000000..ff93adf --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/Extensions/MySqlWindowAggregateDbFunctionsExtensions.cs @@ -0,0 +1,200 @@ +using Thinktecture.EntityFrameworkCore; + +// ReSharper disable once CheckNamespace +namespace Thinktecture; + +/// +/// Windowed aggregate functions for MySQL. +/// +public static class MySqlWindowAggregateDbFunctionsExtensions +{ + private const string _IN_MEMORY_ERROR = "This method is for use with Entity Framework Core only."; + + /// Defines SUM(...) OVER (PARTITION BY ...). + /// The aggregate argument and return type. + /// The partition column type. + /// The EF functions marker. + /// The value to aggregate. + /// The partition column. + /// The windowed sum. + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Sum(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + + /// Defines AVG(...) OVER (PARTITION BY ...). + /// The aggregate argument and return type. + /// The partition column type. + /// The EF functions marker. + /// The value to aggregate. + /// The partition column. + /// The windowed average. + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Average(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + + /// Defines MAX(...) OVER (PARTITION BY ...). + /// The aggregate argument and return type. + /// The partition column type. + /// The EF functions marker. + /// The value to aggregate. + /// The partition column. + /// The windowed maximum. + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Max(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + + /// Defines MIN(...) OVER (PARTITION BY ...). + /// The aggregate argument and return type. + /// The partition column type. + /// The EF functions marker. + /// The value to aggregate. + /// The partition column. + /// The windowed minimum. + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } + /// + public static TAggregate Min(this DbFunctions _, TAggregate columnToAggregate, TColumn1 partitionByColumn1, TColumn2 partitionByColumn2, TColumn3 partitionByColumn3, TColumn4 partitionByColumn4, TColumn5 partitionByColumn5, WindowFunctionOrderByClause orderByClause) { + throw new InvalidOperationException(_IN_MEMORY_ERROR); + } +} diff --git a/src/Thinktecture.EntityFrameworkCore.MySql/Thinktecture.EntityFrameworkCore.MySql.csproj b/src/Thinktecture.EntityFrameworkCore.MySql/Thinktecture.EntityFrameworkCore.MySql.csproj new file mode 100644 index 0000000..320ad62 --- /dev/null +++ b/src/Thinktecture.EntityFrameworkCore.MySql/Thinktecture.EntityFrameworkCore.MySql.csproj @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertAsync.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertAsync.cs new file mode 100644 index 0000000..906dbfe --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertAsync.cs @@ -0,0 +1,2025 @@ +using System.Diagnostics; +using System.Globalization; +using Microsoft.EntityFrameworkCore.Infrastructure; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.TestDatabaseContext; + +#pragma warning disable CS0618 // TableName and Schema are obsolete; these tests cover the obsolete path on purpose. + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +// The executor writes with raw ADO commands (multi-row INSERT or MySqlBulkCopy), so these statements are not visible in ExecutedCommands. +// The tests verify the resulting rows, the return values and, where the number of statements matters, the status counters of the MySQL session. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkInsertAsync : MySqlIntegrationTestsBase +{ + private static readonly Guid _TEST_ENTITY_ID = new("40B5CA93-5C02-48AD-B8A1-12BC13313866"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("8AF163D7-D316-4B2D-A62F-6326A80C8BEE"); + private static readonly Guid _INLINE_OWNER_ID = new("3A1B2FFF-8E11-44E5-80E5-8C7FEEDACEB3"); + private static readonly Guid _SEPARATE_OWNER_ID = new("7C00ABFE-875B-4396-BE51-3E898647A264"); + private static readonly Guid _NESTED_OWNER_ID = new("54FF93FC-6BE9-4F19-A52E-E517CA9FEAA7"); + + private MySqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService(); + + public BulkInsertAsync(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_throw_when_trying_to_insert_temp_table_entity_without_table_name() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await SUT.Awaiting(sut => sut.BulkInsertAsync(new List> { new(0) }, new MySqlBulkInsertOptions())) + .Should().ThrowAsync() + .WithMessage("*configured as a temp table entity*Provide the target table name*"); + } + + [Fact] + public async Task Should_throw_when_type_is_not_in_model() + { + await SUT.Awaiting(sut => sut.BulkInsertAsync(new List { new() }, new MySqlBulkInsertOptions())) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Should_insert_entities() + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + + var testEntities = new[] { testEntity }; + + var affectedRows = await SUT.BulkInsertAsync(testEntities, new MySqlBulkInsertOptions()); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1) + .And.Subject.First() + .Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }); + } + + [Fact] + public async Task Should_insert_column_with_converter() + { + var entities = new List { new() { ConvertibleClass = new ConvertibleClass(42), RequiredName = "RequiredName" } }; + + await SUT.BulkInsertAsync(entities, new MySqlBulkInsertOptions()); + + var entity = await AssertDbContext.TestEntities.SingleAsync(); + + entity.ConvertibleClass.Should().NotBeNull(); + entity.ConvertibleClass!.Key.Should().Be(42); + } + + [Fact] + public async Task Should_insert_private_property() + { + var testEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + testEntity.SetPrivateField(3); + + var testEntities = new[] { testEntity }; + + await SUT.BulkInsertAsync(testEntities, new MySqlBulkInsertOptions()); + + var loadedEntity = await AssertDbContext.TestEntities.FirstOrDefaultAsync(); + loadedEntity!.GetPrivateField().Should().Be(3); + } + + [Fact] + public async Task Should_insert_flags_enum() + { + var testEntity = new TestEntityWithFlags + { + Id = _TEST_ENTITY_ID, + GroupId = 1, + Phase = PhaseMembership.A | PhaseMembership.C + }; + + await SUT.BulkInsertAsync(new[] { testEntity }, new MySqlBulkInsertOptions()); + + var loadedEntity = await AssertDbContext.TestEntitiesWithFlags.FirstOrDefaultAsync(); + loadedEntity.Should().NotBeNull(); + loadedEntity!.Phase.Should().Be(PhaseMembership.A | PhaseMembership.C); + } + + [Fact] + public async Task Should_insert_shadow_properties() + { + var testEntity = new TestEntityWithShadowProperties { Id = _TEST_ENTITY_ID }; + ActDbContext.Entry(testEntity).Property("ShadowStringProperty").CurrentValue = "value"; + ActDbContext.Entry(testEntity).Property("ShadowIntProperty").CurrentValue = 42; + + var testEntities = new[] { testEntity }; + + await SUT.BulkInsertAsync(testEntities, new MySqlBulkInsertOptions()); + + var loadedEntity = await AssertDbContext.TestEntitiesWithShadowProperties.FirstOrDefaultAsync(); + loadedEntity.Should().NotBeNull(); + AssertDbContext.Entry(loadedEntity!).Property("ShadowStringProperty").CurrentValue.Should().Be("value"); + AssertDbContext.Entry(loadedEntity!).Property("ShadowIntProperty").CurrentValue.Should().Be(42); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_specified_properties_only(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + options.PropertiesToInsert = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()); + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42, + PropertyWithBackingField = 7 + }; + testEntity.SetPrivateField(3); + + await SUT.BulkInsertAsync([testEntity], options); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName", + Count = 42, + PropertyWithBackingField = 7 + }); + loadedEntity.GetPrivateField().Should().Be(3); + } + + [Fact] + public async Task Should_accept_options_of_other_providers() + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + + var affectedRows = await SUT.BulkInsertAsync([testEntity], + new GenericBulkOperationOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()) + }); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = null, + RequiredName = "RequiredName", + Count = 42 + }); + } + + [Fact] + public async Task Should_insert_properties_of_base_class() + { + var testEntity = new TestEntityWithBaseClass { Id = _TEST_ENTITY_ID, Name = "Name" }; + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntity = await AssertDbContext.TestEntitiesWithBaseClass.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(testEntity); + } + + [Fact] + public async Task Should_insert_keyless_entities() + { + var affectedRows = await SUT.BulkInsertAsync([new KeylessTestEntity { IntColumn = 1 }, new KeylessTestEntity { IntColumn = 2 }], new MySqlBulkInsertOptions()); + + affectedRows.Should().Be(2); + + var loadedValues = await AssertDbContext.KeylessEntities.Select(e => e.IntColumn).OrderBy(i => i).ToListAsync(); + loadedValues.Should().Equal(1, 2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_entities_with_different_column_names(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + var entities = new[] + { + new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Name 1", Count = 1 }, + new TestEntityWithDifferentColumnNames { Id = _OTHER_TEST_ENTITY_ID, Name = null, Count = 2 } + }; + + var affectedRows = await SUT.BulkInsertAsync(entities, options); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithDifferentColumnNames.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().BeEquivalentTo(entities, config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_insert_row_that_references_another_row_of_the_same_batch() + { + var parent = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Parent" }; + var child = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Child", ParentId = _TEST_ENTITY_ID }; + + // MySQL validates foreign keys row by row, so the parent has to come first. + await SUT.BulkInsertAsync([parent, child], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).ToListAsync(); + loadedEntities.Should().HaveCount(2); + loadedEntities[0].Id.Should().Be(_OTHER_TEST_ENTITY_ID); + loadedEntities[0].ParentId.Should().Be(_TEST_ENTITY_ID); + loadedEntities[1].Id.Should().Be(_TEST_ENTITY_ID); + loadedEntities[1].ParentId.Should().BeNull(); + } + + [Fact] + public async Task Should_insert_auto_increment_column() + { + var testEntity = new TestEntityWithAutoIncrement { Name = "value" }; + var testEntities = new[] { testEntity }; + + await SUT.BulkInsertAsync(testEntities, new MySqlBulkInsertOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Name) + }); + + var loadedEntity = await AssertDbContext.TestEntitiesWithAutoIncrement.FirstOrDefaultAsync(); + loadedEntity.Should().NotBeNull(); + loadedEntity!.Id.Should().NotBe(0); + loadedEntity.Name.Should().Be("value"); + } + + [Fact] + public async Task Should_insert_explicit_value_into_auto_increment_column() + { + var testEntity = new TestEntityWithAutoIncrement { Id = 42, Name = "value" }; + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntity = await AssertDbContext.TestEntitiesWithAutoIncrement.SingleAsync(); + loadedEntity.Id.Should().Be(42); + loadedEntity.Name.Should().Be("value"); + } + + [Fact] + public async Task Should_let_mysql_generate_value_if_zero_is_inserted_into_auto_increment_column() + { + var testEntity = new TestEntityWithAutoIncrement { Id = 0, Name = "value" }; + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + // Without the SQL mode NO_AUTO_VALUE_ON_ZERO, MySQL treats 0 like NULL and generates the next value. + var loadedEntity = await AssertDbContext.TestEntitiesWithAutoIncrement.SingleAsync(); + loadedEntity.Id.Should().NotBe(0); + loadedEntity.Name.Should().Be("value"); + } + + [Fact] + public async Task Should_throw_because_mysql_dont_use_sql_default_value_for_null_in_NOT_NULL_column() + { + var testEntity = new TestEntityWithSqlDefaultValues { Id = _TEST_ENTITY_ID, String = null! }; + + // 1048: column cannot be null + var exception = await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions())) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1048); + } + + [Fact] + public async Task Should_write_provided_column_values_as_is_despite_sql_default_value() + { + var testEntity = new TestEntityWithSqlDefaultValues + { + Id = Guid.Empty, + Int = 0, + String = null!, + NullableInt = null, + NullableString = null + }; + + var options = new MySqlBulkInsertOptions + { + // we skip TestEntityWithSqlDefaultValues.String + PropertiesToInsert = IEntityPropertiesProvider.Include(e => new + { + e.Id, + e.Int, + e.NullableInt, + e.NullableString + }) + }; + + await SUT.BulkInsertAsync([testEntity], options); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDefaultValues.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithSqlDefaultValues + { + Id = Guid.Empty, // persisted as-is + Int = 0, // persisted as-is + NullableInt = null, // persisted as-is + String = "3", // DEFAULT value constraint + NullableString = null // persisted as-is + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_use_sql_default_values_of_columns_that_are_not_inserted(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + options.PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Id); + var testEntity = new TestEntityWithSqlDefaultValues { Id = _TEST_ENTITY_ID }; + + await SUT.BulkInsertAsync([testEntity], options); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDefaultValues.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithSqlDefaultValues + { + Id = _TEST_ENTITY_ID, + Int = 1, + NullableInt = 2, + String = "3", + NullableString = "4" + }); + } + + [Fact] + public async Task Should_use_sql_default_expression_for_primary_key_that_is_not_inserted() + { + var testEntity = new TestEntityWithSqlDefaultValues { Int = 42 }; + + await SUT.BulkInsertAsync([testEntity], + new MySqlBulkInsertOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Int) + }); + + // The default of the column is (UUID()). + var loadedEntity = await AssertDbContext.TestEntitiesWithDefaultValues.SingleAsync(); + loadedEntity.Id.Should().NotBe(Guid.Empty); + loadedEntity.Int.Should().Be(42); + } + + [Fact] + public async Task Should_throw_because_mysql_dont_use_dotnet_default_value_for_null_in_NOT_NULL_column() + { + var testEntity = new TestEntityWithDotnetDefaultValues { Id = _TEST_ENTITY_ID, String = null! }; + + // 1048: column cannot be null + var exception = await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions())) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1048); + } + + [Fact] + public async Task Should_write_provided_column_values_as_is_despite_dotnet_default_value() + { + var testEntity = new TestEntityWithDotnetDefaultValues + { + Id = Guid.Empty, + Int = 0, + String = null!, + NullableInt = null, + NullableString = null + }; + + var options = new MySqlBulkInsertOptions + { + // we skip TestEntityWithDotnetDefaultValues.String + PropertiesToInsert = IEntityPropertiesProvider.Include(e => new + { + e.Id, + e.Int, + e.NullableInt, + e.NullableString + }) + }; + + await SUT.BulkInsertAsync([testEntity], options); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDotnetDefaultValues.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithDotnetDefaultValues + { + Id = Guid.Empty, // persisted as-is + Int = 0, // persisted as-is + NullableInt = null, // persisted as-is + String = "3", // DEFAULT value constraint + NullableString = null // persisted as-is + }); + } + + [Fact] + public async Task Should_use_dotnet_default_values_of_columns_that_are_not_inserted() + { + var testEntity = new TestEntityWithDotnetDefaultValues(); + + await SUT.BulkInsertAsync([testEntity], + new MySqlBulkInsertOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Int) + }); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDotnetDefaultValues.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithDotnetDefaultValues + { + Id = new Guid("0B151271-79BB-4F6C-B85F-E8F61300FF1B"), + Int = 0, + NullableInt = 2, + String = "3", + NullableString = "4" + }); + } + + [Fact] + public async Task Should_insert_inlined_owned_types() + { + var testEntity = new TestEntity_Owns_Inline + { + Id = _INLINE_OWNER_ID, + InlineEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = "value" + } + }; + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_Inline.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity_Owns_Inline + { + Id = _INLINE_OWNER_ID, + InlineEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = "value" + } + }); + } + + [Fact] + public async Task Should_throw_if_no_property_is_selected_for_insert() + { + // MySQL could insert rows from the defaults alone ("INSERT INTO ... () VALUES ()"), but the entity data reader shared by all providers rejects an empty selection. + await SUT.Awaiting(sut => sut.BulkInsertAsync([new TestEntityWithAutoIncrement()], + new MySqlBulkInsertOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Exclude(e => new { e.Id, e.Name }) + })) + .Should().ThrowAsync() + .WithMessage("The properties collection cannot be empty*"); + + (await AssertDbContext.TestEntitiesWithAutoIncrement.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_throw_if_required_inlined_owned_type_is_null() + { + var testEntity = new TestEntity_Owns_Inline + { + Id = _INLINE_OWNER_ID, + InlineEntity = null! + }; + + // 1048: the column of the required owned property cannot be null + var exception = await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions())) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1048); + + (await AssertDbContext.TestEntities_Own_Inline.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateOne() + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = "value" + } + }; + ActDbContext.Add(testEntity); + + var affectedRows = await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateOne.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(testEntity); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateMany() + { + var testEntity = new TestEntity_Owns_SeparateMany + { + Id = _NESTED_OWNER_ID, + SeparateEntities = + [ + new() + { + IntColumn = 42, + StringColumn = "value 1" + }, + + new() + { + IntColumn = 43, + StringColumn = "value 2" + }, + ] + }; + ActDbContext.Add(testEntity); + + var affectedRows = await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + affectedRows.Should().Be(3); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateMany.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(testEntity); + } + + [Theory] + [InlineData(null)] + [InlineData(2)] + public async Task Should_insert_multiple_owners_with_and_without_separate_owned_entity(int? batchSize) + { + var testEntities = Enumerable.Range(1, 5) + .Select(i => new TestEntity_Owns_SeparateOne + { + Id = Guid.NewGuid(), + SeparateEntity = i % 2 == 0 ? null : new OwnedEntity { IntColumn = i, StringColumn = $"value {i}" } + }) + .ToList(); + ActDbContext.AddRange(testEntities); + + var affectedRows = await SUT.BulkInsertAsync(testEntities, new MySqlBulkInsertOptions { BatchSize = batchSize }); + + affectedRows.Should().Be(8, "5 owners and 3 owned entities"); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateOne.ToListAsync(); + loadedEntities.Should().BeEquivalentTo(testEntities); + } + + [Theory] + [InlineData(null)] + [InlineData(2)] + public async Task Should_insert_multiple_owners_with_separate_owned_collections(int? batchSize) + { + var testEntities = new[] + { + new TestEntity_Owns_SeparateMany { Id = Guid.NewGuid(), SeparateEntities = [] }, + new TestEntity_Owns_SeparateMany { Id = Guid.NewGuid(), SeparateEntities = [new() { IntColumn = 1, StringColumn = "value 1" }] }, + new TestEntity_Owns_SeparateMany + { + Id = Guid.NewGuid(), + SeparateEntities = [new() { IntColumn = 2, StringColumn = "value 2" }, new() { IntColumn = 3, StringColumn = null }, new() { IntColumn = 4, StringColumn = "value 4" }] + } + }; + ActDbContext.AddRange(testEntities); + + var affectedRows = await SUT.BulkInsertAsync(testEntities, new MySqlBulkInsertOptions { BatchSize = batchSize }); + + affectedRows.Should().Be(7, "3 owners and 4 owned entities"); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateMany.ToListAsync(); + loadedEntities.Should().BeEquivalentTo(testEntities); + } + + [Fact] + public async Task Should_insert_inlined_owned_type_if_it_has_default_values_only() + { + var testEntity = new TestEntity_Owns_Inline + { + Id = _INLINE_OWNER_ID, + InlineEntity = new OwnedEntity() + }; + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_Inline.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity_Owns_Inline + { + Id = _INLINE_OWNER_ID, + InlineEntity = new OwnedEntity + { + IntColumn = 0, + StringColumn = null + } + }); + } + + [Fact] + public async Task Should_throw_if_separated_owned_type_uses_shadow_property_id_and_is_detached() + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = "value" + } + }; + + await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions())) + .Should().ThrowAsync() + .WithMessage("The entity type 'OwnedEntity' uses a shared type and the supplied entity is currently not being tracked. To start tracking this entity, call '.Reference().TargetEntry' or '.Collection().FindEntry()' on the owner entry."); + + // The executor runs in its own transaction, so the owner row is rolled back. + (await AssertDbContext.TestEntities_Own_SeparateOne.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_throw_if_separated_owned_types_uses_shadow_property_id_and_is_detached() + { + var testEntity = new TestEntity_Owns_SeparateMany + { + Id = _NESTED_OWNER_ID, + SeparateEntities = + [ + new() + { + IntColumn = 42, + StringColumn = "value 1" + }, + ] + }; + + await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions())) + .Should().ThrowAsync() + .WithMessage("The entity type 'OwnedEntity' uses a shared type and the supplied entity is currently not being tracked. To start tracking this entity, call '.Reference().TargetEntry' or '.Collection().FindEntry()' on the owner entry."); + + // The executor runs in its own transaction, so the owner row is rolled back. + (await AssertDbContext.TestEntities_Own_SeparateMany.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_Inline_Inline() + { + var testEntity = new TestEntity_Owns_Inline_Inline + { + Id = _NESTED_OWNER_ID, + InlineEntity = new OwnedEntity_Owns_Inline + { + IntColumn = 42, + StringColumn = "value 1", + InlineEntity = new OwnedEntity + { + IntColumn = 43, + StringColumn = "value 2" + } + } + }; + ActDbContext.Add(testEntity); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_Inline_Inline.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_Inline_SeparateMany() + { + var testEntity = new TestEntity_Owns_Inline_SeparateMany + { + Id = _NESTED_OWNER_ID, + InlineEntity = new OwnedEntity_Owns_SeparateMany + { + IntColumn = 42, + StringColumn = "value 1", + SeparateEntities = + [ + new() + { + IntColumn = 43, + StringColumn = "value 2" + }, + + new() + { + IntColumn = 44, + StringColumn = "value 3" + }, + ] + } + }; + ActDbContext.Add(testEntity); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_Inline_SeparateMany.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_Inline_SeparateOne() + { + var testEntity = new TestEntity_Owns_Inline_SeparateOne + { + Id = _NESTED_OWNER_ID, + InlineEntity = new OwnedEntity_Owns_SeparateOne + { + IntColumn = 42, + StringColumn = "value 1", + SeparateEntity = new() + { + IntColumn = 43, + StringColumn = "value 2" + } + } + }; + + ActDbContext.Add(testEntity); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_Inline_SeparateOne.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateMany_Inline() + { + var testEntity = new TestEntity_Owns_SeparateMany_Inline + { + Id = _NESTED_OWNER_ID, + SeparateEntities = + [ + new() + { + IntColumn = 42, + StringColumn = "value 1", + InlineEntity = new OwnedEntity + { + IntColumn = 43, + StringColumn = "value 2" + } + }, + + new() + { + IntColumn = 44, + StringColumn = "value 3", + InlineEntity = new OwnedEntity + { + IntColumn = 45, + StringColumn = "value 4" + } + }, + ] + }; + + ActDbContext.Add(testEntity); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateMany_Inline.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_throw_on_insert_of_TestEntity_Owns_SeparateMany_SeparateMany() + { + var testEntity = new TestEntity_Owns_SeparateMany_SeparateMany + { + Id = _NESTED_OWNER_ID, + SeparateEntities = + [ + new() + { + IntColumn = 42, + StringColumn = "value 1", + SeparateEntities = + [ + new() + { + IntColumn = 43, + StringColumn = "value 2" + }, + + new() + { + IntColumn = 44, + StringColumn = "value 3" + }, + ] + }, + + new() + { + IntColumn = 45, + StringColumn = "value 4", + SeparateEntities = + [ + new() + { + IntColumn = 46, + StringColumn = "value 5" + }, + + new() + { + IntColumn = 47, + StringColumn = "value 6" + }, + ] + }, + ] + }; + + ActDbContext.Add(testEntity); + + await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions())) + .Should().ThrowAsync().WithMessage("Non-inlined (i.e. with its own table) nested owned type 'Thinktecture.TestDatabaseContext.TestEntity_Owns_SeparateMany_SeparateMany.SeparateEntities#OwnedEntity_Owns_SeparateMany.SeparateEntities' inside another owned type collection 'Thinktecture.TestDatabaseContext.TestEntity_Owns_SeparateMany_SeparateMany.SeparateEntities' is not supported."); + + (await AssertDbContext.TestEntities_Own_SeparateMany_SeparateMany.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_throw_on_insert_of_TestEntity_Owns_SeparateMany_SeparateOne() + { + var testEntity = new TestEntity_Owns_SeparateMany_SeparateOne + { + Id = _NESTED_OWNER_ID, + SeparateEntities = + [ + new() + { + IntColumn = 42, + StringColumn = "value 1", + SeparateEntity = new() + { + IntColumn = 43, + StringColumn = "value 2" + } + }, + + new() + { + IntColumn = 45, + StringColumn = "value 4", + SeparateEntity = new() + { + IntColumn = 46, + StringColumn = "value 5" + } + }, + ] + }; + + ActDbContext.Add(testEntity); + + await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions())) + .Should().ThrowAsync().WithMessage("Non-inlined (i.e. with its own table) nested owned type 'Thinktecture.TestDatabaseContext.TestEntity_Owns_SeparateMany_SeparateOne.SeparateEntities#OwnedEntity_Owns_SeparateOne.SeparateEntity' inside another owned type collection 'Thinktecture.TestDatabaseContext.TestEntity_Owns_SeparateMany_SeparateOne.SeparateEntities' is not supported."); + + (await AssertDbContext.TestEntities_Own_SeparateMany_SeparateOne.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateOne_Inline() + { + var testEntity = new TestEntity_Owns_SeparateOne_Inline + { + Id = _NESTED_OWNER_ID, + SeparateEntity = new() + { + IntColumn = 42, + StringColumn = "value 1", + InlineEntity = new OwnedEntity + { + IntColumn = 43, + StringColumn = "value 2" + } + } + }; + + ActDbContext.Add(testEntity); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateOne_Inline.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateOne_SeparateMany() + { + var testEntity = new TestEntity_Owns_SeparateOne_SeparateMany + { + Id = _NESTED_OWNER_ID, + SeparateEntity = new() + { + IntColumn = 42, + StringColumn = "value 1", + SeparateEntities = + [ + new() + { + IntColumn = 43, + StringColumn = "value 2" + }, + + new() + { + IntColumn = 44, + StringColumn = "value 3" + }, + ] + } + }; + + ActDbContext.Add(testEntity); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateOne_SeparateMany.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateOne_SeparateOne() + { + var testEntity = new TestEntity_Owns_SeparateOne_SeparateOne + { + Id = _NESTED_OWNER_ID, + SeparateEntity = new() + { + IntColumn = 42, + StringColumn = "value 1", + SeparateEntity = new() + { + IntColumn = 43, + StringColumn = "value 2" + } + } + }; + + ActDbContext.Add(testEntity); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateOne_SeparateOne.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_insert_multiple_owners_with_nested_separate_owned_entities() + { + var testEntities = new[] + { + new TestEntity_Owns_SeparateOne_SeparateMany + { + Id = Guid.NewGuid(), + SeparateEntity = new() + { + IntColumn = 1, + StringColumn = "value 1", + SeparateEntities = [new() { IntColumn = 2, StringColumn = "value 2" }, new() { IntColumn = 3, StringColumn = "value 3" }] + } + }, + new TestEntity_Owns_SeparateOne_SeparateMany + { + Id = Guid.NewGuid(), + SeparateEntity = new() + { + IntColumn = 4, + StringColumn = "value 4", + SeparateEntities = [new() { IntColumn = 5, StringColumn = "value 5" }] + } + } + }; + ActDbContext.AddRange(testEntities); + + var affectedRows = await SUT.BulkInsertAsync(testEntities, new MySqlBulkInsertOptions { BatchSize = 1 }); + + affectedRows.Should().Be(7, "2 owners, 2 owned entities and 3 nested owned entities"); + + var loadedEntities = await AssertDbContext.TestEntities_Own_SeparateOne_SeparateMany.ToListAsync(); + loadedEntities.Should().BeEquivalentTo(testEntities); + } + + [Fact] + public async Task Should_insert_TestEntity_with_ComplexType() + { + var testEntity = new TestEntityWithComplexType(_NESTED_OWNER_ID, new BoundaryValueObject(2, 5)); + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions()); + + var loadedEntities = await AssertDbContext.TestEntities_with_ComplexType.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_entity_with_json_column(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + var entity = new TestEntityWithJsonColumns + { + Id = _TEST_ENTITY_ID, + JsonColumn = """{"key": "value"}""" + }; + + var affectedRows = await SUT.BulkInsertAsync([entity], options); + + affectedRows.Should().Be(1); + + // MySQL normalizes the JSON text; the value is already in the normalized form. + var loadedEntity = await AssertDbContext.TestEntitiesWithJsonColumns.SingleAsync(); + loadedEntity.Id.Should().Be(_TEST_ENTITY_ID); + loadedEntity.JsonColumn.Should().Be("""{"key": "value"}"""); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_entity_with_null_json_column(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + var entity = new TestEntityWithJsonColumns + { + Id = _TEST_ENTITY_ID, + JsonColumn = null + }; + + var affectedRows = await SUT.BulkInsertAsync([entity], options); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithJsonColumns.SingleAsync(); + loadedEntity.Id.Should().Be(_TEST_ENTITY_ID); + loadedEntity.JsonColumn.Should().BeNull(); + } + + [Fact] + public async Task Should_insert_into_table_name_override() + { + await CreateTableCopyAsync("TestEntities", "TestEntities_BulkInsertRedirect"); + + try + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableName = "TestEntities_BulkInsertRedirect" }); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(0, "original table should be empty"); + + var redirectedIds = await LoadColumnAsync("TestEntities_BulkInsertRedirect", "Id"); + redirectedIds.Should().Equal(_TEST_ENTITY_ID); + } + finally + { + await DropTablesAsync("TestEntities_BulkInsertRedirect"); + } + } + + [Fact] + public async Task Should_insert_into_table_from_ForRootEntity_provider() + { + await CreateTableCopyAsync("TestEntities", "TestEntities_BulkInsertRedirect"); + + try + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + + await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities_BulkInsertRedirect") }); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(0, "original table should be empty"); + + var redirectedIds = await LoadColumnAsync("TestEntities_BulkInsertRedirect", "Id"); + redirectedIds.Should().Equal(_TEST_ENTITY_ID); + } + finally + { + await DropTablesAsync("TestEntities_BulkInsertRedirect"); + } + } + + [Fact] + public async Task Should_insert_into_table_of_the_database_from_ForRootEntity_provider() + { + // MySQL has no schemas below the database, so the schema of the table name is the name of the database. + var database = await ActDbContext.Database.SqlQueryRaw("SELECT DATABASE() AS `Value`").SingleAsync(); + await CreateTableCopyAsync("TestEntities", "TestEntities_BulkInsertRedirect"); + + try + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName" + }; + + var affectedRows = await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities_BulkInsertRedirect", database) }); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + (await LoadColumnAsync("TestEntities_BulkInsertRedirect", "Id")).Should().Equal(_TEST_ENTITY_ID); + } + finally + { + await DropTablesAsync("TestEntities_BulkInsertRedirect"); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_escape_table_name_of_table_name_provider(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + const string tableName = "TestEntities`; DROP TABLE TestEntities; -- Redirect"; + await CreateTableCopyAsync("TestEntities", tableName); + + try + { + options.TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(tableName); + + var affectedRows = await SUT.BulkInsertAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }], options); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(tableName, "Id")).Should().Equal(_TEST_ENTITY_ID); + } + finally + { + await DropTablesAsync(tableName); + } + } + + // https://github.com/PawelGerr/Thinktecture.EntityFrameworkCore/issues/76 + // The owned entity must not be written to the table of the model. The bulk insert fails when it reaches the owned type. + // The executor runs in its own transaction, so (unlike with PostgreSQL) the owner row is rolled back as well. + [Fact] + public async Task Should_not_insert_separated_owned_entities_into_model_table_when_table_name_is_overridden() + { + await CreateTableCopyAsync("TestEntities_Own_SeparateOne", "TestEntities_Own_SeparateOne_BulkInsertRedirect"); + + try + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity { IntColumn = 42, StringColumn = "value" } + }; + ActDbContext.Add(testEntity); + + await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableName = "TestEntities_Own_SeparateOne_BulkInsertRedirect" })) + .Should().ThrowAsync() + .WithMessage("*override the table of the root entity only*"); + + (await LoadColumnAsync("SeparateEntitiesOne", "TestEntity_Owns_SeparateOneId")) + .Should().BeEmpty("the owned entity must not be written to the table of the model when the owner is redirected"); + (await LoadColumnAsync("TestEntities_Own_SeparateOne_BulkInsertRedirect", "Id")) + .Should().BeEmpty("the executor rolls back the owner row because it runs in its own transaction"); + } + finally + { + await DropTablesAsync("TestEntities_Own_SeparateOne_BulkInsertRedirect"); + } + } + + [Fact] + public async Task Should_throw_when_table_name_is_overridden_and_owned_entities_have_separate_tables() + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity { IntColumn = 42, StringColumn = "value" } + }; + ActDbContext.Add(testEntity); + + await SUT.Awaiting(s => s.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableName = "TestEntities_Own_SeparateOne" })) + .Should().ThrowAsync() + .WithMessage("*'TableName'*'TableNameProvider'*"); + + // The executor runs in its own transaction, so the root row is rolled back when the operation reaches the owned type. + (await LoadColumnAsync("TestEntities_Own_SeparateOne", "Id")).Should().BeEmpty(); + (await LoadColumnAsync("SeparateEntitiesOne", "IntColumn")).Should().BeEmpty(); + } + + [Fact] + public async Task Should_leave_rollback_to_the_caller_if_owned_entity_fails_inside_user_transaction() + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity { IntColumn = 42, StringColumn = "value" } + }; + ActDbContext.Add(testEntity); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + await SUT.Awaiting(s => s.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableName = "TestEntities_Own_SeparateOne" })) + .Should().ThrowAsync() + .WithMessage("*'TableName'*'TableNameProvider'*"); + + // The executor never ends a transaction of the caller. The caller decides about the root row. + (await ActDbContext.Database.SqlQueryRaw("SELECT `Id` AS `Value` FROM `TestEntities_Own_SeparateOne`").ToListAsync()).Should().Equal(_SEPARATE_OWNER_ID); + + await transaction.RollbackAsync(); + + (await LoadColumnAsync("TestEntities_Own_SeparateOne", "Id")).Should().BeEmpty(); + } + + [Fact] + public async Task Should_use_table_name_provider_when_it_is_set_after_table_name() + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName" + }; + var provider = new SuffixTableNameProvider(""); + + var options = new MySqlBulkInsertOptions + { + TableName = "TestEntities", + TableNameProvider = provider + }; + + options.TableNameProvider.Should().BeSameAs(provider); + + await SUT.BulkInsertAsync([testEntity], options); + + provider.RequestedEntityTypes.Should().ContainSingle(); + (await AssertDbContext.TestEntities.ToListAsync()).Should().HaveCount(1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_root_and_owned_entity_into_tables_from_table_name_provider(bool useGenericOptions) + { + await CreateTableCopyAsync("TestEntities_Own_SeparateOne", "TestEntities_Own_SeparateOne_Redirect"); + await CreateTableCopyAsync("SeparateEntitiesOne", "SeparateEntitiesOne_Redirect"); + + try + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity { IntColumn = 42, StringColumn = "value" } + }; + ActDbContext.Add(testEntity); + var provider = new SuffixTableNameProvider("_Redirect"); + IBulkInsertOptions options = useGenericOptions + ? new GenericBulkOperationOptions { TableNameProvider = provider } + : new MySqlBulkInsertOptions { TableNameProvider = provider }; + + var affectedRows = await SUT.BulkInsertAsync([testEntity], options); + + affectedRows.Should().Be(2); + (await LoadColumnAsync("TestEntities_Own_SeparateOne_Redirect", "Id")).Should().BeEquivalentTo([testEntity.Id]); + (await LoadColumnAsync("SeparateEntitiesOne_Redirect", "IntColumn")).Should().BeEquivalentTo([42]); + (await AssertDbContext.TestEntities_Own_SeparateOne.ToListAsync()).Should().BeEmpty(); + (await LoadColumnAsync("SeparateEntitiesOne", "IntColumn")).Should().BeEmpty(); + + provider.RequestedEntityTypes.Should().HaveCount(2); + provider.RequestedEntityTypes.Should().ContainSingle(t => t.FindOwnership() == null && t.ClrType == typeof(TestEntity_Owns_SeparateOne)); + provider.RequestedEntityTypes.Should().ContainSingle(t => t.FindOwnership() != null && t.FindOwnership()!.PrincipalToDependent!.Name == "SeparateEntity"); + } + finally + { + await DropTablesAsync("TestEntities_Own_SeparateOne_Redirect", "SeparateEntitiesOne_Redirect"); + } + } + + [Fact] + public async Task Should_insert_nested_owned_entities_into_tables_from_table_name_provider() + { + await CreateTableCopyAsync("TestEntities_Own_SeparateOne_SeparateOne", "TestEntities_Own_SeparateOne_SeparateOne_Redirect"); + await CreateTableCopyAsync("SeparateEntitiesOne_SeparateOne", "SeparateEntitiesOne_SeparateOne_Redirect"); + await CreateTableCopyAsync("SeparateEntitiesOne_SeparateOne_Inner", "SeparateEntitiesOne_SeparateOne_Inner_Redirect"); + + try + { + var testEntity = new TestEntity_Owns_SeparateOne_SeparateOne + { + Id = _NESTED_OWNER_ID, + SeparateEntity = new() + { + IntColumn = 42, + StringColumn = "value 1", + SeparateEntity = new() { IntColumn = 43, StringColumn = "value 2" } + } + }; + ActDbContext.Add(testEntity); + var provider = new SuffixTableNameProvider("_Redirect"); + + var affectedRows = await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableNameProvider = provider }); + + affectedRows.Should().Be(3); + (await LoadColumnAsync("TestEntities_Own_SeparateOne_SeparateOne_Redirect", "Id")).Should().BeEquivalentTo([testEntity.Id]); + (await LoadColumnAsync("SeparateEntitiesOne_SeparateOne_Redirect", "IntColumn")).Should().BeEquivalentTo([42]); + (await LoadColumnAsync("SeparateEntitiesOne_SeparateOne_Inner_Redirect", "IntColumn")).Should().BeEquivalentTo([43]); + (await AssertDbContext.TestEntities_Own_SeparateOne_SeparateOne.ToListAsync()).Should().BeEmpty(); + (await LoadColumnAsync("SeparateEntitiesOne_SeparateOne", "IntColumn")).Should().BeEmpty(); + (await LoadColumnAsync("SeparateEntitiesOne_SeparateOne_Inner", "IntColumn")).Should().BeEmpty(); + + provider.RequestedEntityTypes.Should().HaveCount(3).And.OnlyHaveUniqueItems(); + } + finally + { + await DropTablesAsync("TestEntities_Own_SeparateOne_SeparateOne_Redirect", "SeparateEntitiesOne_SeparateOne_Redirect", "SeparateEntitiesOne_SeparateOne_Inner_Redirect"); + } + } + + [Fact] + public async Task Should_insert_owned_collection_into_table_from_table_name_provider() + { + await CreateTableCopyAsync("TestEntities_Own_SeparateMany", "TestEntities_Own_SeparateMany_Redirect"); + await CreateTableCopyAsync("SeparateEntitiesMany", "SeparateEntitiesMany_Redirect"); + + try + { + var testEntity = new TestEntity_Owns_SeparateMany + { + Id = _NESTED_OWNER_ID, + SeparateEntities = + [ + new() { IntColumn = 1, StringColumn = "value 1" }, + new() { IntColumn = 2, StringColumn = "value 2" } + ] + }; + ActDbContext.Add(testEntity); + + var affectedRows = await SUT.BulkInsertAsync([testEntity], new MySqlBulkInsertOptions { TableNameProvider = new SuffixTableNameProvider("_Redirect") }); + + affectedRows.Should().Be(3); + (await LoadColumnAsync("TestEntities_Own_SeparateMany_Redirect", "Id")).Should().BeEquivalentTo([testEntity.Id]); + (await LoadColumnAsync("SeparateEntitiesMany_Redirect", "IntColumn")).Should().BeEquivalentTo([1, 2]); + (await AssertDbContext.TestEntities_Own_SeparateMany.ToListAsync()).Should().BeEmpty(); + (await LoadColumnAsync("SeparateEntitiesMany", "IntColumn")).Should().BeEmpty(); + } + finally + { + await DropTablesAsync("TestEntities_Own_SeparateMany_Redirect", "SeparateEntitiesMany_Redirect"); + } + } + + [Fact] + public async Task Should_insert_into_table_name_override_when_separate_owned_entities_are_excluded() + { + await CreateTableCopyAsync("TestEntities_Own_SeparateOne", "TestEntities_Own_SeparateOne_Redirect"); + + try + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity { IntColumn = 42, StringColumn = "value" } + }; + ActDbContext.Add(testEntity); + + var options = new MySqlBulkInsertOptions + { + TableName = "TestEntities_Own_SeparateOne_Redirect", + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Id) + }; + + var affectedRows = await SUT.BulkInsertAsync([testEntity], options); + + affectedRows.Should().Be(1); + (await LoadColumnAsync("TestEntities_Own_SeparateOne_Redirect", "Id")).Should().BeEquivalentTo([testEntity.Id]); + (await LoadColumnAsync("SeparateEntitiesOne", "IntColumn")).Should().BeEmpty(); + } + finally + { + await DropTablesAsync("TestEntities_Own_SeparateOne_Redirect"); + } + } + + [Fact] + public async Task Should_insert_temp_table_entity_when_table_name_is_provided() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + var entity = new TestEntityTempTable + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + + var affectedRows = await SUT.BulkInsertAsync(new[] { entity }, new MySqlBulkInsertOptions { TableName = "TestEntities" }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1); + loadedEntities[0].Id.Should().Be(_TEST_ENTITY_ID); + loadedEntities[0].Name.Should().Be("Name"); + loadedEntities[0].RequiredName.Should().Be("RequiredName"); + loadedEntities[0].Count.Should().Be(42); + } + + [Fact] + public async Task Should_insert_temp_table_entity_with_ForRootEntity_provider() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + var entity = new TestEntityTempTable + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + + var affectedRows = await SUT.BulkInsertAsync(new[] { entity }, new MySqlBulkInsertOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities") }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1); + loadedEntities[0].Id.Should().Be(_TEST_ENTITY_ID); + loadedEntities[0].Name.Should().Be("Name"); + loadedEntities[0].RequiredName.Should().Be("RequiredName"); + loadedEntities[0].Count.Should().Be(42); + } + + // The tests below cover behavior specific to the MySQL executor: batching, command timeout, transactions, MySqlBulkCopy. + + [Fact] + public async Task Should_return_zero_for_empty_input_without_sending_statements() + { + await OpenActConnectionAsync(); + + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + var affectedRows = await SUT.BulkInsertAsync(Array.Empty(), new MySqlBulkInsertOptions()); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + affectedRows.Should().Be(0); + statements.Should().Be(0); + } + + [Fact] + public async Task Should_return_zero_for_empty_input_with_bulk_copy() + { + var options = CreateOptions(true); + + var affectedRows = await SUT.BulkInsertAsync(Array.Empty(), options); + + affectedRows.Should().Be(0); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData(1, null, 1)] + [InlineData(499, null, 1)] + [InlineData(500, null, 1)] // the default batch size + [InlineData(501, null, 2)] + [InlineData(1000, null, 2)] + [InlineData(1001, null, 3)] + [InlineData(10, 1, 10)] + [InlineData(10, 3, 4)] + [InlineData(10, 10, 1)] + [InlineData(10, 100, 1)] + public async Task Should_split_rows_into_statements_according_to_batch_size(int rowCount, int? batchSize, int expectedStatements) + { + var entities = Enumerable.Range(0, rowCount).Select(CreateTestEntity); + await OpenActConnectionAsync(); + + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + var affectedRows = await SUT.BulkInsertAsync(entities, new MySqlBulkInsertOptions { BatchSize = batchSize }); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + affectedRows.Should().Be(rowCount); + statements.Should().Be(expectedStatements); + + var loadedCounts = await AssertDbContext.TestEntities.Select(e => e.Count).OrderBy(c => c).ToListAsync(); + loadedCounts.Should().Equal(Enumerable.Range(0, rowCount)); + } + + [Fact] + public async Task Should_limit_rows_per_statement_to_the_parameter_limit_of_mysql() + { + // MySQL accepts 65,535 parameters per statement, so the number of rows per statement is limited by the number of columns. + var columnCount = ActDbContext.Model.FindEntityType(typeof(TestEntity))!.GetProperties().Count(); + var maximumRows = 65_535 / columnCount; + var entities = Enumerable.Range(0, maximumRows + 1).Select(CreateTestEntity); + await OpenActConnectionAsync(); + + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + var affectedRows = await SUT.BulkInsertAsync(entities, new MySqlBulkInsertOptions { BatchSize = Int32.MaxValue }); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + affectedRows.Should().Be(maximumRows + 1); + statements.Should().Be(2); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(maximumRows + 1); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task Should_throw_if_batch_size_is_not_positive(int batchSize) + { + await SUT.Awaiting(sut => sut.BulkInsertAsync([CreateTestEntity(1)], new MySqlBulkInsertOptions { BatchSize = batchSize })) + .Should().ThrowAsync() + .WithMessage("*batch size*greater than 0*"); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData(false, 500)] + [InlineData(false, 30_000)] + [InlineData(true, 500)] + [InlineData(true, 30_000)] + public async Task Should_insert_entities_with_command_timeout(bool useBulkCopy, int timeoutInMilliseconds) + { + var options = CreateOptions(useBulkCopy); + options.CommandTimeout = TimeSpan.FromMilliseconds(timeoutInMilliseconds); + + var affectedRows = await SUT.BulkInsertAsync([CreateTestEntity(1), CreateTestEntity(2)], options); + + affectedRows.Should().Be(2); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task Should_apply_command_timeout_to_insert_statements() + { + // The uncommitted row of another transaction blocks the insert of the same primary key + // until the lock wait timeout of the server (50 seconds by default) is reached. + await using var blockingTransaction = await ArrangeDbContext.Database.BeginTransactionAsync(); + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Blocking" }); + await ArrangeDbContext.SaveChangesAsync(); + + var stopwatch = Stopwatch.StartNew(); + + await SUT.Awaiting(sut => sut.BulkInsertAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Blocked" }], + new MySqlBulkInsertOptions { CommandTimeout = TimeSpan.FromSeconds(1) })) + .Should().ThrowAsync(); + + stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(20)); + } + + [Fact] + public async Task Should_throw_when_cancellation_is_requested() + { + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await SUT.Awaiting(sut => sut.BulkInsertAsync([CreateTestEntity(1)], new MySqlBulkInsertOptions(), cancellationTokenSource.Token)) + .Should().ThrowAsync(); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_leave_partial_rows_when_a_later_batch_fails() + { + var entities = new[] + { + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "First" }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Second" }, + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Duplicate of the first row" } + }; + + // 1062: duplicate primary key in the third batch + var exception = await SUT.Awaiting(sut => sut.BulkInsertAsync(entities, new MySqlBulkInsertOptions { BatchSize = 1 })) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1062); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_leave_rows_of_a_complete_batch_when_the_last_batch_fails() + { + // The first statement contains 500 valid rows (default batch size), the duplicate is the only row of the second statement. + var entities = Enumerable.Range(0, 500).Select(CreateTestEntity).ToList(); + entities.Add(new TestEntity { Id = entities[0].Id, RequiredName = "Duplicate", Count = 500 }); + + var exception = await SUT.Awaiting(sut => sut.BulkInsertAsync(entities, new MySqlBulkInsertOptions())) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1062); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_not_end_the_transaction_of_the_caller_and_commit_rows_with_it(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await SUT.BulkInsertAsync([CreateTestEntity(1), CreateTestEntity(2)], options); + + affectedRows.Should().Be(2); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "the transaction of the caller is not committed yet"); + + await transaction.CommitAsync(); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_not_insert_rows_if_the_transaction_of_the_caller_is_rolled_back(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await SUT.BulkInsertAsync([CreateTestEntity(1), CreateTestEntity(2)], options); + + affectedRows.Should().Be(2); + + await transaction.RollbackAsync(); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_rows_into_the_nested_transaction_of_the_caller(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + await using var rootTransaction = await ActDbContext.Database.BeginTransactionAsync(); + await using var nestedTransaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await SUT.BulkInsertAsync([CreateTestEntity(1), CreateTestEntity(2)], options); + + affectedRows.Should().Be(2); + + // A nested transaction is logical only; the root transaction decides about the rows. + await nestedTransaction.CommitAsync(); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + + await rootTransaction.RollbackAsync(); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_insert_into_table_from_ForRootEntity_provider_with_bulk_copy() + { + var options = CreateOptions(true); + await CreateTableCopyAsync("TestEntities", "TestEntities_BulkInsertRedirect"); + + try + { + options.TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities_BulkInsertRedirect"); + + var affectedRows = await SUT.BulkInsertAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }], options); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + (await LoadColumnAsync("TestEntities_BulkInsertRedirect", "Id")).Should().Equal(_TEST_ENTITY_ID); + } + finally + { + await DropTablesAsync("TestEntities_BulkInsertRedirect"); + } + } + + [Fact] + public async Task Should_insert_entities_with_bulk_copy() + { + var options = CreateOptions(true); + var entityWithValues = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42, + NullableCount = 7, + ConvertibleClass = new ConvertibleClass(5), + PropertyWithBackingField = 3 + }; + entityWithValues.SetPrivateField(9); + var entityWithNulls = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + Name = null, + RequiredName = "RequiredName", + Count = 0, + NullableCount = null, + ConvertibleClass = null, + ParentId = null + }; + + var affectedRows = await SUT.BulkInsertAsync([entityWithValues, entityWithNulls], options); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().HaveCount(2); + loadedEntities[0].Should().BeEquivalentTo(entityWithNulls); + loadedEntities[1].Should().BeEquivalentTo(entityWithValues); + loadedEntities[1].GetPrivateField().Should().Be(9); + } + + [Fact] + public async Task Should_insert_all_rows_with_single_load_data_statement_and_ignore_batch_size() + { + var options = CreateOptions(true); + options.BatchSize = 1; + await OpenActConnectionAsync(); + + var insertsBefore = await GetSessionStatusAsync("Com_insert"); + var loadsBefore = await GetSessionStatusAsync("Com_load"); + var affectedRows = await SUT.BulkInsertAsync(Enumerable.Range(0, 1001).Select(CreateTestEntity), options); + var inserts = await GetSessionStatusAsync("Com_insert") - insertsBefore; + var loads = await GetSessionStatusAsync("Com_load") - loadsBefore; + + affectedRows.Should().Be(1001); + inserts.Should().Be(0); + loads.Should().Be(1); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(1001); + } + + [Fact] + public async Task Should_insert_flags_enum_with_bulk_copy() + { + var options = CreateOptions(true); + var testEntity = new TestEntityWithFlags + { + Id = _TEST_ENTITY_ID, + GroupId = 1, + Phase = PhaseMembership.A | PhaseMembership.C + }; + + await SUT.BulkInsertAsync([testEntity], options); + + var loadedEntity = await AssertDbContext.TestEntitiesWithFlags.SingleAsync(); + loadedEntity.Phase.Should().Be(PhaseMembership.A | PhaseMembership.C); + } + + [Fact] + public async Task Should_insert_shadow_properties_with_bulk_copy() + { + var options = CreateOptions(true); + var testEntity = new TestEntityWithShadowProperties { Id = _TEST_ENTITY_ID }; + ActDbContext.Entry(testEntity).Property("ShadowStringProperty").CurrentValue = "value"; + ActDbContext.Entry(testEntity).Property("ShadowIntProperty").CurrentValue = null; + + await SUT.BulkInsertAsync([testEntity], options); + + var loadedEntity = await AssertDbContext.TestEntitiesWithShadowProperties.SingleAsync(); + AssertDbContext.Entry(loadedEntity).Property("ShadowStringProperty").CurrentValue.Should().Be("value"); + AssertDbContext.Entry(loadedEntity).Property("ShadowIntProperty").CurrentValue.Should().BeNull(); + } + + [Fact] + public async Task Should_insert_explicit_value_into_auto_increment_column_with_bulk_copy() + { + var options = CreateOptions(true); + + await SUT.BulkInsertAsync([new TestEntityWithAutoIncrement { Id = 42, Name = "value" }], options); + + var loadedEntity = await AssertDbContext.TestEntitiesWithAutoIncrement.SingleAsync(); + loadedEntity.Id.Should().Be(42); + loadedEntity.Name.Should().Be("value"); + } + + [Fact] + public async Task Should_insert_inlined_owned_types_with_bulk_copy() + { + var options = CreateOptions(true); + var testEntity = new TestEntity_Owns_Inline + { + Id = _INLINE_OWNER_ID, + InlineEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = null + } + }; + + await SUT.BulkInsertAsync([testEntity], options); + + var loadedEntity = await AssertDbContext.TestEntities_Own_Inline.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(testEntity); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateOne_with_bulk_copy() + { + var options = CreateOptions(true); + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _SEPARATE_OWNER_ID, + SeparateEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = "value" + } + }; + ActDbContext.Add(testEntity); + + var affectedRows = await SUT.BulkInsertAsync([testEntity], options); + + affectedRows.Should().Be(2); + (await AssertDbContext.TestEntities_Own_SeparateOne.SingleAsync()).Should().BeEquivalentTo(testEntity); + } + + [Fact] + public async Task Should_insert_TestEntity_Owns_SeparateMany_with_bulk_copy() + { + var options = CreateOptions(true); + var testEntity = new TestEntity_Owns_SeparateMany + { + Id = _NESTED_OWNER_ID, + SeparateEntities = + [ + new() { IntColumn = 42, StringColumn = "value 1" }, + new() { IntColumn = 43, StringColumn = null } + ] + }; + ActDbContext.Add(testEntity); + + var affectedRows = await SUT.BulkInsertAsync([testEntity], options); + + affectedRows.Should().Be(3); + (await AssertDbContext.TestEntities_Own_SeparateMany.SingleAsync()).Should().BeEquivalentTo(testEntity); + } + + [Fact] + public async Task Should_insert_TestEntity_with_ComplexType_with_bulk_copy() + { + var options = CreateOptions(true); + var testEntity = new TestEntityWithComplexType(_NESTED_OWNER_ID, new BoundaryValueObject(2, 5)); + + await SUT.BulkInsertAsync([testEntity], options); + + (await AssertDbContext.TestEntities_with_ComplexType.SingleAsync()).Should().BeEquivalentTo(testEntity); + } + + [Fact] + public async Task Should_throw_and_not_leave_rows_if_bulk_copy_writes_null_into_NOT_NULL_column() + { + var options = CreateOptions(true); + var testEntity = new TestEntityWithSqlDefaultValues { Id = _TEST_ENTITY_ID, String = null! }; + + // LOAD DATA LOCAL implies IGNORE, i.e. MySQL replaces the NULL by '' and reports a warning instead of an error. + // The executor turns the warning into an exception and rolls back the rows of its own transaction. + await SUT.Awaiting(sut => sut.BulkInsertAsync([testEntity], options)) + .Should().ThrowAsync() + .WithMessage("MySqlBulkCopy inserted*"); + + (await AssertDbContext.TestEntitiesWithDefaultValues.CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_strings_with_special_characters(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + string?[] names = + [ + null, + "", + " padded ", + "O'Brien", + "say \"hi\"", + "back\\slash", + "trailing backslash\\", + "\\N", + "NULL", + "line1\nline2", + "line1\r\nline2", + "tab\tseparated", + "`backtick`", + "100%_", + "'; DROP TABLE TestEntities; --", + "emoji \U0001F600, \u65E5\u672C\u8A9E" + ]; + var entities = names.Select((name, index) => new TestEntity + { + Id = Guid.NewGuid(), + Name = name, + RequiredName = name ?? "(null)", + Count = index + }) + .ToList(); + + var affectedRows = await SUT.BulkInsertAsync(entities, options); + + affectedRows.Should().Be(names.Length); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => e.Name).Should().Equal(names); + loadedEntities.Select(e => e.RequiredName).Should().Equal(names.Select(name => name ?? "(null)")); + } + + private static TestEntity CreateTestEntity(int count) + { + return new TestEntity + { + Id = Guid.NewGuid(), + RequiredName = $"RequiredName {count}", + Count = count + }; + } + + // MySqlBulkCopy uses LOAD DATA LOCAL INFILE, which the connection has to allow explicitly (the server is started with --local-infile=1). + // This must happen before the first use of the connection of ActDbContext. + private MySqlBulkInsertOptions CreateOptions(bool useBulkCopy) + { + if (useBulkCopy) + { + var connectionString = new MySqlConnectionStringBuilder(ConnectionString) { AllowLoadLocalInfile = true }; + ActDbContext.Database.SetConnectionString(connectionString.ConnectionString); + } + + return new MySqlBulkInsertOptions { UseBulkCopy = useBulkCopy }; + } + + // The status counters are per session. The connection of ActDbContext stays open, so the executor and the status queries share the session. + private async Task OpenActConnectionAsync() + { + await ActDbContext.Database.OpenConnectionAsync(); + } + + private async Task GetSessionStatusAsync(string name) + { + await using var command = ActDbContext.Database.GetDbConnection().CreateCommand(); + command.CommandText = $"SHOW SESSION STATUS LIKE '{name}'"; + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return Int64.Parse(reader.GetString(1), CultureInfo.InvariantCulture); + } + + private async Task> LoadColumnAsync(string tableName, string columnName) + { + return await AssertDbContext.Database.SqlQueryRaw($"SELECT {Quote(columnName)} AS `Value` FROM {Quote(tableName)}").ToListAsync(); + } + + // LIKE copies the columns, the indexes and the primary key, but no foreign keys. + private async Task CreateTableCopyAsync(string sourceTableName, string targetTableName) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"CREATE TABLE {Quote(targetTableName)} LIKE {Quote(sourceTableName)}"); + } + + private async Task DropTablesAsync(params string[] tableNames) + { + foreach (var tableName in tableNames) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"DROP TABLE IF EXISTS {Quote(tableName)}"); + } + } + + private static string Quote(string identifier) + { + return $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`"; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertFromQueryAsync.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertFromQueryAsync.cs new file mode 100644 index 0000000..bbbbe02 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertFromQueryAsync.cs @@ -0,0 +1,969 @@ +using System.Text.RegularExpressions; +using System.Transactions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.TestDatabaseContext; + +#pragma warning disable CS0618 // TableName and Schema are obsolete; one test covers the obsolete path on purpose. + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +// "INSERT INTO target (columns) SELECT ... FROM source" is executed by EF Core (ExecuteSqlRawAsync), so the statement is visible in ExecutedCommands. +// ExecutedCommands contains the setup statements of the tests as well, so the tests pick the statement by its prefix. +// A single statement is atomic, i.e. a failing statement leaves the target table unchanged. +// A MySQL temp table can be referenced once per statement, which is all that "INSERT ... SELECT" needs. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkInsertFromQueryAsync : MySqlIntegrationTestsBase +{ + private const string _REDIRECT_TABLE = "TestEntities_QueryInsertRedirect"; + + private static readonly Guid _TEST_ENTITY_ID = new("40B5CA93-5C02-48AD-B8A1-12BC13313866"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("8AF163D7-D316-4B2D-A62F-6326A80C8BEE"); + private static readonly Guid _THIRD_TEST_ENTITY_ID = new("506E664A-9ADC-4221-9577-71DCFD73DE64"); + private static readonly ConvertibleClass _staticConvertibleClass = new(42); + + private MySqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService(); + + public BulkInsertFromQueryAsync(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_entities_from_temp_table() + { + var sourceEntities = new[] + { + new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "Req1", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Name2", RequiredName = "Req2", Count = 20 } + }; + await using var tempTable = await CreateSourceAsync(sourceEntities); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync(tempTable.Query, MapAllColumns); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().BeEquivalentTo(sourceEntities, config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_insert_multiple_properties() + { + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "TestName", + RequiredName = "ReqName", + Count = 42, + NullableCount = 7, + PropertyWithBackingField = 3 + }; + entity.SetPrivateField(5); + await using var tempTable = await CreateSourceAsync(entity); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + builder => MapAllColumns(builder).Map(e => e.NullableCount, f => f.NullableCount)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.AsNoTracking().SingleAsync(); + loadedEntity.Should().BeEquivalentTo(entity); + loadedEntity.GetPrivateField().Should().Be(5); + } + + [Fact] + public async Task Should_insert_null_values() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = null, RequiredName = "ReqName", NullableCount = null }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + builder => MapAllColumns(builder).Map(e => e.NullableCount, f => f.NullableCount)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().BeNull(); + loadedEntity.NullableCount.Should().BeNull(); + } + + [Fact] + public async Task Should_insert_private_field_via_ef_property() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "Req1", Count = 10 }; + entity.SetPrivateField(42); + await using var tempTable = await CreateSourceAsync(entity); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync(tempTable.Query, MapAllColumns); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.GetPrivateField().Should().Be(42); + } + + [Fact] + public async Task Should_return_correct_affected_row_count() + { + await using var tempTable = await CreateSourceAsync( + new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "Req1", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Name2", RequiredName = "Req2", Count = 2 }, + new TestEntity { Id = _THIRD_TEST_ENTITY_ID, Name = "Name3", RequiredName = "Req3", Count = 3 }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync(tempTable.Query, MapAllColumns); + + affectedRows.Should().Be(3); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(3); + } + + [Fact] + public async Task Should_return_0_if_temp_table_is_empty() + { + await using var tempTable = await CreateSourceAsync(); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync(tempTable.Query, MapAllColumns); + + affectedRows.Should().Be(0); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_return_0_if_source_query_returns_no_rows(bool useConstantPredicate) + { + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source", Count = 5 }); + await ArrangeDbContext.SaveChangesAsync(); + + var sourceQuery = useConstantPredicate + ? ActDbContext.QuerySources.Where(s => false) + : ActDbContext.QuerySources.Where(s => s.Count > 100); + + var affectedRows = await ActDbContext.QueryTargets.BulkInsertAsync( + sourceQuery, + builder => builder.Map(t => t.Id, s => s.Id) + .Map(t => t.Name, s => s.Name) + .Map(t => t.Count, s => s.Count)); + + affectedRows.Should().Be(0); + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_generate_correct_sql_shape() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "Req1" }); + + await ActDbContext.TestEntities.BulkInsertAsync(tempTable.Query, MapAllColumns); + + var statement = GetExecutedStatement("INSERT INTO "); + statement.Should().StartWith("INSERT INTO `TestEntities` (`Id`, `RequiredName`, `Name`, `Count`, `PropertyWithBackingField`, `_privateField`) SELECT "); + statement.Should().Contain($" FROM `{tempTable.Name}` AS `t`"); + statement.Should().NotContain(" WHERE "); + } + + [Fact] + public async Task Should_insert_from_regular_dbset_query() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "OrigName", RequiredName = "ReqName", Count = 99 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Other", RequiredName = "ReqName", Count = 1 }); + await ArrangeDbContext.SaveChangesAsync(); + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + var sourceQuery = ActDbContext.TestEntities + .Where(e => e.Id == _TEST_ENTITY_ID) + .Select(e => new { e.Id, e.RequiredName, e.Name, e.Count }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + sourceQuery, + builder => builder.Map(e => e.Id, f => f.Id) + .Map(e => e.RequiredName, f => f.RequiredName) + .Map(e => e.Name, f => f.Name) + .Map(e => e.Count, f => f.Count) + .Map(e => e.PropertyWithBackingField, f => 1) + .Map(e => EF.Property(e, "_privateField"), f => 2), + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("OrigName"); + (await LoadColumnAsync(_REDIRECT_TABLE, "PropertyWithBackingField")).Should().Equal(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "_privateField")).Should().Equal(2); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(2, "the source table is not changed"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_insert_copies_of_the_rows_of_the_same_table() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "ReqName", Count = 3, PropertyWithBackingField = 4 }; + entity.SetPrivateField(5); + ArrangeDbContext.TestEntities.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + // MySQL materializes the rows to be inserted if source and target are the same table. + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + ActDbContext.TestEntities, + builder => builder.Map(e => e.Id, f => Guid.NewGuid()) + .Map(e => e.RequiredName, f => f.RequiredName) + .Map(e => e.Name, f => f.Name + " (copy)") + .Map(e => e.Count, f => f.Count) + .Map(e => e.PropertyWithBackingField, f => f.PropertyWithBackingField) + .Map(e => EF.Property(e, "_privateField"), f => EF.Property(f, "_privateField"))); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Name).ToListAsync(); + loadedEntities.Should().HaveCount(2); + loadedEntities[0].Should().BeEquivalentTo(entity); + loadedEntities[1].Id.Should().NotBe(Guid.Empty).And.NotBe(_TEST_ENTITY_ID); + loadedEntities[1].Name.Should().Be("Original (copy)"); + loadedEntities[1].Count.Should().Be(3); + loadedEntities[1].PropertyWithBackingField.Should().Be(4); + loadedEntities[1].GetPrivateField().Should().Be(5); + } + + [Fact] + public async Task Should_apply_parameters_of_the_source_query() + { + ArrangeDbContext.QuerySources.AddRange(new MySqlQuerySource { Id = 1, Name = "Source 1", Count = 1 }, + new MySqlQuerySource { Id = 2, Name = "Source 2", Count = 5 }, + new MySqlQuerySource { Id = 3, Name = "Source 3", Count = 6 }, + new MySqlQuerySource { Id = 4, Name = "Source 4", Count = 9 }); + await ArrangeDbContext.SaveChangesAsync(); + var minCount = 5; + var take = 2; + + var affectedRows = await ActDbContext.QueryTargets.BulkInsertAsync( + ActDbContext.QuerySources.Where(s => s.Count >= minCount).OrderBy(s => s.Count).Take(take), + builder => builder.Map(t => t.Id, s => s.Id) + .Map(t => t.Name, s => s.Name) + .Map(t => t.Count, s => s.Count)); + + affectedRows.Should().Be(2); + + var loadedTargets = await AssertDbContext.QueryTargets.OrderBy(t => t.Id).ToListAsync(); + loadedTargets.Select(t => t.Name).Should().Equal("Source 2", "Source 3"); + } + + [Fact] + public async Task Should_insert_entities_in_table_name_override() + { + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "InsertedName", Count = 99 }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + MapAllColumns, + new MySqlBulkInsertFromQueryOptions { TableName = _REDIRECT_TABLE }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("InsertedName"); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_insert_entities_in_table_from_ForRootEntity_provider() + { + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "InsertedName", Count = 99 }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + MapAllColumns, + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("InsertedName"); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + GetExecutedStatement("INSERT INTO ").Should().StartWith($"INSERT INTO `{_REDIRECT_TABLE}` ("); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_insert_entities_in_table_of_the_database_from_ForRootEntity_provider() + { + // MySQL has no schemas below the database, so the schema of the table name is the name of the database (here: the current one). + var database = await ActDbContext.Database.SqlQueryRaw("SELECT DATABASE() AS `Value`").SingleAsync(); + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "InsertedName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + MapAllColumns, + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE, database) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("InsertedName"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact(Skip = "Known limitation: the schema of a table name is dropped by DelimitIdentifier of the MySQL SQL generation helper (Pomelo SchemaBehavior), so the statement runs against the current database without an error.")] + public async Task Should_not_write_to_the_current_database_if_the_schema_of_the_table_name_names_another_database() + { + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "InsertedName" }); + + // The schema of a MySQL table name is the name of the database. The statement has to address that database (or fail), + // it must not silently write to the table of the same name in the current database. + var act = () => ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + MapAllColumns, + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE, "other_database") }); + + await act.Should().ThrowAsync(); + (await LoadColumnAsync(_REDIRECT_TABLE, "Id")).Should().BeEmpty(); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_insert_entities_in_table_from_table_name_provider() + { + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "InsertedName" }); + var provider = new SuffixTableNameProvider("_QueryInsertRedirect"); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + MapAllColumns, + new MySqlBulkInsertFromQueryOptions { TableNameProvider = provider }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("InsertedName"); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + provider.RequestedEntityTypes.Should().ContainSingle().Which.ClrType.Should().Be(typeof(TestEntity)); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_escape_table_name_of_table_name_provider() + { + const string tableName = "TestEntities`; DROP TABLE TestEntities; -- Redirect"; + await CreateTableCopyAsync("TestEntities", tableName); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + MapAllColumns, + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(tableName) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(tableName, "Id")).Should().Equal(_TEST_ENTITY_ID); + } + finally + { + await DropTablesAsync(tableName); + } + } + + [Fact] + public async Task Should_insert_with_constant_value() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "OrigName", RequiredName = "ReqName", Count = 99 }); + await ArrangeDbContext.SaveChangesAsync(); + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + var sourceQuery = ActDbContext.TestEntities.Select(e => new { e.Id, e.RequiredName, e.Name }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + sourceQuery, + builder => builder.Map(e => e.Id, f => f.Id) + .Map(e => e.RequiredName, f => f.RequiredName) + .Map(e => e.Name, f => f.Name) + .Map(e => e.Count, f => 42) + .Map(e => e.PropertyWithBackingField, f => 0) + .Map(e => EF.Property(e, "_privateField"), f => 0), + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Count")).Should().Equal(42); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_insert_with_captured_variable() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "OrigName", RequiredName = "ReqName", Count = 99 }); + await ArrangeDbContext.SaveChangesAsync(); + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + var sourceQuery = ActDbContext.TestEntities.Select(e => new { e.Id, e.RequiredName, e.Count }); + var capturedName = "CapturedValue"; + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + sourceQuery, + builder => builder.Map(e => e.Id, f => f.Id) + .Map(e => e.RequiredName, f => f.RequiredName) + .Map(e => e.Name, f => capturedName) + .Map(e => e.Count, f => f.Count) + .Map(e => e.PropertyWithBackingField, f => 0) + .Map(e => EF.Property(e, "_privateField"), f => 0), + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("CapturedValue"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_insert_captured_string_with_special_characters_as_is() + { + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source", Count = 1 }); + await ArrangeDbContext.SaveChangesAsync(); + var capturedName = "O'Reilly \\ \"quoted\" `tick`; DROP TABLE QueryTargets; -- \\'"; + + var affectedRows = await ActDbContext.QueryTargets.BulkInsertAsync( + ActDbContext.QuerySources, + builder => builder.Map(t => t.Id, s => s.Id) + .Map(t => t.Name, s => capturedName) + .Map(t => t.Count, s => s.Count)); + + affectedRows.Should().Be(1); + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be(capturedName); + } + + // The literal "{0}" is part of the generated SQL. It must neither be rejected nor be replaced by a parameter, which happens to the placeholders of a format string. + [Fact] + public async Task Should_insert_from_source_query_that_contains_curly_braces_in_string_literal() + { + await ArrangeSourcesWithCurlyBracesAsync(); + + var affectedRows = await ActDbContext.QueryTargets.BulkInsertAsync( + ActDbContext.QuerySources.Where(s => s.Name != "{0}"), + builder => builder.Map(t => t.Id, s => s.Id) + .Map(t => t.Name, s => s.Name) + .Map(t => t.Count, s => s.Count)); + + affectedRows.Should().Be(2); + (await AssertDbContext.QueryTargets.OrderBy(t => t.Id).Select(t => t.Id).ToListAsync()).Should().Equal(2, 3); + } + + [Fact] + public async Task Should_insert_from_source_query_that_contains_curly_braces_in_string_literal_if_a_constant_is_mapped() + { + await ArrangeSourcesWithCurlyBracesAsync(); + + var affectedRows = await ActDbContext.QueryTargets.BulkInsertAsync( + ActDbContext.QuerySources.Where(s => s.Name != "{0}"), + builder => builder.Map(t => t.Id, s => s.Id) + .Map(t => t.Name, s => s.Name) + .Map(t => t.Count, s => 42)); + + affectedRows.Should().Be(2); + (await AssertDbContext.QueryTargets.OrderBy(t => t.Id).Select(t => t.Id).ToListAsync()).Should().Equal(2, 3); + } + + [Fact] + public async Task Should_insert_with_arithmetic_expression() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "OrigName", RequiredName = "ReqName", Count = 7 }); + await ArrangeDbContext.SaveChangesAsync(); + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + var sourceQuery = ActDbContext.TestEntities.Select(e => new { e.Id, e.RequiredName, e.Name, e.Count }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + sourceQuery, + builder => builder.Map(e => e.Id, f => f.Id) + .Map(e => e.RequiredName, f => f.RequiredName) + .Map(e => e.Name, f => f.Name) + .Map(e => e.Count, f => f.Count * 2 + 1) + .Map(e => e.PropertyWithBackingField, f => 0) + .Map(e => EF.Property(e, "_privateField"), f => 0), + new MySqlBulkInsertFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Count")).Should().Equal(15); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_insert_with_string_to_upper() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "lowercase", RequiredName = "ReqName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + builder => builder.Map(e => e.Id, f => f.Id) + .Map(e => e.RequiredName, f => f.RequiredName) + .Map(e => e.Name, f => f.Name!.ToUpper()) + .Map(e => e.Count, f => f.Count) + .Map(e => e.PropertyWithBackingField, f => f.PropertyWithBackingField) + .Map(e => EF.Property(e, "_privateField"), f => EF.Property(f, "_privateField"))); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("LOWERCASE"); + } + + [Fact] + public async Task Should_insert_with_static_member_of_converted_type() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Req1" }); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + builder => MapAllColumns(builder).Map(e => e.ConvertibleClass, f => _staticConvertibleClass)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.ConvertibleClass).SingleAsync()).Should().Be(new ConvertibleClass(42)); + (await LoadColumnAsync("TestEntities", "ConvertibleClass")).Should().Equal(42); + } + + [Fact] + public async Task Should_insert_with_captured_variable_of_converted_type() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Req1" }); + var capturedValue = new ConvertibleClass(43); + + var affectedRows = await ActDbContext.TestEntities.BulkInsertAsync( + tempTable.Query, + builder => MapAllColumns(builder).Map(e => e.ConvertibleClass, f => capturedValue)); + + affectedRows.Should().Be(1); + (await LoadColumnAsync("TestEntities", "ConvertibleClass")).Should().Equal(43); + } + + [Fact] + public async Task Should_insert_captured_values_of_different_types() + { + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source", Count = 1 }); + await ArrangeDbContext.SaveChangesAsync(); + var id = _TEST_ENTITY_ID; + var amount = 1234.5678m; + var payload = """{"key": "value"}"""; + var createdAt = new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Unspecified).AddTicks(1234560); // datetime(6): 123456 microseconds + var data = new byte[] { 1, 2, 3 }; + + var affectedRows = await ActDbContext.TypedEntities.BulkInsertAsync( + ActDbContext.QuerySources, + builder => builder.Map(e => e.Id, f => id) + .Map(e => e.Amount, f => amount) + .Map(e => e.Payload, f => payload) + .Map(e => e.CreatedAt, f => createdAt) + .Map(e => e.Status, f => MySqlTestStatus.Published) + .Map(e => e.Data, f => data) + .Map(e => e.Flag, f => true)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TypedEntities.SingleAsync(); + loadedEntity.Id.Should().Be(id); + loadedEntity.Amount.Should().Be(amount); + loadedEntity.Payload.Should().Be(payload); + loadedEntity.CreatedAt.Should().Be(createdAt); + loadedEntity.Status.Should().Be(MySqlTestStatus.Published); + loadedEntity.Data.Should().Equal(data); + loadedEntity.Flag.Should().BeTrue(); + } + + [Fact] + public async Task Should_translate_server_side_functions_instead_of_evaluating_them() + { + ArrangeDbContext.QuerySources.AddRange(new MySqlQuerySource { Id = 1, Name = "Source 1", Count = 1 }, + new MySqlQuerySource { Id = 2, Name = "Source 2", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await ActDbContext.TypedEntities.BulkInsertAsync( + ActDbContext.QuerySources, + builder => builder.Map(e => e.Id, f => Guid.NewGuid()) + .Map(e => e.Amount, f => 1m) + .Map(e => e.CreatedAt, f => DateTime.UtcNow) + .Map(e => e.Status, f => MySqlTestStatus.Draft) + .Map(e => e.Flag, f => false)); + + affectedRows.Should().Be(2); + + var statement = GetExecutedStatement("INSERT INTO "); + statement.Should().Contain("UUID()"); + statement.Should().ContainAny("UTC_TIMESTAMP", "NOW("); + + var loadedEntities = await AssertDbContext.TypedEntities.OrderBy(e => e.Id).ToListAsync(); + loadedEntities.Should().HaveCount(2); + loadedEntities.Select(e => e.Id).Should().OnlyHaveUniqueItems().And.NotContain(Guid.Empty); + loadedEntities.Select(e => e.CreatedAt.Year).Should().OnlyContain(year => year == DateTime.UtcNow.Year); + } + + [Fact] + public async Task Should_use_sql_default_values_of_unmapped_columns() + { + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "FromSource", Count = 7 }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await ActDbContext.TestEntitiesWithDefaultValues.BulkInsertAsync( + ActDbContext.QuerySources, + builder => builder.Map(e => e.NullableString, f => f.Name)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDefaultValues.SingleAsync(); + loadedEntity.Id.Should().NotBe(Guid.Empty); + loadedEntity.Int.Should().Be(1); + loadedEntity.NullableInt.Should().Be(2); + loadedEntity.String.Should().Be("3"); + loadedEntity.NullableString.Should().Be("FromSource"); + } + + [Fact] + public async Task Should_let_mysql_generate_auto_increment_values() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "Req1" }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Name2", RequiredName = "Req2" }); + + var affectedRows = await ActDbContext.TestEntitiesWithAutoIncrement.BulkInsertAsync( + tempTable.Query, + builder => builder.Map(e => e.Name, f => f.Name)); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithAutoIncrement.OrderBy(e => e.Name).ToListAsync(); + loadedEntities.Select(e => e.Name).Should().Equal("Name1", "Name2"); + loadedEntities.Select(e => e.Id).Should().OnlyHaveUniqueItems().And.OnlyContain(id => id > 0); + } + + [Fact] + public async Task Should_insert_into_table_with_different_column_names() + { + ConfigureModel = ConfigureTempSourceWithMappedColumns; + await using var tempTable = await CreateSourceAsync(new TempSourceWithMappedColumns { Id = _TEST_ENTITY_ID, Name = "Name1", Count = 1 }, + new TempSourceWithMappedColumns { Id = _OTHER_TEST_ENTITY_ID, Name = null, Count = 2 }); + + var affectedRows = await ActDbContext.TestEntitiesWithDifferentColumnNames.BulkInsertAsync( + tempTable.Query, + builder => builder.Map(e => e.Id, f => f.Id) + .Map(e => e.Name, f => f.Name) + .Map(e => e.Count, f => f.Count * 10)); + + affectedRows.Should().Be(2); + + var statement = GetExecutedStatement("INSERT INTO "); + // The alias of the temp table entity is derived from its name, so it is not part of the expectation. + statement.Should().Contain("(`entity_id`, `display_name`, `item_count`) SELECT "); + statement.Should().MatchRegex(@"SELECT `[^`]+`\.`entity_id`, `[^`]+`\.`display_name`, `[^`]+`\.`item_count` \* 10 FROM "); + + var loadedEntities = await AssertDbContext.TestEntitiesWithDifferentColumnNames.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => e.Id).Should().Equal(_TEST_ENTITY_ID, _OTHER_TEST_ENTITY_ID); + loadedEntities.Select(e => e.Name).Should().Equal("Name1", null); + loadedEntities.Select(e => e.Count).Should().Equal(10, 20); + } + + [Fact] + public async Task Should_throw_if_no_map_entries() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var act = () => ActDbContext.TestEntities.BulkInsertAsync(tempTable.Query, builder => builder); + + await act.Should().ThrowAsync().WithMessage("*at least one*"); + } + + [Fact] + public async Task Should_throw_if_target_property_is_not_mapped() + { + var act = () => ActDbContext.TestEntities.BulkInsertAsync(ActDbContext.TestEntities, builder => builder.Map(e => e.Parent, f => f.Parent)); + + await act.Should().ThrowAsync().WithMessage("*'Parent' is not a mapped property*"); + } + + [Fact] + public async Task Should_throw_if_target_selector_is_not_a_simple_property_access() + { + var act = () => ActDbContext.TestEntities.BulkInsertAsync(ActDbContext.TestEntities, builder => builder.Map(e => e.Name!.Length, f => f.Count)); + + await act.Should().ThrowAsync().WithMessage("*Only simple property access*"); + } + + [Fact] + public async Task Should_throw_if_value_expression_cannot_be_translated() + { + var act = () => ActDbContext.TestEntities.BulkInsertAsync(ActDbContext.TestEntities, builder => builder.Map(e => e.Name, f => NotTranslatable(f.Name))); + + await act.Should().ThrowAsync().WithMessage("*Could not translate value expression*"); + } + + [Fact] + public async Task Should_throw_if_not_null_column_without_default_value_is_not_mapped() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + // 1364: field doesn't have a default value (strict mode); PropertyWithBackingField and _privateField are NOT NULL + var exception = await ActDbContext.TestEntities + .Awaiting(set => set.BulkInsertAsync(tempTable.Query, + builder => builder.Map(e => e.Id, f => f.Id) + .Map(e => e.RequiredName, f => f.RequiredName))) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1364); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_insert_any_row_if_the_statement_fails() + { + ArrangeDbContext.Entities.Add(new MySqlTestEntity { Id = 1, Name = "Existing" }); + ArrangeDbContext.QuerySources.AddRange(new MySqlQuerySource { Id = 2, Name = "New", Count = 1 }, + new MySqlQuerySource { Id = 3, Name = "Existing", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + + // 1062: the name of the second source row violates the unique index of MySqlTestEntity.Name + var exception = await ActDbContext.Entities + .Awaiting(set => set.BulkInsertAsync(ActDbContext.QuerySources, + builder => builder.Map(e => e.Id, s => s.Id) + .Map(e => e.Name, s => s.Name))) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1062); + + (await AssertDbContext.Entities.Select(e => e.Name).ToListAsync()).Should().Equal("Existing"); + } + + [Fact] + public async Task Should_throw_when_cancellation_is_requested() + { + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source", Count = 1 }); + await ArrangeDbContext.SaveChangesAsync(); + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + var act = () => ActDbContext.QueryTargets.BulkInsertAsync( + ActDbContext.QuerySources, + builder => builder.Map(t => t.Id, s => s.Id) + .Map(t => t.Name, s => s.Name) + .Map(t => t.Count, s => s.Count), + cancellationToken: cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_end_the_transaction_of_the_caller_and_commit_rows_with_it() + { + await ArrangeSourcesAsync(); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await InsertTargetsAsync(); + + affectedRows.Should().Be(2); + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(0, "the transaction of the caller is not committed yet"); + + await transaction.CommitAsync(); + + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task Should_not_insert_rows_if_the_transaction_of_the_caller_is_rolled_back() + { + await ArrangeSourcesAsync(); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await InsertTargetsAsync(); + + affectedRows.Should().Be(2); + + await transaction.RollbackAsync(); + + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_insert_rows_into_the_nested_transaction_of_the_caller() + { + await ArrangeSourcesAsync(); + await using var rootTransaction = await ActDbContext.Database.BeginTransactionAsync(); + await using var nestedTransaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await InsertTargetsAsync(); + + affectedRows.Should().Be(2); + + // A nested transaction is logical only; the root transaction decides about the rows. + await nestedTransaction.CommitAsync(); + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(0); + + await rootTransaction.RollbackAsync(); + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_insert_and_commit_inside_a_transaction_scope() + { + await ArrangeSourcesAsync(); + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var affectedRows = await InsertTargetsAsync(); + + affectedRows.Should().Be(2); + scope.Complete(); + } + + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(2); + } + + [Fact] + public async Task Should_roll_back_the_insert_if_the_transaction_scope_is_not_completed() + { + await ArrangeSourcesAsync(); + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var affectedRows = await InsertTargetsAsync(); + + affectedRows.Should().Be(2); + } + + (await AssertDbContext.QueryTargets.CountAsync()).Should().Be(0); + } + + private async Task ArrangeSourcesWithCurlyBracesAsync() + { + ArrangeDbContext.QuerySources.AddRange(new MySqlQuerySource { Id = 1, Name = "{0}", Count = 1 }, + new MySqlQuerySource { Id = 2, Name = """{"key": 1}""", Count = 2 }, + new MySqlQuerySource { Id = 3, Name = "other", Count = 3 }); + await ArrangeDbContext.SaveChangesAsync(); + } + + private async Task ArrangeSourcesAsync() + { + ArrangeDbContext.QuerySources.AddRange(new MySqlQuerySource { Id = 1, Name = "Source 1", Count = 1 }, + new MySqlQuerySource { Id = 2, Name = "Source 2", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + } + + private Task InsertTargetsAsync() + { + return ActDbContext.QueryTargets.BulkInsertAsync( + ActDbContext.QuerySources, + builder => builder.Map(t => t.Id, s => s.Id) + .Map(t => t.Name, s => s.Name) + .Map(t => t.Count, s => s.Count)); + } + + private static string NotTranslatable(string? value) + { + return value ?? String.Empty; + } + + private static InsertPropertyBuilder MapAllColumns(InsertPropertyBuilder builder) + { + return builder.Map(e => e.Id, f => f.Id) + .Map(e => e.RequiredName, f => f.RequiredName) + .Map(e => e.Name, f => f.Name) + .Map(e => e.Count, f => f.Count) + .Map(e => e.PropertyWithBackingField, f => f.PropertyWithBackingField) + .Map(e => EF.Property(e, "_privateField"), f => EF.Property(f, "_privateField")); + } + + private static void ConfigureTempSourceWithMappedColumns(ModelBuilder builder) + { + builder.ConfigureTempTableEntity(false, b => + { + b.HasKey(e => e.Id); + b.Property(e => e.Id).HasColumnName("entity_id"); + b.Property(e => e.Name).HasColumnName("display_name"); + b.Property(e => e.Count).HasColumnName("item_count"); + }); + } + + private async Task> CreateSourceAsync(params T[] entities) + where T : class + { + return await SUT.BulkInsertIntoTempTableAsync(entities, new MySqlTempTableBulkInsertOptions()); + } + + // Line breaks of the generated SQL depend on the platform, so whitespace is normalized. + private string GetExecutedStatement(string prefix) + { + return Regex.Replace(ExecutedCommands.Last(command => command.StartsWith(prefix, StringComparison.Ordinal)), @"\s+", " "); + } + + private async Task> LoadColumnAsync(string tableName, string columnName) + { + return await AssertDbContext.Database.SqlQueryRaw($"SELECT {Quote(columnName)} AS `Value` FROM {Quote(tableName)} ORDER BY {Quote(columnName)}").ToListAsync(); + } + + // LIKE copies the columns, the indexes and the primary key, but no foreign keys. + private async Task CreateTableCopyAsync(string sourceTableName, string targetTableName) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"CREATE TABLE {Quote(targetTableName)} LIKE {Quote(sourceTableName)}"); + } + + private async Task DropTablesAsync(params string[] tableNames) + { + foreach (var tableName in tableNames) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"DROP TABLE IF EXISTS {Quote(tableName)}"); + } + } + + private static string Quote(string identifier) + { + return $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`"; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertIntoTempTableAsync.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertIntoTempTableAsync.cs new file mode 100644 index 0000000..4abac51 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertIntoTempTableAsync.cs @@ -0,0 +1,935 @@ +using System.Globalization; +using System.Transactions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.EntityFrameworkCore.TempTables.MySqlTempTableCreatorTests; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +// The rows are written with raw ADO commands (multi-row INSERT or MySqlBulkCopy), so the INSERT statements are not visible in ExecutedCommands. +// A temp table exists in the session of the connection that created it, so the structure and the content are read through the connection of ActDbContext. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkInsertIntoTempTableAsync : MySqlIntegrationTestsBase +{ + private static readonly Guid _TEST_ENTITY_ID = new("577BFD36-21BC-4F9E-97B4-367B8F29B730"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("3A1B2FFF-8E11-44E5-80E5-8C7FEEDACEB3"); + + private MySqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService(); + + public BulkInsertIntoTempTableAsync(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_entities_into_temp_table() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var entities = new List + { + new() { Column1 = 1, Column2 = "value1" }, + new() { Column1 = 2, Column2 = "value2" } + }; + + var options = new MySqlTempTableBulkInsertOptions + { + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + + var columns = await MySqlTempTableInspector.GetColumnsAsync(ActDbContext, tempTable.Name); + columns.Select(c => c.Name).Should().Equal(nameof(CustomTempTable.Column1), nameof(CustomTempTable.Column2)); + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, tempTable.Name)).Should().Be(2); + + var loadedEntities = await tempTable.Query.OrderBy(e => e.Column1).ToListAsync(); + loadedEntities.Should().BeEquivalentTo(entities, config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_insert_entities_into_temp_table_with_pk() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, b => b.HasKey(e => e.Column1)); + + var entities = new List + { + new() { Column1 = 1, Column2 = "value1" }, + new() { Column1 = 2, Column2 = "value2" } + }; + + var options = new MySqlTempTableBulkInsertOptions(); + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, tempTable.Name)).Should().Be(2); + (await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name)).Should().Equal(nameof(CustomTempTable.Column1)); + + // The explicit values are kept although the key is an auto-increment column. + var loadedEntities = await tempTable.Query.OrderBy(e => e.Column1).ToListAsync(); + loadedEntities.Should().BeEquivalentTo(entities, config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_create_pk_before_bulk_insert_and_reject_duplicate_keys() + { + // MySQL has no "create primary key after the bulk insert"; the primary key is part of CREATE TEMPORARY TABLE. + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, b => b.HasKey(e => e.Column1)); + + var entities = new List + { + new() { Column1 = 1, Column2 = "value1" }, + new() { Column1 = 1, Column2 = "value2" } + }; + + // 1062: duplicate entry for the primary key + var exception = await SUT.Awaiting(sut => sut.BulkInsertIntoTempTableAsync(entities, new MySqlTempTableBulkInsertOptions())) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1062); + + // The temp table is dropped and the connection is released if the insert fails. + ActDbContext.Database.GetDbConnection().State.Should().Be(System.Data.ConnectionState.Closed); + } + + [Fact] + public async Task Should_insert_scalar_values_into_temp_table() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var values = new[] { 1, 2, 3 }; + + var options = new MySqlTempTableBulkInsertOptions + { + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None + }; + + await using var tempTable = await SUT.BulkInsertValuesIntoTempTableAsync(values, options, CancellationToken.None); + + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, tempTable.Name)).Should().Be(3); + (await tempTable.Query.OrderBy(v => v).ToListAsync()).Should().Equal(1, 2, 3); + } + + [Fact] + public async Task Should_dispose_temp_table() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var entities = new List + { + new() { Column1 = 1, Column2 = "value1" } + }; + + var options = new MySqlTempTableBulkInsertOptions + { + DropTableOnDispose = true, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None + }; + + string tempTableName; + + // A closed connection is reset by the pool, which drops the temp table on its own. Keep the connection open to observe the DROP of the library. + await ActDbContext.Database.OpenConnectionAsync(); + + try + { + await using (var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options)) + { + tempTableName = tempTable.Name; + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTableName)).Should().BeTrue(); + } + + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTableName)).Should().BeFalse(); + } + finally + { + await ActDbContext.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_insert_into_existing_temp_table() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new MySqlTempTableBulkInsertOptions + { + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None + }; + + // First batch + var firstBatch = new List + { + new() { Column1 = 1, Column2 = "value1" } + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(firstBatch, options); + + // Second batch into existing temp table + var secondBatch = new List + { + new() { Column1 = 2, Column2 = "value2" } + }; + + await SUT.BulkInsertIntoTempTableAsync(secondBatch, tempTable, (IBulkInsertOptions?)null, CancellationToken.None); + + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, tempTable.Name)).Should().Be(2); + (await tempTable.Query.OrderBy(e => e.Column1).Select(e => e.Column2).ToListAsync()).Should().Equal("value1", "value2"); + } + + [Fact] + public async Task Should_insert_entityType_without_touching_real_table() + { + var realEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Real", + RequiredName = "RealRequiredName", + Count = 1 + }; + ArrangeDbContext.TestEntities.Add(realEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var tempEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42, + ConvertibleClass = new ConvertibleClass(43) + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([tempEntity], new MySqlTempTableBulkInsertOptions()); + + var tempEntities = await tempTable.Query.ToListAsync(); + tempEntities.Should().BeEquivalentTo([ + new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42, + ConvertibleClass = new ConvertibleClass(43) + } + ]); + + var realEntities = await AssertDbContext.TestEntities.ToListAsync(); + realEntities.Should().BeEquivalentTo([ + new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Real", + RequiredName = "RealRequiredName", + Count = 1 + } + ]); + } + + [Fact] + public async Task Should_insert_entityType_without_required_fields_if_excluded() + { + // The temp table is created with the properties to insert only (MySQL has no 'UsePropertiesToInsertForTempTableCreation' switch). + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID + }; + var entities = new List { entity }; + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, new MySqlTempTableBulkInsertOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Id) + }); + + (await MySqlTempTableInspector.GetColumnsAsync(ActDbContext, tempTable.Name)).Select(c => c.Name).Should().Equal(nameof(TestEntity.Id)); + + var result = await tempTable.Query.Select(t => new { t.Id }).ToListAsync(); + result.Should().BeEquivalentTo([ + new { Id = _TEST_ENTITY_ID } + ]); + } + + [Fact] + public async Task Should_throw_if_required_columns_of_an_existing_temp_table_are_not_inserted() + { + // The temp table has all columns of the entity. The columns that are not inserted are NOT NULL without a default value. + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID + }; + await using var tempTable = await ActDbContext.CreateTempTableAsync(new TempTableCreationOptions()); + + // 1364: field doesn't have a default value (strict mode) + var exception = await ActDbContext.Awaiting(ctx => ctx.BulkInsertIntoTempTableAsync([entity], tempTable, e => e.Id)) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1364); + + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, tempTable.Name)).Should().Be(0); + } + + [Fact] + public async Task Should_return_disposable_query() + { + var tempTableQuery = await SUT.BulkInsertIntoTempTableAsync(Array.Empty(), new MySqlTempTableBulkInsertOptions()); + var query = tempTableQuery.Query; + tempTableQuery.Dispose(); + + // The connection is closed and returned to the pool, i.e. the session of the temp table is gone. + // 1146: table doesn't exist + var exception = await query.Awaiting(q => q.ToListAsync()) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1146); + + tempTableQuery.Invoking(q => q.Query).Should().Throw(); + tempTableQuery.Invoking(q => q.Name).Should().Throw(); + } + + [Fact] + public async Task Should_return_detached_entities_for_entities_with_a_primary_key() + { + var testEntity = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "Name1" + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([testEntity], new MySqlTempTableBulkInsertOptions()); + + var entities = await tempTable.Query.ToListAsync(); + + ActDbContext.Entry(entities[0]).State.Should().Be(EntityState.Detached); + } + + [Fact] + public async Task Should_not_mess_up_temp_tables_with_alternating_requests_without_disposing_previous_one() + { + var testEntity1 = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "Name1" + }; + var testEntity2 = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "Name2" + }; + + var options = new MySqlTempTableBulkInsertOptions(); + + await using var tempTable1_1 = await SUT.BulkInsertIntoTempTableAsync([testEntity1], options); + await using var tempTable2_1 = await SUT.BulkInsertIntoTempTableAsync([testEntity2], options); + await using var tempTable1_2 = await SUT.BulkInsertIntoTempTableAsync([testEntity1], options); + await using var tempTable2_2 = await SUT.BulkInsertIntoTempTableAsync([testEntity2], options); + + tempTable1_1.Query.ToList().Should().BeEquivalentTo([testEntity1]); + tempTable1_2.Query.ToList().Should().BeEquivalentTo([testEntity1]); + tempTable2_1.Query.ToList().Should().BeEquivalentTo([testEntity2]); + tempTable2_2.Query.ToList().Should().BeEquivalentTo([testEntity2]); + } + + [Fact] + public async Task Should_not_mess_up_temp_tables_with_alternating_requests_with_disposing_previous_one() + { + var testEntity1 = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "Name1" + }; + var testEntity2 = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "Name2" + }; + + var options = new MySqlTempTableBulkInsertOptions(); + + await using (var tempTable1 = await SUT.BulkInsertIntoTempTableAsync([testEntity1], options)) + await using (var tempTable2 = await SUT.BulkInsertIntoTempTableAsync([testEntity2], options)) + { + tempTable1.Query.ToList().Should().BeEquivalentTo([testEntity1]); + tempTable2.Query.ToList().Should().BeEquivalentTo([testEntity2]); + } + + await using (var tempTable1 = await SUT.BulkInsertIntoTempTableAsync([testEntity1], options)) + await using (var tempTable2 = await SUT.BulkInsertIntoTempTableAsync([testEntity2], options)) + { + tempTable1.Query.ToList().Should().BeEquivalentTo([testEntity1]); + tempTable2.Query.ToList().Should().BeEquivalentTo([testEntity2]); + } + } + + [Fact] + public async Task Should_use_different_tables_for_temp_tables_of_the_same_entity_that_are_alive_at_once() + { + var options = new MySqlTempTableBulkInsertOptions(); + + await using var first = await SUT.BulkInsertIntoTempTableAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "First" }], options); + await using var second = await SUT.BulkInsertIntoTempTableAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Second" }], options); + + first.Name.Should().Be("TestEntities_1"); + second.Name.Should().Be("TestEntities_2"); + + // The name of a disposed temp table is used again. + await first.DisposeAsync(); + await using var third = await SUT.BulkInsertIntoTempTableAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Third" }], options); + + third.Name.Should().Be("TestEntities_1"); + (await third.Query.Select(e => e.RequiredName).SingleAsync()).Should().Be("Third"); + (await second.Query.Select(e => e.RequiredName).SingleAsync()).Should().Be("Second"); + } + + [Fact] + public async Task Should_properly_join_real_table_with_temp_table() + { + var realEntity = new TestEntity + { + Id = new Guid("C0A98E8F-2715-4764-A02E-033FF5278B9B"), + RequiredName = "Name1" + }; + ArrangeDbContext.Add(realEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var tempEntity = new TestEntity + { + Id = new Guid("C0A98E8F-2715-4764-A02E-033FF5278B9B"), + RequiredName = "Name" + }; + + var options = new MySqlTempTableBulkInsertOptions(); + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([tempEntity], options); + + var entities = await tempTable.Query + .Join(ActDbContext.TestEntities, e => e.Id, e => e.Id, (temp, real) => new { temp, real }) + .ToListAsync(); + + entities.Should().BeEquivalentTo([new { temp = tempEntity, real = realEntity }]); + + entities = await tempTable.Query + .Join(ActDbContext.TestEntities, e => e.Id, e => e.Id, (temp, real) => new { temp, real }) + .ToListAsync(); + + entities.Should().BeEquivalentTo([new { temp = tempEntity, real = realEntity }]); + } + + [Fact] + public async Task Should_throw_if_entity_contains_inlined_owned_type() + { + var testEntity = new TestEntity_Owns_Inline + { + Id = _OTHER_TEST_ENTITY_ID, + InlineEntity = null! + }; + var testEntities = new[] { testEntity }; + + await SUT.Invoking(sut => sut.BulkInsertIntoTempTableAsync(testEntities, new MySqlTempTableBulkInsertOptions())) + .Should().ThrowAsync().WithMessage("Temp tables don't support owned entities."); + } + + [Fact] + public async Task Should_throw_for_entities_with_separated_owned_type() + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _OTHER_TEST_ENTITY_ID, + SeparateEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = "value" + } + }; + + await SUT.Invoking(sut => sut.BulkInsertIntoTempTableAsync([testEntity], new MySqlTempTableBulkInsertOptions())) + .Should().ThrowAsync().WithMessage("Temp tables don't support owned entities."); + } + + [Fact] + public async Task Should_throw_if_entities_contain_separated_owned_type_although_it_is_not_inserted() + { + var testEntity = new TestEntity_Owns_SeparateOne + { + Id = _OTHER_TEST_ENTITY_ID, + SeparateEntity = new OwnedEntity + { + IntColumn = 42, + StringColumn = "value" + } + }; + + // The temp table is created with the properties to insert, which are validated against owned types as well. + await ActDbContext.Awaiting(ctx => ctx.BulkInsertIntoTempTableAsync([testEntity], e => e.Id)) + .Should().ThrowAsync() + .WithMessage("The entity 'Thinktecture.TestDatabaseContext.TestEntity_Owns_SeparateOne' must not contain owned entities."); + } + + [Fact] + public async Task Should_insert_TestEntity_with_ComplexType() + { + var testEntity = new TestEntityWithComplexType(new Guid("54FF93FC-6BE9-4F19-A52E-E517CA9FEAA7"), + new BoundaryValueObject(2, 5)); + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([testEntity], new MySqlTempTableBulkInsertOptions()); + + var loadedEntities = await tempTable.Query.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_return_number_of_inserted_rows_for_entities() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var entities = new List + { + new() { Column1 = 1, Column2 = "value1" }, + new() { Column1 = 2, Column2 = "value2" } + }; + + var options = new MySqlTempTableBulkInsertOptions + { + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + + tempTable.NumberOfInsertedRows.Should().Be(2); + } + + [Fact] + public async Task Should_return_number_of_inserted_rows_for_values() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var values = new[] { 1, 2, 3 }; + + var options = new MySqlTempTableBulkInsertOptions + { + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None + }; + + await using var tempTable = await SUT.BulkInsertValuesIntoTempTableAsync(values, options, CancellationToken.None); + + tempTable.NumberOfInsertedRows.Should().Be(3); + } + + // The tests below cover behavior specific to MySQL: column mapping, batching, MySqlBulkCopy, command timeout, cancellation, transactions. + + [Fact] + public void Should_copy_settings_of_options_and_provide_them_to_table_creation_and_insert() + { + var propertiesToInsert = IEntityPropertiesProvider.Include(e => e.Column1); + var options = new MySqlTempTableBulkInsertOptions + { + TruncateTableIfExists = true, + DropTableOnDispose = false, + TableNameProvider = DefaultTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced, + PropertiesToInsert = propertiesToInsert, + BatchSize = 7, + CommandTimeout = TimeSpan.FromSeconds(5), + UseBulkCopy = true + }; + + new MySqlTempTableBulkInsertOptions(options).Should().BeEquivalentTo(options); + + options.GetTempTableCreationOptions().Should().BeEquivalentTo(new TempTableCreationOptions + { + TruncateTableIfExists = true, + DropTableOnDispose = false, + TableNameProvider = DefaultTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced, + PropertiesToInclude = propertiesToInsert + }); + options.GetBulkInsertOptions().Should().BeEquivalentTo(new MySqlBulkInsertOptions + { + PropertiesToInsert = propertiesToInsert, + BatchSize = 7, + CommandTimeout = TimeSpan.FromSeconds(5), + UseBulkCopy = true + }); + } + + [Fact] + public async Task Should_accept_options_of_other_providers() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var entities = new List + { + new() { Column1 = 1, Column2 = "value1" }, + new() { Column1 = 2, Column2 = "value2" } + }; + var options = new GenericTempTableBulkInsertOptions + { + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None, + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Column1) + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + + tempTable.NumberOfInsertedRows.Should().Be(2); + (await MySqlTempTableInspector.GetColumnsAsync(ActDbContext, tempTable.Name)).Select(c => c.Name).Should().Equal(nameof(CustomTempTable.Column1)); + (await tempTable.Query.OrderBy(e => e.Column1).Select(e => e.Column1).ToListAsync()).Should().Equal(1, 2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_entities_with_different_column_names(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + var entities = new[] + { + new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Name 1", Count = 1 }, + new TestEntityWithDifferentColumnNames { Id = _OTHER_TEST_ENTITY_ID, Name = null, Count = 2 } + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + + var columns = await MySqlTempTableInspector.GetColumnsAsync(ActDbContext, tempTable.Name); + columns.Select(c => c.Name).Should().BeEquivalentTo("entity_id", "display_name", "item_count"); + + var loadedEntities = await tempTable.Query.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().BeEquivalentTo(entities, config => config.WithStrictOrdering()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_entity_with_json_column(bool useBulkCopy) + { + var options = CreateOptions(useBulkCopy); + var entities = new[] + { + new TestEntityWithJsonColumns { Id = _TEST_ENTITY_ID, JsonColumn = """{"a":1}""" }, + new TestEntityWithJsonColumns { Id = _OTHER_TEST_ENTITY_ID, JsonColumn = null } + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + + var columns = await MySqlTempTableInspector.GetColumnsAsync(ActDbContext, tempTable.Name); + columns.Single(c => c.Name == nameof(TestEntityWithJsonColumns.JsonColumn)).Type.Should().Be("json"); + + var loadedEntities = await tempTable.Query.ToListAsync(); + loadedEntities.Single(e => e.Id == _TEST_ENTITY_ID).JsonColumn.Should().Contain("\"a\""); + loadedEntities.Single(e => e.Id == _OTHER_TEST_ENTITY_ID).JsonColumn.Should().BeNull(); + } + + [Theory] + [InlineData(1, null, 1)] + [InlineData(499, null, 1)] + [InlineData(500, null, 1)] // the default batch size + [InlineData(501, null, 2)] + [InlineData(1000, null, 2)] + [InlineData(1001, null, 3)] + [InlineData(10, 1, 10)] + [InlineData(10, 3, 4)] + [InlineData(10, 10, 1)] + [InlineData(10, 100, 1)] + public async Task Should_split_rows_into_statements_according_to_batch_size(int rowCount, int? batchSize, int expectedStatements) + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var entities = Enumerable.Range(0, rowCount).Select(i => new CustomTempTable(i, $"value {i}")).ToList(); + var options = new MySqlTempTableBulkInsertOptions + { + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None, + BatchSize = batchSize + }; + + // The status counters are per session. The connection of ActDbContext stays open, so the executor and the status queries share the session. + await ActDbContext.Database.OpenConnectionAsync(); + + try + { + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + tempTable.NumberOfInsertedRows.Should().Be(rowCount); + statements.Should().Be(expectedStatements); + + var loadedValues = await tempTable.Query.OrderBy(e => e.Column1).Select(e => e.Column1).ToListAsync(); + loadedValues.Should().Equal(Enumerable.Range(0, rowCount)); + } + finally + { + await ActDbContext.Database.CloseConnectionAsync(); + } + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task Should_throw_and_drop_temp_table_if_batch_size_is_not_positive(int batchSize) + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new MySqlTempTableBulkInsertOptions { BatchSize = batchSize }; + + await SUT.Awaiting(sut => sut.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value")], options)) + .Should().ThrowAsync() + .WithMessage("*batch size*greater than 0*"); + + ActDbContext.Database.GetDbConnection().State.Should().Be(System.Data.ConnectionState.Closed); + + // The name of the failed temp table is available again. + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value")], new MySqlTempTableBulkInsertOptions()); + tempTable.Name.Should().EndWith("_1"); + } + + [Fact] + public async Task Should_insert_entities_with_bulk_copy() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = CreateOptions(true); + options.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None; + var entities = new List + { + new() { Column1 = 1, Column2 = "value1" }, + new() { Column1 = 2, Column2 = null }, + new() { Column1 = 3, Column2 = "O'Brien \\N\r\n\t\"" } + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + + tempTable.NumberOfInsertedRows.Should().Be(3); + (await tempTable.Query.OrderBy(e => e.Column1).ToListAsync()).Should().BeEquivalentTo(entities, config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_insert_entityType_with_bulk_copy() + { + var options = CreateOptions(true); + var entityWithValues = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42, + NullableCount = 7, + ConvertibleClass = new ConvertibleClass(5), + PropertyWithBackingField = 3 + }; + entityWithValues.SetPrivateField(9); + var entityWithNulls = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "RequiredName", + Count = 0 + }; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([entityWithValues, entityWithNulls], options); + + tempTable.NumberOfInsertedRows.Should().Be(2); + + var loadedEntities = await tempTable.Query.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().HaveCount(2); + loadedEntities[0].Should().BeEquivalentTo(entityWithNulls); + loadedEntities[1].Should().BeEquivalentTo(entityWithValues); + loadedEntities[1].GetPrivateField().Should().Be(9); + } + + [Fact] + public async Task Should_insert_all_rows_with_single_load_data_statement_and_ignore_batch_size() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = CreateOptions(true); + options.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None; + options.BatchSize = 1; + var entities = Enumerable.Range(0, 1001).Select(i => new CustomTempTable(i, $"value {i}")).ToList(); + + await ActDbContext.Database.OpenConnectionAsync(); + + try + { + var insertsBefore = await GetSessionStatusAsync("Com_insert"); + var loadsBefore = await GetSessionStatusAsync("Com_load"); + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync(entities, options); + var inserts = await GetSessionStatusAsync("Com_insert") - insertsBefore; + var loads = await GetSessionStatusAsync("Com_load") - loadsBefore; + + tempTable.NumberOfInsertedRows.Should().Be(1001); + inserts.Should().Be(0); + loads.Should().Be(1); + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, tempTable.Name)).Should().Be(1001); + } + finally + { + await ActDbContext.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_insert_into_existing_temp_table_with_bulk_copy() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = CreateOptions(true); + options.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None; + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value1")], options); + + await SUT.BulkInsertIntoTempTableAsync([new CustomTempTable(2, "value2")], tempTable, options, CancellationToken.None); + + (await tempTable.Query.OrderBy(e => e.Column1).Select(e => e.Column2).ToListAsync()).Should().Equal("value1", "value2"); + } + + [Fact] + public async Task Should_throw_and_drop_temp_table_if_bulk_copy_inserts_duplicate_keys() + { + ConfigureModel = builder => builder.ConfigureTempTable(false); + + var options = CreateOptions(true); + + // LOAD DATA LOCAL implies IGNORE, i.e. the duplicate row would be dropped silently if the executor wouldn't compare the number of rows. + await SUT.Awaiting(sut => sut.BulkInsertValuesIntoTempTableAsync(new[] { 1, 1 }, options, CancellationToken.None)) + .Should().ThrowAsync().WithMessage("*only 1 was inserted*"); + + ActDbContext.Database.GetDbConnection().State.Should().Be(System.Data.ConnectionState.Closed); + } + + [Theory] + [InlineData(false, 500)] + [InlineData(false, 30_000)] + [InlineData(true, 500)] + [InlineData(true, 30_000)] + public async Task Should_insert_entities_with_command_timeout(bool useBulkCopy, int timeoutInMilliseconds) + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = CreateOptions(useBulkCopy); + options.CommandTimeout = TimeSpan.FromMilliseconds(timeoutInMilliseconds); + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value1"), new CustomTempTable(2, "value2")], options); + + tempTable.NumberOfInsertedRows.Should().Be(2); + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, tempTable.Name)).Should().Be(2); + } + + [Fact] + public async Task Should_throw_and_release_connection_and_name_when_cancellation_is_requested() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await SUT.Awaiting(sut => sut.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value1")], new MySqlTempTableBulkInsertOptions(), cancellationTokenSource.Token)) + .Should().ThrowAsync(); + + ActDbContext.Database.GetDbConnection().State.Should().Be(System.Data.ConnectionState.Closed); + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value1")], new MySqlTempTableBulkInsertOptions()); + tempTable.Name.Should().EndWith("_1"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_keep_rows_of_temp_table_if_the_transaction_of_the_caller_is_committed(bool useBulkCopy) + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = CreateOptions(useBulkCopy); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value1"), new CustomTempTable(2, "value2")], options); + + tempTable.NumberOfInsertedRows.Should().Be(2); + (await tempTable.Query.CountAsync()).Should().Be(2); + + await transaction.CommitAsync(); + + (await tempTable.Query.CountAsync()).Should().Be(2); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_remove_rows_but_not_the_temp_table_if_the_transaction_of_the_caller_is_rolled_back(bool useBulkCopy) + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = CreateOptions(useBulkCopy); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + await using var tempTable = await SUT.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value1"), new CustomTempTable(2, "value2")], options); + + (await tempTable.Query.CountAsync()).Should().Be(2); + + await transaction.RollbackAsync(); + + // CREATE TEMPORARY TABLE is not part of the transaction, the rows (InnoDB) are. + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTable.Name)).Should().BeTrue(); + (await tempTable.Query.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_commit_the_transaction_of_the_caller_when_temp_table_is_created_and_dropped() + { + // CREATE TEMPORARY TABLE and DROP TEMPORARY TABLE don't end a transaction implicitly (as DROP TABLE does). + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + ActDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Real" }); + await ActDbContext.SaveChangesAsync(); + + await using (var tempTable = await SUT.BulkInsertIntoTempTableAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Temp" }], new MySqlTempTableBulkInsertOptions())) + { + (await tempTable.Query.CountAsync()).Should().Be(1); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "the transaction of the caller is not committed yet"); + + await transaction.RollbackAsync(); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_throw_inside_a_TransactionScope_because_xa_transactions_reject_temp_tables() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + + // 4091: temporary tables cannot be accessed inside XA transactions + var exception = await SUT.Awaiting(sut => sut.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value1")], new MySqlTempTableBulkInsertOptions())) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(4091); + } + + // MySqlBulkCopy uses LOAD DATA LOCAL INFILE, which the connection has to allow explicitly (the server is started with --local-infile=1). + // This must happen before the first use of the connection of ActDbContext. + private MySqlTempTableBulkInsertOptions CreateOptions(bool useBulkCopy) + { + if (useBulkCopy) + { + var connectionString = new MySqlConnectionStringBuilder(ConnectionString) { AllowLoadLocalInfile = true }; + ActDbContext.Database.SetConnectionString(connectionString.ConnectionString); + } + + return new MySqlTempTableBulkInsertOptions { UseBulkCopy = useBulkCopy }; + } + + private async Task GetSessionStatusAsync(string name) + { + await using var command = ActDbContext.Database.GetDbConnection().CreateCommand(); + command.CommandText = $"SHOW SESSION STATUS LIKE '{name}'"; + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return Int64.Parse(reader.GetString(1), CultureInfo.InvariantCulture); + } + + // Options that are not specific to MySQL, so the executor has to copy them into its own options type. + private sealed class GenericTempTableBulkInsertOptions : ITempTableBulkInsertOptions + { + public bool TruncateTableIfExists { get; init; } + public bool DropTableOnDispose { get; init; } = true; + public ITempTableNameProvider? TableNameProvider { get; init; } + public IPrimaryKeyPropertiesProvider? PrimaryKeyCreation { get; init; } + public IEntityPropertiesProvider? PropertiesToInsert { get; init; } + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertOrUpdateAsync.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertOrUpdateAsync.cs new file mode 100644 index 0000000..cd3f05c --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkInsertOrUpdateAsync.cs @@ -0,0 +1,1612 @@ +using System.Diagnostics; +using System.Globalization; +using System.Transactions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.TestDatabaseContext; + +#pragma warning disable CS0618 // TableName and Schema are obsolete; these tests cover the obsolete path on purpose. + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +// The executor writes with batched "INSERT ... ON DUPLICATE KEY UPDATE" statements using raw ADO commands, so the statements are not visible in ExecutedCommands. +// The tests verify the resulting rows and the return values (the number of processed input rows, not the 1/2/0 affected-row codes of MySQL). +// MySQL fires ON DUPLICATE KEY for any primary key or unique index conflict, so each assignment is guarded by the key properties: +// col = IF(, , col). Key columns are never updated and a row that collides with +// a different row on another unique index is skipped silently. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkInsertOrUpdateAsync : MySqlIntegrationTestsBase +{ + private static readonly Guid _TEST_ENTITY_ID = new("40B5CA93-5C02-48AD-B8A1-12BC13313866"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("8AF163D7-D316-4B2D-A62F-6326A80C8BEE"); + private static readonly Guid _THIRD_TEST_ENTITY_ID = new("5B9587A3-2312-43DF-9681-38EC22AD8606"); + private static readonly Guid _MISSING_TEST_ENTITY_ID = new("506E664A-9ADC-4221-9577-71DCFD73DE64"); + + // Before MySQL 8.0.19 (and always on MariaDB) there is no row alias, so the executor falls back to the deprecated VALUES(col). + private static readonly MySqlServerVersion _SERVER_VERSION_WITHOUT_ROW_ALIAS = new(new Version(8, 0, 18)); + + private MySqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService(); + + public BulkInsertOrUpdateAsync(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_not_throw_if_entities_is_empty() + { + var affectedRows = await SUT.BulkInsertOrUpdateAsync(new List(), new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(0); + } + + [Fact] + public async Task Should_not_throw_if_key_property_is_not_present_in_PropertiesToUpdate() + { + var propertiesProvider = IEntityPropertiesProvider.Include(entity => new { entity.Name }); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync(new List(), new MySqlBulkInsertOrUpdateOptions { PropertiesToUpdate = propertiesProvider }); + + affectedRows.Should().Be(0); + } + + [Fact] + public async Task Should_throw_when_entity_has_no_key() + { + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync(new List { new() }, new MySqlBulkInsertOrUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("The entity 'Thinktecture.TestDatabaseContext.KeylessTestEntity' has no primary key. Please provide key properties to perform JOIN/match on."); + } + + [Fact] + public async Task Should_insert_new_entities() + { + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1) + .And.Subject.First() + .Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }); + } + + [Fact] + public async Task Should_update_existing_entities() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "Name"; + entity.Count = 42; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1) + .And.Subject.First() + .Should().BeEquivalentTo(entity); + } + + [Fact] + public async Task Should_insert_and_update_in_same_batch() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = "UpdatedName"; + existingEntity.Count = 1; + + var newEntity = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + Name = "NewName", + RequiredName = "RequiredName", + Count = 2 + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([existingEntity, newEntity]); + } + + [Fact] + public async Task Should_return_the_number_of_processed_rows_including_unchanged_rows() + { + var unchanged = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 1 }; + var changed = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 2 }; + ArrangeDbContext.AddRange(unchanged, changed); + await ArrangeDbContext.SaveChangesAsync(); + + changed.Count = 20; + var newEntity = new TestEntity { Id = _THIRD_TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 3 }; + + // MySQL reports different affected-row codes for inserted, updated and unchanged rows; the executor reports the number of input rows instead. + var affectedRows = await SUT.BulkInsertOrUpdateAsync([unchanged, changed, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(3); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([unchanged, changed, newEntity]); + } + + [Fact] + public async Task Should_insert_column_with_converter() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.ConvertibleClass = new ConvertibleClass(43); + var newEntity = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "RequiredName", + ConvertibleClass = new ConvertibleClass(42) + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var entities = await AssertDbContext.TestEntities.ToListAsync(); + entities.Should().BeEquivalentTo([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(43) }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(42) } + ]); + } + + [Fact] + public async Task Should_insert_private_property() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.SetPrivateField(1); + + var newEntity = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName" }; + newEntity.SetPrivateField(3); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([newEntity, existingEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(2); + loadedEntities.Single(e => e.Id == _TEST_ENTITY_ID).GetPrivateField().Should().Be(1); + loadedEntities.Single(e => e.Id == _OTHER_TEST_ENTITY_ID).GetPrivateField().Should().Be(3); + } + + [Fact] + public async Task Should_insert_shadow_properties() + { + var existingEntity = new TestEntityWithShadowProperties { Id = _TEST_ENTITY_ID }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + ActDbContext.Entry(existingEntity).Property("ShadowStringProperty").CurrentValue = "value1"; + ActDbContext.Entry(existingEntity).Property("ShadowIntProperty").CurrentValue = 42; + + var newEntity = new TestEntityWithShadowProperties { Id = _OTHER_TEST_ENTITY_ID }; + ActDbContext.Entry(newEntity).Property("ShadowStringProperty").CurrentValue = "value2"; + ActDbContext.Entry(newEntity).Property("ShadowIntProperty").CurrentValue = 43; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([newEntity, existingEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithShadowProperties.ToListAsync(); + loadedEntities.Should().HaveCount(2); + + var existingEntry = AssertDbContext.Entry(loadedEntities.Single(e => e.Id == _TEST_ENTITY_ID)); + existingEntry.Property("ShadowStringProperty").CurrentValue.Should().Be("value1"); + existingEntry.Property("ShadowIntProperty").CurrentValue.Should().Be(42); + + var newEntry = AssertDbContext.Entry(loadedEntities.Single(e => e.Id == _OTHER_TEST_ENTITY_ID)); + newEntry.Property("ShadowStringProperty").CurrentValue.Should().Be("value2"); + newEntry.Property("ShadowIntProperty").CurrentValue.Should().Be(43); + } + + [Fact] + public async Task Should_insert_specified_properties_only() + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42, + PropertyWithBackingField = 7 + }; + testEntity.SetPrivateField(3); + + // PropertiesToUpdate is not set. As with the other providers, the default must not require the properties that are not inserted. + await SUT.BulkInsertOrUpdateAsync([testEntity], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()) + }); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName", + Count = 42, + PropertyWithBackingField = 7 + }); + loadedEntity.GetPrivateField().Should().Be(3); + } + + [Fact] + public async Task Should_insert_and_update_specified_properties_only() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = "Name"; // would neither be updated nor inserted + existingEntity.Count = 42; + var newEntity = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "New", RequiredName = "RequiredName", Count = 1 }; + + var requiredProperties = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()); + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToInsert = requiredProperties, + PropertiesToUpdate = requiredProperties + }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, (string?)null, 1), + (_TEST_ENTITY_ID, "original value", 42)); + } + + [Fact] + public async Task Should_update_entities() + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + entity_1.Name = "Name"; + entity_1.Count = 1; + entity_2.Name = "OtherName"; + entity_2.Count = 2; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([entity_1, entity_2], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([entity_1, entity_2]); + } + + [Fact] + public async Task Should_update_provided_entity_only() + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + entity_1.Name = "Name"; + entity_1.Count = 1; + entity_2.Name = "OtherName"; + entity_2.Count = 2; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([entity_1], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(1); + + entity_2.Name = default; + entity_2.Count = default; + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([entity_1, entity_2]); + } + + [Fact] + public async Task Should_update_specified_properties_only() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "Name"; // this would not be updated + entity.Count = 42; + entity.PropertyWithBackingField = 7; + entity.SetPrivateField(3); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([entity], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()) + }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + Count = 42, + PropertyWithBackingField = 7, + Name = "original value", + RequiredName = "RequiredName" + }); + loadedEntity.GetPrivateField().Should().Be(3); + } + + [Fact] + public async Task Should_update_nullable_columns_to_null() + { + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + NullableCount = 42, + ConvertibleClass = new ConvertibleClass(43) + }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = null; + entity.NullableCount = null; + entity.ConvertibleClass = null; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().BeNull(); + loadedEntity.NullableCount.Should().BeNull(); + loadedEntity.ConvertibleClass.Should().BeNull(); + } + + [Fact] + public async Task Should_not_update_columns_of_the_primary_key_by_default() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName", Count = 1 }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 42 }], + new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 42 }); + } + + [Fact] + public async Task Should_only_insert_new_rows_if_no_property_is_left_to_update() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 42 }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = "changed"; + existingEntity.Count = 43; + var newEntity = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "new", RequiredName = "RequiredName", Count = 1 }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Empty + }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, "new", 1), + (_TEST_ENTITY_ID, "Name", 42)); + } + + [Fact] + public async Task Should_only_insert_new_rows_if_the_key_is_the_only_property_to_update() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 42 }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = "changed"; + var newEntity = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "new", RequiredName = "RequiredName", Count = 1 }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Id) + }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, "new", 1), + (_TEST_ENTITY_ID, "Name", 42)); + } + + [Fact] + public async Task Should_update_only_rows_whose_primary_key_and_key_properties_match() + { + var matching = new TestEntity { Id = _TEST_ENTITY_ID, Name = null, RequiredName = "RequiredName", Count = 1 }; + var differentKey = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "value", RequiredName = "RequiredName", Count = 2 }; + ArrangeDbContext.AddRange(matching, differentKey); + await ArrangeDbContext.SaveChangesAsync(); + + // The primary key is the only unique index of the table, so ON DUPLICATE KEY fires for the rows with an existing Id. + // They are updated only if the key property (Name) is equal as well; null equals null. + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, Name = null, RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "other value", RequiredName = "RequiredName", Count = 20 }, + new TestEntity { Id = _THIRD_TEST_ENTITY_ID, Name = "new", RequiredName = "RequiredName", Count = 30 } + ], + new MySqlBulkInsertOrUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.Name) + }); + + affectedRows.Should().Be(3, "skipped rows are counted as processed"); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, "value", 2), + (_TEST_ENTITY_ID, (string?)null, 10), + (_THIRD_TEST_ENTITY_ID, "new", 30)); + } + + [Fact] + public async Task Should_match_null_keys_null_safe() + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", NullableCount = null, Count = 0 }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", NullableCount = 5, Count = 0 }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", NullableCount = null, Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", NullableCount = null, Count = 20 } + ], + new MySqlBulkInsertOrUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.NullableCount), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Count) + }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.NullableCount).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.NullableCount, e.Count)).Should().Equal((_TEST_ENTITY_ID, (int?)null, 10), + (_OTHER_TEST_ENTITY_ID, 5, 0)); + } + + [Fact] + public async Task Should_upsert_entities_with_composite_primary_key() + { + ArrangeDbContext.CompositeEntities.AddRange(new MySqlCompositeEntity { RegionId = 1, ItemId = 1, Name = "1-1" }, + new MySqlCompositeEntity { RegionId = 1, ItemId = 2, Name = "1-2" }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new MySqlCompositeEntity { RegionId = 1, ItemId = 1, Name = "changed 1-1" }, + new MySqlCompositeEntity { RegionId = 1, ItemId = 3, Name = "new 1-3" }, + new MySqlCompositeEntity { RegionId = 2, ItemId = 1, Name = "new 2-1" } + ], + new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(3); + + var loadedEntities = await AssertDbContext.CompositeEntities.OrderBy(e => e.RegionId).ThenBy(e => e.ItemId).ToListAsync(); + loadedEntities.Select(e => (e.RegionId, e.ItemId, e.Name)).Should().Equal((1, 1, "changed 1-1"), + (1, 2, "1-2"), + (1, 3, "new 1-3"), + (2, 1, "new 2-1")); + } + + [Fact] + public async Task Should_apply_duplicates_of_a_key_in_order() + { + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 2 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 3 } + ], + new MySqlBulkInsertOrUpdateOptions()); + + // The first row is inserted, the second row finds it and updates it (last one wins). + affectedRows.Should().Be(3); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Count)).Should().Equal((_TEST_ENTITY_ID, 2), (_OTHER_TEST_ENTITY_ID, 3)); + } + + [Fact] + public async Task Should_apply_duplicates_of_a_key_in_order_across_batches() + { + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 2 } + ], + new MySqlBulkInsertOrUpdateOptions { BatchSize = 1 }); + + affectedRows.Should().Be(2); + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(2); + } + + [Fact] + public async Task Should_insert_and_update_TestEntity_with_ComplexType() + { + var testEntity_1 = new TestEntityWithComplexType(new Guid("54FF93FC-6BE9-4F19-A52E-E517CA9FEAA7"), new BoundaryValueObject(2, 5)); + + ArrangeDbContext.Add(testEntity_1); + await ArrangeDbContext.SaveChangesAsync(); + + testEntity_1.Boundary = new BoundaryValueObject(10, 20); + var testEntity_2 = new TestEntityWithComplexType(new Guid("67A9500B-CF51-4A39-8C89-F2EBF7EDE84D"), new BoundaryValueObject(3, 4)); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([testEntity_1, testEntity_2], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities_with_ComplexType.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity_1, testEntity_2]); + } + + [Fact] + public async Task Should_insert_and_update_flags_enum() + { + var existingEntity = new TestEntityWithFlags { Id = _TEST_ENTITY_ID, GroupId = 1, Phase = PhaseMembership.A }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.GroupId = 2; + existingEntity.Phase = PhaseMembership.A | PhaseMembership.C; + var newEntity = new TestEntityWithFlags { Id = _OTHER_TEST_ENTITY_ID, GroupId = 3, Phase = PhaseMembership.B | PhaseMembership.C }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithFlags.OrderBy(e => e.GroupId).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.GroupId, e.Phase)).Should().Equal((_TEST_ENTITY_ID, 2, PhaseMembership.A | PhaseMembership.C), + (_OTHER_TEST_ENTITY_ID, 3, PhaseMembership.B | PhaseMembership.C)); + } + + [Fact] + public async Task Should_insert_and_update_entities_with_different_column_names() + { + var existingEntity = new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Name 1", Count = 1 }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = null; + existingEntity.Count = 10; + var newEntity = new TestEntityWithDifferentColumnNames { Id = _OTHER_TEST_ENTITY_ID, Name = "Name 2", Count = 2 }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithDifferentColumnNames.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().BeEquivalentTo([newEntity, existingEntity], config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_update_entities_with_different_column_names_by_non_pk_key() + { + ArrangeDbContext.TestEntitiesWithDifferentColumnNames.Add(new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Name 1", Count = 1 }); + await ArrangeDbContext.SaveChangesAsync(); + + // The primary key collides and the key column (item_count) matches, so the row is updated; the second row has a different key and is skipped. + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Changed", Count = 1 }, + new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Skipped", Count = 2 } + ], + new MySqlBulkInsertOrUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.Count) + }); + + affectedRows.Should().Be(2); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDifferentColumnNames.SingleAsync(); + loadedEntity.Id.Should().Be(_TEST_ENTITY_ID); + loadedEntity.Name.Should().Be("Changed"); + loadedEntity.Count.Should().Be(1); + } + + [Fact] + public async Task Should_insert_and_update_entities_with_auto_increment_column() + { + var existingEntity = new TestEntityWithAutoIncrement { Name = "original value" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = "Name"; + var newEntity = new TestEntityWithAutoIncrement { Id = existingEntity.Id + 10, Name = "New Entity" }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithAutoIncrement.OrderBy(e => e.Id).ToListAsync(); + loadedEntities.Should().BeEquivalentTo([existingEntity, newEntity], config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_throw_if_the_auto_increment_key_is_not_selected_for_insert() + { + var existingEntity = new TestEntityWithAutoIncrement { Name = "original value" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = "Name"; + var newEntity = new TestEntityWithAutoIncrement { Name = "New Entity" }; + + // MySQL needs the key in the INSERT, otherwise it can't tell whether a row exists. + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([existingEntity, newEntity], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Name), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Name) + })) + .Should().ThrowAsync() + .WithMessage("MySQL upsert requires key property 'Id' to be selected for insertion."); + + var loadedEntity = await AssertDbContext.TestEntitiesWithAutoIncrement.SingleAsync(); + loadedEntity.Name.Should().Be("original value"); + } + + [Fact] + public async Task Should_throw_if_an_update_property_is_not_selected_for_insert() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Count = 42; + + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([existingEntity], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => new { e.Id, e.RequiredName }), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Count) + })) + .Should().ThrowAsync() + .WithMessage("MySQL upsert requires update property 'Count' to also be selected for insertion."); + + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(1); + } + + [Fact] + public async Task Should_throw_if_a_key_property_is_not_selected_for_insert() + { + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => new { e.Id, e.RequiredName }), + PropertiesToUpdate = IEntityPropertiesProvider.Empty, + KeyProperties = IEntityPropertiesProvider.Include(e => e.Name) + })) + .Should().ThrowAsync() + .WithMessage("MySQL upsert requires key property 'Name' to be selected for insertion."); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_write_provided_column_values_as_is_despite_sql_default_value() + { + var entity = new TestEntityWithSqlDefaultValues + { + Id = _TEST_ENTITY_ID, + Int = 5, + String = "value", + NullableInt = 6, + NullableString = "otherValue" + }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Int = 0; + entity.String = "other value"; + entity.NullableInt = null; + entity.NullableString = null; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDefaultValues.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithSqlDefaultValues + { + Id = _TEST_ENTITY_ID, + Int = 0, // persisted as-is + NullableInt = null, // persisted as-is + String = "other value", + NullableString = null // persisted as-is + }); + } + + [Fact] + public async Task Should_use_sql_default_values_of_columns_that_are_not_inserted() + { + var existingEntity = new TestEntityWithSqlDefaultValues + { + Id = _TEST_ENTITY_ID, + Int = 5, + String = "value", + NullableInt = 6, + NullableString = "otherValue" + }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([new TestEntityWithSqlDefaultValues { Id = _TEST_ENTITY_ID }, new TestEntityWithSqlDefaultValues { Id = _OTHER_TEST_ENTITY_ID }], + new MySqlBulkInsertOrUpdateOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(e => e.Id), + PropertiesToUpdate = IEntityPropertiesProvider.Empty + }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithDefaultValues.OrderBy(e => e.Int).ToListAsync(); + loadedEntities.Should().BeEquivalentTo([ + new TestEntityWithSqlDefaultValues { Id = _OTHER_TEST_ENTITY_ID, Int = 1, NullableInt = 2, String = "3", NullableString = "4" }, + existingEntity + ], + config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_insert_and_update_entity_with_json_column() + { + var existingEntity = new TestEntityWithJsonColumns + { + Id = _TEST_ENTITY_ID, + JsonColumn = """{"key": "original"}""" + }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.JsonColumn = """{"key": "updated"}"""; + + var newEntity = new TestEntityWithJsonColumns + { + Id = _OTHER_TEST_ENTITY_ID, + JsonColumn = """{"key": "new"}""" + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + // MySQL normalizes the JSON text; the values are already in the normalized form. + var loadedEntities = await AssertDbContext.TestEntitiesWithJsonColumns.OrderBy(e => e.JsonColumn).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.JsonColumn)).Should().Equal((_OTHER_TEST_ENTITY_ID, """{"key": "new"}"""), + (_TEST_ENTITY_ID, """{"key": "updated"}""")); + } + + [Fact] + public async Task Should_accept_options_of_other_providers() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Name = "Name"; // this would not be updated + existingEntity.Count = 42; + var newEntity = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "New", RequiredName = "RequiredName", Count = 1 }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], + new GenericBulkOperationOptions + { + PropertiesToInsert = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()), + PropertiesToUpdate = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()) + }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, (string?)null, 1), + (_TEST_ENTITY_ID, "original value", 42)); + } + + [Fact] + public async Task Should_skip_rows_that_collide_with_another_row_on_a_unique_index() + { + ArrangeDbContext.Entities.AddRange(new MySqlTestEntity { Id = 1, Name = "a" }, new MySqlTestEntity { Id = 2, Name = "b" }); + await ArrangeDbContext.SaveChangesAsync(); + + // The name of MySqlTestEntity has a unique index. The first row collides with row 1 on that index, but its primary key is new. + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new MySqlTestEntity { Id = 9, Name = "a" }, + new MySqlTestEntity { Id = 2, Name = "b changed" }, + new MySqlTestEntity { Id = 10, Name = "new" } + ], + new MySqlBulkInsertOrUpdateOptions { BatchSize = 1 }); + + affectedRows.Should().Be(3, "skipped rows are counted as processed"); + + var loadedEntities = await AssertDbContext.Entities.OrderBy(e => e.Id).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name)).Should().Equal((1, "a"), (2, "b changed"), (10, "new")); + } + + [Fact] + public async Task Should_insert_or_update_entities_in_table_name_override() + { + await CreateRedirectTableAsync("TestEntities_BulkUpsertRedirect"); + + try + { + var existingEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName", + Name = "UpdatedName", + Count = 99 + }; + var newEntity = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "RequiredName", + Name = "NewName", + Count = 1 + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], + new MySqlBulkInsertOrUpdateOptions { TableName = "TestEntities_BulkUpsertRedirect" }); + + affectedRows.Should().Be(2); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + (await LoadColumnAsync("TestEntities_BulkUpsertRedirect", "Name")).Should().BeEquivalentTo(["NewName", "UpdatedName"]); + } + finally + { + await DropTablesAsync("TestEntities_BulkUpsertRedirect"); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_or_update_entities_in_table_from_table_name_provider(bool useGenericOptions) + { + await CreateRedirectTableAsync("TestEntities_BulkUpsertRedirect"); + + try + { + var existingEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName", + Name = "UpdatedName", + Count = 99 + }; + var newEntity = new TestEntity + { + Id = _OTHER_TEST_ENTITY_ID, + RequiredName = "RequiredName", + Name = "NewName", + Count = 1 + }; + var provider = new SuffixTableNameProvider("_BulkUpsertRedirect"); + IBulkInsertOrUpdateOptions options = useGenericOptions + ? new GenericBulkOperationOptions { TableNameProvider = provider } + : new MySqlBulkInsertOrUpdateOptions { TableNameProvider = provider }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], options); + + affectedRows.Should().Be(2); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + (await LoadColumnAsync("TestEntities_BulkUpsertRedirect", "Name")).Should().BeEquivalentTo(["NewName", "UpdatedName"]); + provider.RequestedEntityTypes.Should().ContainSingle(); + } + finally + { + await DropTablesAsync("TestEntities_BulkUpsertRedirect"); + } + } + + [Fact] + public async Task Should_insert_or_update_entities_in_table_of_the_database_from_ForRootEntity_provider() + { + // MySQL has no schemas below the database, so the schema of the table name is the name of the database. + var database = await ActDbContext.Database.SqlQueryRaw("SELECT DATABASE() AS `Value`").SingleAsync(); + await CreateRedirectTableAsync("TestEntities_BulkUpsertRedirect"); + + try + { + var affectedRows = await SUT.BulkInsertOrUpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName" }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "NewName" } + ], + new MySqlBulkInsertOrUpdateOptions + { + TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities_BulkUpsertRedirect", database) + }); + + affectedRows.Should().Be(2); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + (await LoadColumnAsync("TestEntities_BulkUpsertRedirect", "Name")).Should().BeEquivalentTo(["NewName", "UpdatedName"]); + } + finally + { + await DropTablesAsync("TestEntities_BulkUpsertRedirect"); + } + } + + [Fact] + public async Task Should_throw_when_trying_to_insert_or_update_temp_table_entity_without_table_name() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + // The entity is keyless, but the missing table name is the problem to report (as the other providers do). + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync(new List> { new(0) }, new MySqlBulkInsertOrUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*configured as a temp table entity*Provide the target table name*"); + } + + [Fact] + public async Task Should_throw_when_trying_to_insert_or_update_keyed_temp_table_entity_without_table_name() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync(new[] { new TestEntityTempTable { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" } }, new MySqlBulkInsertOrUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*configured as a temp table entity*Provide the target table name*"); + } + + [Fact] + public async Task Should_insert_or_update_temp_table_entity_when_table_name_is_provided() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + var existingEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var updatedEntity = new TestEntityTempTable + { + Id = _TEST_ENTITY_ID, + Name = "UpdatedName", + RequiredName = "RequiredName", + Count = 99 + }; + var newEntity = new TestEntityTempTable + { + Id = _OTHER_TEST_ENTITY_ID, + Name = "NewName", + RequiredName = "RequiredName", + Count = 1 + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync(new[] { updatedEntity, newEntity }, new MySqlBulkInsertOrUpdateOptions { TableName = "TestEntities" }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, "NewName", 1), + (_TEST_ENTITY_ID, "UpdatedName", 99)); + } + + [Fact] + public async Task Should_insert_or_update_temp_table_entity_with_ForRootEntity_provider() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + var existingEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var updatedEntity = new TestEntityTempTable + { + Id = _TEST_ENTITY_ID, + Name = "UpdatedName", + RequiredName = "RequiredName", + Count = 99 + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync(new[] { updatedEntity }, + new MySqlBulkInsertOrUpdateOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities") }); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().Be("UpdatedName"); + loadedEntity.Count.Should().Be(99); + } + + [Fact] + public async Task Should_upsert_inlined_owned_type() + { + var existingEntity = new TestEntity_Owns_Inline { Id = _TEST_ENTITY_ID, InlineEntity = new OwnedEntity { IntColumn = 1, StringColumn = "old" } }; + ArrangeDbContext.TestEntities_Own_Inline.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.InlineEntity.IntColumn = 2; + existingEntity.InlineEntity.StringColumn = "new"; + var newEntity = new TestEntity_Owns_Inline { Id = _OTHER_TEST_ENTITY_ID, InlineEntity = new OwnedEntity { IntColumn = 3, StringColumn = null } }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities_Own_Inline.OrderBy(e => e.InlineEntity.IntColumn).ToListAsync(); + loadedEntities.Should().BeEquivalentTo([existingEntity, newEntity], config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_upsert_nested_inlined_owned_type() + { + var existingEntity = new TestEntity_Owns_Inline_Inline + { + Id = _TEST_ENTITY_ID, + InlineEntity = new OwnedEntity_Owns_Inline { IntColumn = 1, StringColumn = "old", InlineEntity = new OwnedEntity { IntColumn = 10, StringColumn = "old inner" } } + }; + ArrangeDbContext.TestEntities_Own_Inline_Inline.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.InlineEntity.IntColumn = 2; + existingEntity.InlineEntity.InlineEntity.StringColumn = "new inner"; + var newEntity = new TestEntity_Owns_Inline_Inline + { + Id = _OTHER_TEST_ENTITY_ID, + InlineEntity = new OwnedEntity_Owns_Inline { IntColumn = 3, StringColumn = "new", InlineEntity = new OwnedEntity { IntColumn = 30, StringColumn = null } } + }; + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, newEntity], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities_Own_Inline_Inline.OrderBy(e => e.InlineEntity.IntColumn).ToListAsync(); + loadedEntities.Should().BeEquivalentTo([existingEntity, newEntity], config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_throw_on_upsert_of_owned_type_in_separate_table() + { + ArrangeDbContext.TestEntities_Own_SeparateOne.Add(new TestEntity_Owns_SeparateOne { Id = new Guid("7C00ABFE-875B-4396-BE51-3E898647A264"), SeparateEntity = new OwnedEntity { IntColumn = 1, StringColumn = "old" } }); + await ArrangeDbContext.SaveChangesAsync(); + + var entity = await ActDbContext.TestEntities_Own_SeparateOne.SingleAsync(); + entity.SeparateEntity!.IntColumn = 2; + + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*owned entities mapped to separate tables*"); + + (await AssertDbContext.TestEntities_Own_SeparateOne.SingleAsync()).SeparateEntity!.IntColumn.Should().Be(1); + } + + [Fact] + public async Task Should_throw_on_upsert_of_owned_collection_in_separate_table() + { + ArrangeDbContext.TestEntities_Own_SeparateMany.Add(new TestEntity_Owns_SeparateMany { Id = new Guid("54FF93FC-6BE9-4F19-A52E-E517CA9FEAA7"), SeparateEntities = [new() { IntColumn = 1, StringColumn = "old" }] }); + await ArrangeDbContext.SaveChangesAsync(); + + var entity = await ActDbContext.TestEntities_Own_SeparateMany.SingleAsync(); + entity.SeparateEntities[0].IntColumn = 2; + + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*owned entities mapped to separate tables*"); + + (await AssertDbContext.TestEntities_Own_SeparateMany.SingleAsync()).SeparateEntities.Single().IntColumn.Should().Be(1); + } + + [Fact] + public async Task Should_throw_on_upsert_of_separate_owned_type_inside_inlined_owned_type() + { + var entity = new TestEntity_Owns_Inline_SeparateOne + { + Id = _TEST_ENTITY_ID, + InlineEntity = new OwnedEntity_Owns_SeparateOne { IntColumn = 1, StringColumn = "old", SeparateEntity = new OwnedEntity { IntColumn = 10, StringColumn = "old inner" } } + }; + ArrangeDbContext.TestEntities_Own_Inline_SeparateOne.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.InlineEntity.IntColumn = 2; + entity.InlineEntity.SeparateEntity!.IntColumn = 20; + + // The separately mapped owned type is not nested in the root entity directly, but it must not end up in the INSERT statement of the root table either. + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions())) + .Should().ThrowAsync(); + + var loadedEntity = await AssertDbContext.TestEntities_Own_Inline_SeparateOne.SingleAsync(); + loadedEntity.InlineEntity.IntColumn.Should().Be(1); + loadedEntity.InlineEntity.SeparateEntity!.IntColumn.Should().Be(10); + } + + [Fact] + public async Task Should_throw_on_update_of_separate_owned_type_inside_inlined_owned_type() + { + var entity = new TestEntity_Owns_Inline_SeparateOne + { + Id = _TEST_ENTITY_ID, + InlineEntity = new OwnedEntity_Owns_SeparateOne { IntColumn = 1, StringColumn = "old", SeparateEntity = new OwnedEntity { IntColumn = 10, StringColumn = "old inner" } } + }; + ArrangeDbContext.TestEntities_Own_Inline_SeparateOne.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.InlineEntity.IntColumn = 2; + entity.InlineEntity.SeparateEntity!.IntColumn = 20; + + await SUT.Awaiting(sut => sut.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions())) + .Should().ThrowAsync(); + + var loadedEntity = await AssertDbContext.TestEntities_Own_Inline_SeparateOne.SingleAsync(); + loadedEntity.InlineEntity.IntColumn.Should().Be(1); + loadedEntity.InlineEntity.SeparateEntity!.IntColumn.Should().Be(10); + } + + // The tests below cover behavior specific to the MySQL executor: batching, transactions, command timeout, syntax of old server versions. + + [Fact] + public async Task Should_not_use_a_temp_table() + { + await SUT.BulkInsertOrUpdateAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }], new MySqlBulkInsertOrUpdateOptions()); + + ExecutedCommands.Should().NotContain(command => command.Contains("TEMPORARY", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(1, null, 1)] + [InlineData(499, null, 1)] + [InlineData(500, null, 1)] // the default batch size + [InlineData(501, null, 2)] + [InlineData(1001, null, 3)] + [InlineData(7, 1, 7)] + [InlineData(7, 2, 4)] + [InlineData(7, 100, 1)] + public async Task Should_split_rows_into_statements_according_to_batch_size(int rowCount, int? batchSize, int expectedStatements) + { + // every second row exists already, so each batch inserts and updates rows + ArrangeDbContext.TestEntitiesWithAutoIncrement.AddRange(Enumerable.Range(1, rowCount).Where(id => id % 2 == 0).Select(id => new TestEntityWithAutoIncrement { Id = id, Name = $"old {id}" })); + await ArrangeDbContext.SaveChangesAsync(); + + var entities = Enumerable.Range(1, rowCount).Select(id => new TestEntityWithAutoIncrement { Id = id, Name = $"new {id}" }).ToList(); + + // The status counters are per session. The connection stays open, so the executor and the status queries share the session. + await ActDbContext.Database.OpenConnectionAsync(); + + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + var affectedRows = await SUT.BulkInsertOrUpdateAsync(entities, new MySqlBulkInsertOrUpdateOptions { BatchSize = batchSize }); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + affectedRows.Should().Be(rowCount); + statements.Should().Be(expectedStatements); + + var loadedNames = await AssertDbContext.TestEntitiesWithAutoIncrement.OrderBy(e => e.Id).Select(e => e.Name).ToListAsync(); + loadedNames.Should().Equal(Enumerable.Range(1, rowCount).Select(id => $"new {id}")); + } + + [Fact] + public async Task Should_limit_rows_per_statement_to_the_parameter_limit_of_mysql() + { + // MySQL accepts 65,535 parameters per statement, so the number of rows per statement is limited by the number of columns. + var columnCount = ActDbContext.Model.FindEntityType(typeof(TestEntity))!.GetProperties().Count(); + var maximumRows = 65_535 / columnCount; + var entities = Enumerable.Range(0, maximumRows + 1).Select(count => new TestEntity { Id = Guid.NewGuid(), RequiredName = "RequiredName", Count = count }); + await ActDbContext.Database.OpenConnectionAsync(); + + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + var affectedRows = await SUT.BulkInsertOrUpdateAsync(entities, new MySqlBulkInsertOrUpdateOptions { BatchSize = Int32.MaxValue }); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + affectedRows.Should().Be(maximumRows + 1); + statements.Should().Be(2); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(maximumRows + 1); + } + + [Fact] + public async Task Should_upsert_row_that_references_another_row_of_the_same_batch() + { + var parent = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Parent" }; + var child = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Child", ParentId = _TEST_ENTITY_ID }; + + // MySQL validates foreign keys row by row, so the parent has to come first. + var affectedRows = await SUT.BulkInsertOrUpdateAsync([parent, child], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.ParentId)).Should().Equal((_OTHER_TEST_ENTITY_ID, (Guid?)_TEST_ENTITY_ID), + (_TEST_ENTITY_ID, (Guid?)null)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task Should_throw_if_batch_size_is_not_positive(int batchSize) + { + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }], new MySqlBulkInsertOrUpdateOptions { BatchSize = batchSize })) + .Should().ThrowAsync() + .WithMessage("*batch size*greater than 0*"); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_upsert_entities_with_command_timeout() + { + var affectedRows = await SUT.BulkInsertOrUpdateAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }], + new MySqlBulkInsertOrUpdateOptions { CommandTimeout = TimeSpan.FromSeconds(30) }); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Should_apply_command_timeout_to_upsert_statements() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + // The uncommitted update of another transaction locks the row until the lock wait timeout of the server (50 seconds by default) is reached. + await using var blockingTransaction = await ArrangeDbContext.Database.BeginTransactionAsync(); + await ArrangeDbContext.Database.ExecuteSqlRawAsync("UPDATE `TestEntities` SET `Name` = 'Blocking' WHERE `Id` = {0}", _TEST_ENTITY_ID); + + var stopwatch = Stopwatch.StartNew(); + + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([entity], new MySqlBulkInsertOrUpdateOptions { CommandTimeout = TimeSpan.FromSeconds(1) })) + .Should().ThrowAsync(); + + stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(20)); + } + + [Fact] + public async Task Should_throw_when_cancellation_is_requested() + { + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await SUT.Awaiting(sut => sut.BulkInsertOrUpdateAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }], new MySqlBulkInsertOrUpdateOptions(), cancellationTokenSource.Token)) + .Should().ThrowAsync(); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_apply_updates_and_inserts_of_earlier_batches_if_a_later_batch_fails() + { + var existingEntity = new TestEntityWithSqlDefaultValues { Id = _TEST_ENTITY_ID, Int = 5, String = "old", NullableInt = 6, NullableString = "old" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var act = () => SUT.BulkInsertOrUpdateAsync([ + new TestEntityWithSqlDefaultValues { Id = _TEST_ENTITY_ID, Int = 7, String = "new", NullableInt = 8, NullableString = "new" }, + new TestEntityWithSqlDefaultValues { Id = _OTHER_TEST_ENTITY_ID, Int = 9, String = "inserted", NullableInt = 10, NullableString = "inserted" }, + new TestEntityWithSqlDefaultValues { Id = _THIRD_TEST_ENTITY_ID, Int = 11, String = new string('x', 101), NullableInt = 12, NullableString = "too long" } + ], + new MySqlBulkInsertOrUpdateOptions { BatchSize = 1 }); + + // 1406: data too long for the column (strict mode) + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1406); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDefaultValues.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(existingEntity); + } + + [Fact] + public async Task Should_upsert_rows_and_commit_with_the_transaction_of_the_caller() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Count = 42; + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + (await AssertDbContext.TestEntities.AsNoTracking().ToListAsync()).Should().ContainSingle().Which.Count.Should().Be(0, "the transaction of the caller is not committed yet"); + + await transaction.CommitAsync(); + + var loadedEntities = await AssertDbContext.TestEntities.AsNoTracking().OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, 1), (_TEST_ENTITY_ID, 42)); + } + + [Fact] + public async Task Should_not_upsert_rows_if_the_transaction_of_the_caller_is_rolled_back() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Count = 42; + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + await transaction.RollbackAsync(); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Id.Should().Be(_TEST_ENTITY_ID); + loadedEntity.Count.Should().Be(0); + } + + [Fact] + public async Task Should_upsert_rows_and_commit_with_the_transaction_scope() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Count = 42; + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + scope.Complete(); + } + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, 1), (_TEST_ENTITY_ID, 42)); + } + + [Fact] + public async Task Should_roll_back_the_upsert_if_the_transaction_scope_is_not_completed() + { + var existingEntity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(existingEntity); + await ArrangeDbContext.SaveChangesAsync(); + + existingEntity.Count = 42; + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var affectedRows = await SUT.BulkInsertOrUpdateAsync([existingEntity, new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }], new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + } + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Id.Should().Be(_TEST_ENTITY_ID); + loadedEntity.Count.Should().Be(0); + } + + [Theory] + [InlineData(false, "8.4.0", false)] + [InlineData(false, "8.0.19", false)] + [InlineData(false, "8.0.18", true)] + [InlineData(false, "5.7.44", true)] + [InlineData(true, "10.11.0", true)] + public async Task Should_use_the_syntax_of_the_configured_server_version(bool isMariaDb, string version, bool expectValuesFunction) + { + await using var context = CreateContext(isMariaDb ? new MariaDbServerVersion(Version.Parse(version)) : new MySqlServerVersion(Version.Parse(version))); + context.Entities.Add(new MySqlTestEntity { Id = 1, Name = "old" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + await using var transaction = await context.Database.BeginTransactionAsync(); + + var affectedRows = await context.GetService().BulkInsertOrUpdateAsync([new MySqlTestEntity { Id = 1, Name = "new" }, new MySqlTestEntity { Id = 2, Name = "added" }], + new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(2); + + // The deprecated VALUES(col) function causes warning 1287 on MySQL 8.4; the row alias doesn't. + var warnings = await GetWarningsAsync(context); + if (expectValuesFunction) + warnings.Should().Contain(warning => warning.Code == 1287); + else + warnings.Should().NotContain(warning => warning.Code == 1287); + + await transaction.CommitAsync(); + + var loadedEntities = await AssertDbContext.Entities.OrderBy(e => e.Id).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name)).Should().Equal((1, "new"), (2, "added")); + } + + [Fact] + public async Task Should_guard_the_update_with_the_key_columns_on_server_versions_without_row_alias() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = null, RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "value", RequiredName = "RequiredName", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + await using var context = CreateContext(_SERVER_VERSION_WITHOUT_ROW_ALIAS); + + var affectedRows = await context.GetService().BulkInsertOrUpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, Name = null, RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "other value", RequiredName = "RequiredName", Count = 20 }, + new TestEntity { Id = _THIRD_TEST_ENTITY_ID, Name = "new", RequiredName = "RequiredName", Count = 30 } + ], + new MySqlBulkInsertOrUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.Name) + }); + + affectedRows.Should().Be(3); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_OTHER_TEST_ENTITY_ID, "value", 2), + (_TEST_ENTITY_ID, (string?)null, 10), + (_THIRD_TEST_ENTITY_ID, "new", 30)); + } + + [Fact] + public async Task Should_skip_rows_that_collide_on_a_unique_index_on_server_versions_without_row_alias() + { + ArrangeDbContext.Entities.AddRange(new MySqlTestEntity { Id = 1, Name = "a" }, new MySqlTestEntity { Id = 2, Name = "b" }); + await ArrangeDbContext.SaveChangesAsync(); + await using var context = CreateContext(_SERVER_VERSION_WITHOUT_ROW_ALIAS); + + var affectedRows = await context.GetService().BulkInsertOrUpdateAsync([ + new MySqlTestEntity { Id = 9, Name = "a" }, + new MySqlTestEntity { Id = 2, Name = "b changed" }, + new MySqlTestEntity { Id = 10, Name = "new" } + ], + new MySqlBulkInsertOrUpdateOptions { BatchSize = 2 }); + + affectedRows.Should().Be(3); + + var loadedEntities = await AssertDbContext.Entities.OrderBy(e => e.Id).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name)).Should().Equal((1, "a"), (2, "b changed"), (10, "new")); + } + + [Fact] + public async Task Should_upsert_entities_with_composite_primary_key_on_server_versions_without_row_alias() + { + ArrangeDbContext.CompositeEntities.AddRange(new MySqlCompositeEntity { RegionId = 1, ItemId = 1, Name = "1-1" }, + new MySqlCompositeEntity { RegionId = 1, ItemId = 2, Name = "1-2" }); + await ArrangeDbContext.SaveChangesAsync(); + await using var context = CreateContext(_SERVER_VERSION_WITHOUT_ROW_ALIAS); + + var affectedRows = await context.GetService().BulkInsertOrUpdateAsync([ + new MySqlCompositeEntity { RegionId = 1, ItemId = 1, Name = "changed 1-1" }, + new MySqlCompositeEntity { RegionId = 1, ItemId = 3, Name = "new 1-3" }, + new MySqlCompositeEntity { RegionId = 2, ItemId = 1, Name = "new 2-1" } + ], + new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(3); + + var loadedEntities = await AssertDbContext.CompositeEntities.OrderBy(e => e.RegionId).ThenBy(e => e.ItemId).ToListAsync(); + loadedEntities.Select(e => (e.RegionId, e.ItemId, e.Name)).Should().Equal((1, 1, "changed 1-1"), + (1, 2, "1-2"), + (1, 3, "new 1-3"), + (2, 1, "new 2-1")); + } + + [Fact] + public async Task Should_only_insert_new_rows_if_no_property_is_left_to_update_on_server_versions_without_row_alias() + { + ArrangeDbContext.Entities.Add(new MySqlTestEntity { Id = 1, Name = "a" }); + await ArrangeDbContext.SaveChangesAsync(); + await using var context = CreateContext(_SERVER_VERSION_WITHOUT_ROW_ALIAS); + + var affectedRows = await context.GetService().BulkInsertOrUpdateAsync([new MySqlTestEntity { Id = 1, Name = "changed" }, new MySqlTestEntity { Id = 2, Name = "new" }], + new MySqlBulkInsertOrUpdateOptions { PropertiesToUpdate = IEntityPropertiesProvider.Empty }); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.Entities.OrderBy(e => e.Id).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name)).Should().Equal((1, "a"), (2, "new")); + } + + private MySqlTestDbContext CreateContext(ServerVersion serverVersion) + { + var options = new DbContextOptionsBuilder() + .UseMySql(ConnectionString, serverVersion, builder => builder.AddBulkOperationSupport()) + .Options; + + return new MySqlTestDbContext(options); + } + + // The warnings belong to the last statement of the session, so the connection has to be the one of the executor and no statement may run in between. + private static async Task> GetWarningsAsync(DbContext context) + { + await using var command = context.Database.GetDbConnection().CreateCommand(); + command.Transaction = context.Database.CurrentTransaction?.GetDbTransaction(); + command.CommandText = "SHOW WARNINGS"; + await using var reader = await command.ExecuteReaderAsync(); + + var warnings = new List<(string Level, int Code, string Message)>(); + + while (await reader.ReadAsync()) + { + warnings.Add((reader.GetString(0), Convert.ToInt32(reader.GetValue(1), CultureInfo.InvariantCulture), reader.GetString(2))); + } + + return warnings; + } + + // The status counters are per session; see Should_split_rows_into_statements_according_to_batch_size. + private async Task GetSessionStatusAsync(string name) + { + await using var command = ActDbContext.Database.GetDbConnection().CreateCommand(); + command.CommandText = $"SHOW SESSION STATUS LIKE '{name}'"; + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return Int64.Parse(reader.GetString(1), CultureInfo.InvariantCulture); + } + + // Creates a copy of the table "TestEntities" that contains one row, and empties the original table, so that the upsert is visible in the copy only. + private async Task CreateRedirectTableAsync(string tableName) + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "OldName" }); + await ArrangeDbContext.SaveChangesAsync(); + + await ActDbContext.Database.ExecuteSqlRawAsync($"CREATE TABLE {Quote(tableName)} LIKE `TestEntities`"); + await ActDbContext.Database.ExecuteSqlRawAsync($"INSERT INTO {Quote(tableName)} SELECT * FROM `TestEntities`"); + await ActDbContext.Database.ExecuteSqlRawAsync("DELETE FROM `TestEntities`"); + } + + private async Task> LoadColumnAsync(string tableName, string columnName) + { + return await AssertDbContext.Database.SqlQueryRaw($"SELECT {Quote(columnName)} AS `Value` FROM {Quote(tableName)}").ToListAsync(); + } + + private async Task DropTablesAsync(params string[] tableNames) + { + foreach (var tableName in tableNames) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"DROP TABLE IF EXISTS {Quote(tableName)}"); + } + } + + private static string Quote(string identifier) + { + return $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`"; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateAsync.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateAsync.cs new file mode 100644 index 0000000..5f7f4ae --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateAsync.cs @@ -0,0 +1,1359 @@ +using System.Diagnostics; +using System.Globalization; +using System.Transactions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.TestDatabaseContext; + +#pragma warning disable CS0618 // TableName and Schema are obsolete; these tests cover the obsolete path on purpose. + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +// The executor stages the rows in a temp table (EF creates it, so the CREATE statement is visible in ExecutedCommands) and runs one raw +// "UPDATE target INNER JOIN staging" statement. That statement is not visible in ExecutedCommands, so the tests verify the resulting rows +// and the return values (the number of matched rows, i.e. rows with unchanged values are counted as well). +// Inside a System.Transactions.TransactionScope the executor updates row by row because MySQL rejects temp tables inside XA transactions. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkUpdateAsync : MySqlIntegrationTestsBase +{ + private static readonly Guid _TEST_ENTITY_ID = new("40B5CA93-5C02-48AD-B8A1-12BC13313866"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("8AF163D7-D316-4B2D-A62F-6326A80C8BEE"); + private static readonly Guid _THIRD_TEST_ENTITY_ID = new("5B9587A3-2312-43DF-9681-38EC22AD8606"); + private static readonly Guid _MISSING_TEST_ENTITY_ID = new("506E664A-9ADC-4221-9577-71DCFD73DE64"); + + private MySqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService(); + + public BulkUpdateAsync(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_not_throw_if_entities_is_empty() + { + var affectedRows = await SUT.BulkUpdateAsync(new List(), new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(0); + } + + [Fact] + public async Task Should_not_throw_if_key_property_is_not_present_in_PropertiesToUpdate() + { + var propertiesProvider = IEntityPropertiesProvider.Include(entity => new { entity.Name }); + + var affectedRows = await SUT.BulkUpdateAsync(new List(), new MySqlBulkUpdateOptions { PropertiesToUpdate = propertiesProvider }); + + affectedRows.Should().Be(0); + } + + [Fact] + public async Task Should_throw_when_entity_has_no_key() + { + await SUT.Awaiting(sut => sut.BulkUpdateAsync(new List { new() }, new MySqlBulkUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("The entity 'Thinktecture.TestDatabaseContext.KeylessTestEntity' has no primary key. Please provide key properties to perform JOIN/match on."); + } + + [Fact] + public async Task Should_update_entities() + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + entity_1.Name = "Name"; + entity_1.Count = 1; + entity_2.Name = "OtherName"; + entity_2.Count = 2; + + var affectedRows = await SUT.BulkUpdateAsync([entity_1, entity_2], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([entity_1, entity_2]); + } + + [Fact] + public async Task Should_return_0_if_no_rows_match() + { + var entity = new TestEntity { Id = _THIRD_TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await SUT.BulkUpdateAsync(new List { new() { Id = _MISSING_TEST_ENTITY_ID, RequiredName = "Other", Count = 42 } }, new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(0); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(entity); + } + + [Fact] + public async Task Should_update_provided_entity_only() + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + entity_1.Name = "Name"; + entity_1.Count = 1; + entity_2.Name = "OtherName"; + entity_2.Count = 2; + + var affectedRows = await SUT.BulkUpdateAsync([entity_1], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + entity_2.Name = default; + entity_2.Count = default; + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([entity_1, entity_2]); + } + + [Fact] + public async Task Should_count_matched_rows_whose_values_do_not_change() + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 1 }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "OtherName", RequiredName = "RequiredName", Count = 2 }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + // MySqlConnector requests the number of found rows instead of the number of changed rows. + var affectedRows = await SUT.BulkUpdateAsync([entity_1, entity_2], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([entity_1, entity_2]); + } + + [Fact] + public async Task Should_update_the_existing_rows_of_the_input_only() + { + var existing = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 1 }; + var untouched = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Untouched", RequiredName = "RequiredName", Count = 2 }; + ArrangeDbContext.AddRange(existing, untouched); + await ArrangeDbContext.SaveChangesAsync(); + + existing.Name = "Changed"; + existing.Count = 10; + var missing = new TestEntity { Id = _MISSING_TEST_ENTITY_ID, Name = "Missing", RequiredName = "RequiredName", Count = 3 }; + + var affectedRows = await SUT.BulkUpdateAsync([existing, missing], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([existing, untouched]); + } + + [Fact] + public async Task Should_update_specified_properties_only() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "Name"; // this would not be updated + entity.Count = 42; + entity.PropertyWithBackingField = 7; + entity.SetPrivateField(3); + + var affectedRows = await SUT.BulkUpdateAsync([entity], + new MySqlBulkUpdateOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()) + }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + Count = 42, + PropertyWithBackingField = 7, + Name = "original value", + RequiredName = "RequiredName" + }); + loadedEntity.GetPrivateField().Should().Be(3); + } + + [Fact] + public async Task Should_stage_key_and_update_properties_only_in_the_temp_table() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "Name"; + + await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions { PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Name) }); + + var createStatement = ExecutedCommands.Should().ContainSingle(command => command.StartsWith("CREATE TEMPORARY TABLE", StringComparison.Ordinal)).Subject; + createStatement.Should().Contain("`Id`").And.Contain("`Name`"); + createStatement.Should().NotContain("`RequiredName`").And.NotContain("`Count`"); + } + + [Fact] + public async Task Should_update_entities_based_on_non_pk_property() + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, Name = "value", RequiredName = "RequiredName" }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = null, RequiredName = "RequiredName" }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + entity_1.Name = null; + entity_1.Count = 1; + entity_2.Name = "value"; + entity_2.Count = 2; + + var properties = IEntityPropertiesProvider.Include(e => new { e.Count }); + var keyProperties = IEntityPropertiesProvider.Include(e => e.Name); + var affectedRows = await SUT.BulkUpdateAsync([entity_1, entity_2], new MySqlBulkUpdateOptions { PropertiesToUpdate = properties, KeyProperties = keyProperties }); + + affectedRows.Should().Be(2); + + // The key is the name, so the counts are swapped between the rows. + entity_1.Name = "value"; + entity_2.Name = null; + + entity_1.Count = 2; + entity_2.Count = 1; + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([entity_1, entity_2]); + } + + [Fact] + public async Task Should_not_update_key_columns_that_are_selected_for_update() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName", Count = 1 }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + // The row is matched by Name; Name is selected for update as well but is never part of the SET clause. + var affectedRows = await SUT.BulkUpdateAsync([new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "original value", RequiredName = "Other", Count = 42 }], + new MySqlBulkUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.Name), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => new { e.Name, e.Count }) + }); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName", Count = 42 }); + } + + [Fact] + public async Task Should_update_entities_based_on_composite_non_pk_properties() + { + ArrangeDbContext.TestEntitiesWithFlags.AddRange(new TestEntityWithFlags { Id = _TEST_ENTITY_ID, GroupId = 1, Phase = PhaseMembership.A }, + new TestEntityWithFlags { Id = _OTHER_TEST_ENTITY_ID, GroupId = 1, Phase = PhaseMembership.B }, + new TestEntityWithFlags { Id = _THIRD_TEST_ENTITY_ID, GroupId = 2, Phase = PhaseMembership.B }); + await ArrangeDbContext.SaveChangesAsync(); + + // Only the row with GroupId = 1 and Phase = B matches both key columns. + var affectedRows = await SUT.BulkUpdateAsync([new TestEntityWithFlags { Id = _MISSING_TEST_ENTITY_ID, GroupId = 1, Phase = PhaseMembership.B }], + new MySqlBulkUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => new { e.GroupId, e.Phase }), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Id) + }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntitiesWithFlags.OrderBy(e => e.GroupId).ThenBy(e => e.Phase).ToListAsync(); + loadedEntities.Should().HaveCount(3); + loadedEntities.Select(e => (e.Id, e.GroupId, e.Phase)).Should().Equal((_TEST_ENTITY_ID, 1, PhaseMembership.A), + (_MISSING_TEST_ENTITY_ID, 1, PhaseMembership.B), + (_THIRD_TEST_ENTITY_ID, 2, PhaseMembership.B)); + } + + [Fact] + public async Task Should_match_keys_with_the_collation_of_the_column() + { + ArrangeDbContext.TestEntitiesWithCollation.AddRange(new TestEntityWithCollation(_TEST_ENTITY_ID, "old 1", "abc"), + new TestEntityWithCollation(_OTHER_TEST_ENTITY_ID, "old 2", "ABC")); + await ArrangeDbContext.SaveChangesAsync(); + + // The column is case-sensitive (utf8mb4_bin), so "ABC" must not match "abc". Without the collation in the staging table the JOIN fails with error 1267. + var affectedRows = await SUT.BulkUpdateAsync([new TestEntityWithCollation(_MISSING_TEST_ENTITY_ID, "new", "ABC")], + new MySqlBulkUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.ColumnWithCollation), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.ColumnWithoutCollation) + }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntitiesWithCollation.OrderBy(e => e.ColumnWithCollation).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.ColumnWithoutCollation, e.ColumnWithCollation)).Should().Equal((_OTHER_TEST_ENTITY_ID, "new", "ABC"), + (_TEST_ENTITY_ID, "old 1", "abc")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_update_entities_with_composite_primary_key(bool useTransactionScope) + { + ArrangeDbContext.CompositeEntities.AddRange(new MySqlCompositeEntity { RegionId = 1, ItemId = 1, Name = "1-1" }, + new MySqlCompositeEntity { RegionId = 1, ItemId = 2, Name = "1-2" }, + new MySqlCompositeEntity { RegionId = 2, ItemId = 1, Name = "2-1" }, + new MySqlCompositeEntity { RegionId = 2, ItemId = 2, Name = "2-2" }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await UpdateAsync([ + new MySqlCompositeEntity { RegionId = 1, ItemId = 2, Name = "changed 1-2" }, + new MySqlCompositeEntity { RegionId = 2, ItemId = 1, Name = "changed 2-1" }, + new MySqlCompositeEntity { RegionId = 3, ItemId = 1, Name = "missing 3-1" } + ], + new MySqlBulkUpdateOptions(), + useTransactionScope); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.CompositeEntities.OrderBy(e => e.RegionId).ThenBy(e => e.ItemId).ToListAsync(); + loadedEntities.Select(e => (e.RegionId, e.ItemId, e.Name)).Should().Equal((1, 1, "1-1"), + (1, 2, "changed 1-2"), + (2, 1, "changed 2-1"), + (2, 2, "2-2")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_match_null_keys_null_safe(bool useTransactionScope) + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", NullableCount = null, Count = 0 }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", NullableCount = 5, Count = 0 }; + var entity_3 = new TestEntity { Id = _THIRD_TEST_ENTITY_ID, RequiredName = "RequiredName", NullableCount = 6, Count = 0 }; + ArrangeDbContext.AddRange(entity_1, entity_2, entity_3); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await UpdateAsync([ + new TestEntity { NullableCount = null, Count = 10 }, + new TestEntity { NullableCount = 5, Count = 20 }, + new TestEntity { NullableCount = 7, Count = 30 } + ], + new MySqlBulkUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.NullableCount), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Count) + }, + useTransactionScope); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).ThenBy(e => e.NullableCount).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.NullableCount, e.Count)).Should().Equal((_TEST_ENTITY_ID, (int?)null, 10), + (_OTHER_TEST_ENTITY_ID, 5, 20), + (_THIRD_TEST_ENTITY_ID, 6, 0)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_match_keys_with_converter(bool useTransactionScope) + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(1), Count = 0 }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(2), Count = 0 }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await UpdateAsync([ + new TestEntity { ConvertibleClass = new ConvertibleClass(2), Count = 20 }, + new TestEntity { ConvertibleClass = new ConvertibleClass(3), Count = 30 } + ], + new MySqlBulkUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.ConvertibleClass), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Count) + }, + useTransactionScope); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Count)).Should().Equal((_TEST_ENTITY_ID, 0), (_OTHER_TEST_ENTITY_ID, 20)); + } + + [Fact] + public async Task Should_update_entity_with_auto_increment() + { + var entity = new TestEntityWithAutoIncrement { Name = "original value" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "Name"; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + // The staging table of a regular entity must not renumber explicit ids. + ExecutedCommands.Should().ContainSingle(command => command.StartsWith("CREATE TEMPORARY TABLE", StringComparison.Ordinal)) + .Which.Should().NotContain("AUTO_INCREMENT"); + + var loadedEntities = await AssertDbContext.TestEntitiesWithAutoIncrement.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Id.Should().NotBe(0); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithAutoIncrement + { + Id = loadedEntity.Id, + Name = "Name" + }); + } + + [Fact] + public async Task Should_update_entity_with_auto_increment_having_custom_property_selector() + { + var entity = new TestEntityWithAutoIncrement { Name = "original value" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "Name"; + + var affectedRows = await SUT.BulkUpdateAsync([entity], + new MySqlBulkUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.Id), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => new + { + e.Id, + e.Name + }) + }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntitiesWithAutoIncrement.ToListAsync(); + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Id.Should().NotBe(0); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithAutoIncrement + { + Id = entity.Id, + Name = "Name" + }); + } + + [Fact] + public async Task Should_update_private_property() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.SetPrivateField(1); + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.GetPrivateField().Should().Be(1); + } + + [Fact] + public async Task Should_update_shadow_properties() + { + var entity = new TestEntityWithShadowProperties { Id = _TEST_ENTITY_ID }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + ActDbContext.Entry(entity).Property("ShadowStringProperty").CurrentValue = "value"; + ActDbContext.Entry(entity).Property("ShadowIntProperty").CurrentValue = 42; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithShadowProperties.SingleAsync(); + AssertDbContext.Entry(loadedEntity).Property("ShadowStringProperty").CurrentValue.Should().Be("value"); + AssertDbContext.Entry(loadedEntity).Property("ShadowIntProperty").CurrentValue.Should().Be(42); + } + + [Fact] + public async Task Should_update_column_with_converter() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.ConvertibleClass = new ConvertibleClass(43); + + var affectedRows = await SUT.BulkUpdateAsync(new List { entity }, new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.ConvertibleClass.Should().NotBeNull(); + loadedEntity.ConvertibleClass!.Key.Should().Be(43); + } + + [Fact] + public async Task Should_update_nullable_columns_to_null() + { + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + NullableCount = 42, + ConvertibleClass = new ConvertibleClass(43) + }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = null; + entity.NullableCount = null; + entity.ConvertibleClass = null; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().BeNull(); + loadedEntity.NullableCount.Should().BeNull(); + loadedEntity.ConvertibleClass.Should().BeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_update_all_kinds_of_properties(bool useTransactionScope) + { + var entity_1 = new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 1, NullableCount = 1, ConvertibleClass = new ConvertibleClass(1) }; + var entity_2 = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "RequiredName" }; + var untouched = new TestEntity { Id = _THIRD_TEST_ENTITY_ID, Name = "Untouched", RequiredName = "RequiredName", Count = 3 }; + ArrangeDbContext.AddRange(entity_1, entity_2, untouched); + await ArrangeDbContext.SaveChangesAsync(); + + entity_1.Name = null; + entity_1.RequiredName = "Changed"; + entity_1.Count = 42; + entity_1.NullableCount = null; + entity_1.ConvertibleClass = new ConvertibleClass(43); + entity_1.PropertyWithBackingField = 7; + entity_1.SetPrivateField(3); + + entity_2.Name = "O'Brien \\ \"quoted\" `tick`"; + entity_2.NullableCount = 5; + entity_2.ConvertibleClass = null; + entity_2.SetPrivateField(9); + + var missing = new TestEntity { Id = _MISSING_TEST_ENTITY_ID, RequiredName = "Missing", Count = 100 }; + + var affectedRows = await UpdateAsync([entity_1, missing, entity_2], new MySqlBulkUpdateOptions(), useTransactionScope); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().HaveCount(3); + loadedEntities[0].Should().BeEquivalentTo(entity_2); + loadedEntities[0].GetPrivateField().Should().Be(9); + loadedEntities[1].Should().BeEquivalentTo(untouched); + loadedEntities[1].GetPrivateField().Should().Be(0); + loadedEntities[2].Should().BeEquivalentTo(entity_1); + loadedEntities[2].GetPrivateField().Should().Be(3); + } + + [Fact] + public async Task Should_update_entity_with_ComplexType() + { + var testEntity = new TestEntityWithComplexType(new Guid("54FF93FC-6BE9-4F19-A52E-E517CA9FEAA7"), new BoundaryValueObject(2, 5)); + + ArrangeDbContext.Add(testEntity); + await ArrangeDbContext.SaveChangesAsync(); + + testEntity.Boundary = new BoundaryValueObject(10, 20); + + var affectedRows = await SUT.BulkUpdateAsync([testEntity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities_with_ComplexType.ToListAsync(); + loadedEntities.Should().BeEquivalentTo([testEntity]); + } + + [Fact] + public async Task Should_update_property_of_base_class() + { + var entity = new TestEntityWithBaseClass { Id = new Guid("3D3AECE7-3B5A-48C6-9C8C-CAB7FFE2120D"), Name = "Initial" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "changed"; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Name) + }); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithBaseClass.SingleAsync(); + loadedEntity.Name.Should().Be("changed"); + } + + [Fact] + public async Task Should_update_flags_enum() + { + var entity = new TestEntityWithFlags { Id = _TEST_ENTITY_ID, GroupId = 1, Phase = PhaseMembership.A }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.GroupId = 2; + entity.Phase = PhaseMembership.A | PhaseMembership.C; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithFlags.SingleAsync(); + loadedEntity.GroupId.Should().Be(2); + loadedEntity.Phase.Should().Be(PhaseMembership.A | PhaseMembership.C); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_update_entities_with_different_column_names(bool useTransactionScope) + { + var entity_1 = new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Name 1", Count = 1 }; + var entity_2 = new TestEntityWithDifferentColumnNames { Id = _OTHER_TEST_ENTITY_ID, Name = "Name 2", Count = 2 }; + ArrangeDbContext.AddRange(entity_1, entity_2); + await ArrangeDbContext.SaveChangesAsync(); + + entity_1.Name = null; + entity_1.Count = 10; + entity_2.Name = "Changed 2"; + + var affectedRows = await UpdateAsync([entity_1, entity_2], new MySqlBulkUpdateOptions(), useTransactionScope); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntitiesWithDifferentColumnNames.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Should().BeEquivalentTo([entity_2, entity_1], config => config.WithStrictOrdering()); + } + + [Fact] + public async Task Should_match_on_non_pk_property_with_different_column_name() + { + ArrangeDbContext.TestEntitiesWithDifferentColumnNames.AddRange(new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Name 1", Count = 1 }, + new TestEntityWithDifferentColumnNames { Id = _OTHER_TEST_ENTITY_ID, Name = "Name 2", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await SUT.BulkUpdateAsync([new TestEntityWithDifferentColumnNames { Id = _MISSING_TEST_ENTITY_ID, Name = "Changed", Count = 2 }], + new MySqlBulkUpdateOptions + { + KeyProperties = IEntityPropertiesProvider.Include(e => e.Count), + PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Name) + }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntitiesWithDifferentColumnNames.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Name, e.Count)).Should().Equal((_TEST_ENTITY_ID, "Name 1", 1), + (_OTHER_TEST_ENTITY_ID, "Changed", 2)); + } + + [Fact] + public async Task Should_write_provided_column_values_as_is_despite_sql_default_value() + { + var entity = new TestEntityWithSqlDefaultValues + { + Id = _TEST_ENTITY_ID, + Int = 5, + String = "value", + NullableInt = 6, + NullableString = "otherValue" + }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Int = 0; + entity.String = "other value"; + entity.NullableInt = null; + entity.NullableString = null; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDefaultValues.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntityWithSqlDefaultValues + { + Id = _TEST_ENTITY_ID, + Int = 0, // persisted as-is + NullableInt = null, // persisted as-is + String = "other value", + NullableString = null // persisted as-is + }); + } + + [Fact] + public async Task Should_update_entity_with_json_column() + { + var entity = new TestEntityWithJsonColumns + { + Id = _TEST_ENTITY_ID, + JsonColumn = """{"key": "original"}""" + }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.JsonColumn = """{"key": "updated"}"""; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + // MySQL normalizes the JSON text; the value is already in the normalized form. + var loadedEntity = await AssertDbContext.TestEntitiesWithJsonColumns.SingleAsync(); + loadedEntity.JsonColumn.Should().Be("""{"key": "updated"}"""); + } + + [Fact] + public async Task Should_accept_options_of_other_providers() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Name = "Name"; // this would not be updated + entity.Count = 42; + + var affectedRows = await SUT.BulkUpdateAsync([entity], + new GenericBulkOperationOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Include(TestEntity.GetRequiredProperties()) + }); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Should().BeEquivalentTo(new TestEntity { Id = _TEST_ENTITY_ID, Name = "original value", RequiredName = "RequiredName", Count = 42 }); + } + + [Fact] + public async Task Should_update_entities_in_table_name_override() + { + await CreateRedirectTableAsync("TestEntities_BulkUpdateRedirect", "OldName"); + + try + { + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName", + Name = "UpdatedName", + Count = 99 + }; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions { TableName = "TestEntities_BulkUpdateRedirect" }); + + affectedRows.Should().Be(1); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + (await LoadColumnAsync("TestEntities_BulkUpdateRedirect", "Name")).Should().Equal("UpdatedName"); + (await LoadColumnAsync("TestEntities_BulkUpdateRedirect", "Count")).Should().Equal(99); + } + finally + { + await DropTablesAsync("TestEntities_BulkUpdateRedirect"); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_update_entities_in_table_from_table_name_provider(bool useGenericOptions) + { + await CreateRedirectTableAsync("TestEntities_BulkUpdateRedirect", "OldName"); + + try + { + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + RequiredName = "RequiredName", + Name = "UpdatedName", + Count = 99 + }; + var provider = new SuffixTableNameProvider("_BulkUpdateRedirect"); + + IBulkUpdateOptions options = useGenericOptions + ? new GenericBulkOperationOptions { TableNameProvider = provider } + : new MySqlBulkUpdateOptions { TableNameProvider = provider }; + + var affectedRows = await SUT.BulkUpdateAsync([entity], options); + + affectedRows.Should().Be(1); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "original table should be empty"); + (await LoadColumnAsync("TestEntities_BulkUpdateRedirect", "Name")).Should().Equal("UpdatedName"); + provider.RequestedEntityTypes.Should().ContainSingle(); + } + finally + { + await DropTablesAsync("TestEntities_BulkUpdateRedirect"); + } + } + + [Fact] + public async Task Should_update_entities_in_table_of_the_database_from_ForRootEntity_provider() + { + // MySQL has no schemas below the database, so the schema of the table name is the name of the database. + var database = await ActDbContext.Database.SqlQueryRaw("SELECT DATABASE() AS `Value`").SingleAsync(); + await CreateRedirectTableAsync("TestEntities_BulkUpdateRedirect", "OldName"); + + try + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName" }; + + var affectedRows = await SUT.BulkUpdateAsync([entity], + new MySqlBulkUpdateOptions + { + TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities_BulkUpdateRedirect", database) + }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync("TestEntities_BulkUpdateRedirect", "Name")).Should().Equal("UpdatedName"); + } + finally + { + await DropTablesAsync("TestEntities_BulkUpdateRedirect"); + } + } + + [Fact] + public async Task Should_throw_when_trying_to_update_temp_table_entity_without_table_name() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + // The entity is keyless, but the missing table name is the problem to report (as the other providers do). + await SUT.Awaiting(sut => sut.BulkUpdateAsync(new List> { new(0) }, new MySqlBulkUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*configured as a temp table entity*Provide the target table name*"); + } + + [Fact] + public async Task Should_throw_when_trying_to_update_keyed_temp_table_entity_without_table_name() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + await SUT.Awaiting(sut => sut.BulkUpdateAsync(new[] { new TestEntityTempTable { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" } }, new MySqlBulkUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*configured as a temp table entity*Provide the target table name*"); + } + + [Fact] + public async Task Should_update_temp_table_entity_when_table_name_is_provided() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + var tempEntity = new TestEntityTempTable + { + Id = _TEST_ENTITY_ID, + Name = "UpdatedName", + RequiredName = "RequiredName", + Count = 99 + }; + + var affectedRows = await SUT.BulkUpdateAsync(new[] { tempEntity }, new MySqlBulkUpdateOptions { TableName = "TestEntities" }); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.ToListAsync(); + loadedEntities.Should().HaveCount(1); + loadedEntities[0].Id.Should().Be(_TEST_ENTITY_ID); + loadedEntities[0].Name.Should().Be("UpdatedName"); + loadedEntities[0].Count.Should().Be(99); + } + + [Fact] + public async Task Should_update_temp_table_entity_with_ForRootEntity_provider() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, TestEntityTempTable.Configure); + + var entity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + var tempEntity = new TestEntityTempTable + { + Id = _TEST_ENTITY_ID, + Name = "UpdatedName", + RequiredName = "RequiredName", + Count = 99 + }; + + var affectedRows = await SUT.BulkUpdateAsync(new[] { tempEntity }, new MySqlBulkUpdateOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity("TestEntities") }); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().Be("UpdatedName"); + loadedEntity.Count.Should().Be(99); + } + + [Fact] + public async Task Should_throw_on_update_of_inlined_owned_type() + { + ArrangeDbContext.TestEntities_Own_Inline.Add(new TestEntity_Owns_Inline { Id = new Guid("3A1B2FFF-8E11-44E5-80E5-8C7FEEDACEB3"), InlineEntity = new OwnedEntity { IntColumn = 1, StringColumn = "old" } }); + await ArrangeDbContext.SaveChangesAsync(); + + var entity = await ActDbContext.TestEntities_Own_Inline.SingleAsync(); + entity.InlineEntity.IntColumn = 2; + + // The staging temp table doesn't support owned types (same as with PostgreSQL and SQL Server). + await SUT.Awaiting(sut => sut.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("Temp tables don't support owned entities."); + + (await AssertDbContext.TestEntities_Own_Inline.SingleAsync()).InlineEntity.IntColumn.Should().Be(1); + } + + [Fact] + public async Task Should_throw_on_update_of_owned_type_in_separate_table() + { + ArrangeDbContext.TestEntities_Own_SeparateOne.Add(new TestEntity_Owns_SeparateOne { Id = new Guid("7C00ABFE-875B-4396-BE51-3E898647A264"), SeparateEntity = new OwnedEntity { IntColumn = 1, StringColumn = "old" } }); + await ArrangeDbContext.SaveChangesAsync(); + + var entity = await ActDbContext.TestEntities_Own_SeparateOne.SingleAsync(); + entity.SeparateEntity!.IntColumn = 2; + + await SUT.Awaiting(sut => sut.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*owned entities mapped to separate tables*"); + + (await AssertDbContext.TestEntities_Own_SeparateOne.SingleAsync()).SeparateEntity!.IntColumn.Should().Be(1); + } + + [Fact] + public async Task Should_throw_on_update_of_owned_collection_in_separate_table() + { + ArrangeDbContext.TestEntities_Own_SeparateMany.Add(new TestEntity_Owns_SeparateMany { Id = new Guid("54FF93FC-6BE9-4F19-A52E-E517CA9FEAA7"), SeparateEntities = [new() { IntColumn = 1, StringColumn = "old" }] }); + await ArrangeDbContext.SaveChangesAsync(); + + var entity = await ActDbContext.TestEntities_Own_SeparateMany.SingleAsync(); + entity.SeparateEntities[0].IntColumn = 2; + + await SUT.Awaiting(sut => sut.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions())) + .Should().ThrowAsync() + .WithMessage("*owned entities mapped to separate tables*"); + + (await AssertDbContext.TestEntities_Own_SeparateMany.SingleAsync()).SeparateEntities.Single().IntColumn.Should().Be(1); + } + + // The tests below cover behavior specific to the MySQL executor: staging table, batching, transactions, command timeout. + + [Theory] + [InlineData(1, null, 1)] + [InlineData(500, null, 1)] // the default batch size + [InlineData(501, null, 2)] + [InlineData(10, 1, 10)] + [InlineData(10, 3, 4)] + [InlineData(10, 100, 1)] + public async Task Should_stage_rows_in_batches_according_to_batch_size(int rowCount, int? batchSize, int expectedStatements) + { + var entities = Enumerable.Range(0, rowCount).Select(count => new TestEntity { Id = Guid.NewGuid(), RequiredName = "RequiredName", Count = count }).ToList(); + ArrangeDbContext.AddRange(entities); + await ArrangeDbContext.SaveChangesAsync(); + + foreach (var entity in entities) + { + entity.Count += 1000; + } + + // The status counters are per session. The connection stays open, so the executor and the status queries share the session. + await ActDbContext.Database.OpenConnectionAsync(); + + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + var affectedRows = await SUT.BulkUpdateAsync(entities, new MySqlBulkUpdateOptions { BatchSize = batchSize, PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Count) }); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + affectedRows.Should().Be(rowCount); + statements.Should().Be(expectedStatements); + + var loadedCounts = await AssertDbContext.TestEntities.Select(e => e.Count).OrderBy(c => c).ToListAsync(); + loadedCounts.Should().Equal(Enumerable.Range(1000, rowCount)); + } + + [Fact] + public async Task Should_limit_rows_per_staging_statement_to_the_parameter_limit_of_mysql() + { + // The staging table has all columns of the entity and MySQL accepts 65,535 parameters per statement, so the number of rows per statement is limited by the number of columns. + var columnCount = ActDbContext.Model.FindEntityType(typeof(TestEntity))!.GetProperties().Count(); + var maximumRows = 65_535 / columnCount; + var entities = Enumerable.Range(0, maximumRows + 1).Select(count => new TestEntity { Id = Guid.NewGuid(), RequiredName = "RequiredName", Count = count }).ToList(); + await ArrangeDbContext.GetService().BulkInsertAsync(entities, new MySqlBulkInsertOptions()); + + foreach (var entity in entities) + { + entity.Count += 100_000; + } + + await ActDbContext.Database.OpenConnectionAsync(); + + var statementsBefore = await GetSessionStatusAsync("Com_insert"); + var affectedRows = await SUT.BulkUpdateAsync(entities, new MySqlBulkUpdateOptions { BatchSize = Int32.MaxValue }); + var statements = await GetSessionStatusAsync("Com_insert") - statementsBefore; + + affectedRows.Should().Be(maximumRows + 1); + statements.Should().Be(2); + (await AssertDbContext.TestEntities.CountAsync(e => e.Count >= 100_000)).Should().Be(maximumRows + 1); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task Should_throw_if_batch_size_is_not_positive(int batchSize) + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + await SUT.Awaiting(sut => sut.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions { BatchSize = batchSize })) + .Should().ThrowAsync() + .WithMessage("*batch size*greater than 0*"); + + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(0); + } + + [Fact] + public async Task Should_drop_the_staging_table_after_the_update() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + // A temp table belongs to the session. The connection stays open, so the executor and the query below share the session. + await ActDbContext.Database.OpenConnectionAsync(); + + await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + var stagingTable = ExecutedCommands.Should().ContainSingle(command => command.StartsWith("CREATE TEMPORARY TABLE", StringComparison.Ordinal)).Subject; + var tableName = stagingTable.Split('`')[1]; + + // A temporary table exists for the session only; if the executor didn't drop it, the table could still be queried. + var act = () => ActDbContext.Database.ExecuteSqlRawAsync($"SELECT 1 FROM `{tableName}`"); + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1146); + } + + [Fact] + public async Task Should_update_entities_with_command_timeout() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions { CommandTimeout = TimeSpan.FromSeconds(30) }); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(42); + } + + [Fact] + public async Task Should_apply_command_timeout_to_update_statement() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + // The uncommitted update of another transaction locks the row until the lock wait timeout of the server (50 seconds by default) is reached. + await using var blockingTransaction = await ArrangeDbContext.Database.BeginTransactionAsync(); + await ArrangeDbContext.Database.ExecuteSqlRawAsync("UPDATE `TestEntities` SET `Name` = 'Blocking' WHERE `Id` = {0}", _TEST_ENTITY_ID); + + var stopwatch = Stopwatch.StartNew(); + + await SUT.Awaiting(sut => sut.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions { CommandTimeout = TimeSpan.FromSeconds(1) })) + .Should().ThrowAsync(); + + stopwatch.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(20)); + } + + [Fact] + public async Task Should_throw_when_cancellation_is_requested() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await SUT.Awaiting(sut => sut.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions(), cancellationTokenSource.Token)) + .Should().ThrowAsync(); + + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(0); + } + + [Fact] + public async Task Should_not_update_any_row_if_a_later_batch_of_the_staging_table_fails() + { + ArrangeDbContext.Entities.AddRange(new MySqlTestEntity { Id = 1, Name = "A" }, new MySqlTestEntity { Id = 2, Name = "B" }, new MySqlTestEntity { Id = 3, Name = "C" }); + await ArrangeDbContext.SaveChangesAsync(); + + var act = () => SUT.BulkUpdateAsync([ + new MySqlTestEntity { Id = 1, Name = "A changed" }, + new MySqlTestEntity { Id = 2, Name = "B changed" }, + new MySqlTestEntity { Id = 3, Name = new string('x', 300) } + ], + new MySqlBulkUpdateOptions { BatchSize = 1 }); + + // 1406: data too long for the column of the staging table (strict mode) + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1406); + + var names = await AssertDbContext.Entities.OrderBy(e => e.Id).Select(e => e.Name).ToListAsync(); + names.Should().Equal("A", "B", "C"); + } + + [Fact] + public async Task Should_not_apply_any_update_if_the_update_statement_fails() + { + ArrangeDbContext.Entities.AddRange(new MySqlTestEntity { Id = 1, Name = "A" }, new MySqlTestEntity { Id = 2, Name = "B" }, new MySqlTestEntity { Id = 3, Name = "C" }); + await ArrangeDbContext.SaveChangesAsync(); + + // The name of MySqlTestEntity has a unique index. Row 1 can be updated, row 2 collides with row 3. + var act = () => SUT.BulkUpdateAsync([ + new MySqlTestEntity { Id = 1, Name = "A changed" }, + new MySqlTestEntity { Id = 2, Name = "C" } + ], + new MySqlBulkUpdateOptions { PropertiesToUpdate = IEntityPropertiesProvider.Include(e => e.Name) }); + + // 1062: duplicate entry for the unique index + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1062); + + var names = await AssertDbContext.Entities.OrderBy(e => e.Id).Select(e => e.Name).ToListAsync(); + names.Should().Equal("A", "B", "C"); + } + + [Fact] + public async Task Should_not_apply_any_update_if_a_foreign_key_is_violated() + { + var parent = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Parent", Count = 1 }; + var child = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Child", Count = 2, ParentId = _TEST_ENTITY_ID }; + ArrangeDbContext.AddRange(parent, child); + await ArrangeDbContext.SaveChangesAsync(); + + parent.Count = 10; + child.Count = 20; + child.ParentId = _MISSING_TEST_ENTITY_ID; + + var act = () => SUT.BulkUpdateAsync([parent, child], new MySqlBulkUpdateOptions()); + + // 1452: cannot add or update a child row, a foreign key constraint fails + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1452); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Count).ToListAsync(); + loadedEntities.Select(e => (e.Id, e.Count, e.ParentId)).Should().Equal((_TEST_ENTITY_ID, 1, (Guid?)null), + (_OTHER_TEST_ENTITY_ID, 2, _TEST_ENTITY_ID)); + } + + [Fact] + public async Task Should_throw_if_the_input_contains_the_same_primary_key_twice() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + // The staging table has the primary key of the entity. + var act = () => SUT.BulkUpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 2 } + ], + new MySqlBulkUpdateOptions()); + + // 1062: duplicate entry for the primary key of the staging table + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1062); + + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(0); + } + + [Fact] + public async Task Should_update_rows_and_commit_with_the_transaction_of_the_caller() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(0, "the transaction of the caller is not committed yet"); + + await transaction.CommitAsync(); + + AssertDbContext.ChangeTracker.Clear(); + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(42); + } + + [Fact] + public async Task Should_not_update_rows_if_the_transaction_of_the_caller_is_rolled_back() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + + await transaction.RollbackAsync(); + + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(0); + } + + [Fact] + public async Task Should_update_rows_inside_a_transaction_scope_without_a_staging_table() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + var affectedRows = await UpdateAsync([entity], new MySqlBulkUpdateOptions(), true); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(42); + ExecutedCommands.Should().NotContain(command => command.Contains("TEMPORARY", StringComparison.Ordinal), "MySQL rejects temp tables inside XA transactions"); + } + + [Fact] + public async Task Should_roll_back_the_update_if_the_transaction_scope_is_not_completed() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + entity.Count = 42; + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var affectedRows = await SUT.BulkUpdateAsync([entity], new MySqlBulkUpdateOptions()); + + affectedRows.Should().Be(1); + } + + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(0); + } + + [Fact] + public async Task Should_apply_duplicates_of_a_key_in_order_inside_a_transaction_scope() + { + var entity = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }; + ArrangeDbContext.Add(entity); + await ArrangeDbContext.SaveChangesAsync(); + + // Row by row there is no staging table with a primary key, so every row is applied, the last one wins. + var affectedRows = await UpdateAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 2 } + ], + new MySqlBulkUpdateOptions(), + true); + + affectedRows.Should().Be(2); + (await AssertDbContext.TestEntities.SingleAsync()).Count.Should().Be(2); + } + + private async Task UpdateAsync(IReadOnlyList entities, IBulkUpdateOptions options, bool useTransactionScope) + where T : class + { + if (!useTransactionScope) + return await SUT.BulkUpdateAsync(entities, options); + + using var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled); + var affectedRows = await SUT.BulkUpdateAsync(entities, options); + scope.Complete(); + + return affectedRows; + } + + // The status counters are per session; see Should_stage_rows_in_batches_according_to_batch_size. + private async Task GetSessionStatusAsync(string name) + { + await using var command = ActDbContext.Database.GetDbConnection().CreateCommand(); + command.CommandText = $"SHOW SESSION STATUS LIKE '{name}'"; + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return Int64.Parse(reader.GetString(1), CultureInfo.InvariantCulture); + } + + // Creates a copy of the table "TestEntities" with the rows of that table (the entity is the only one of the test) and empties the original table, + // so that an update is visible in the copy only. + private async Task CreateRedirectTableAsync(string tableName, string name) + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = name }); + await ArrangeDbContext.SaveChangesAsync(); + + await ActDbContext.Database.ExecuteSqlRawAsync($"CREATE TABLE {Quote(tableName)} LIKE `TestEntities`"); + await ActDbContext.Database.ExecuteSqlRawAsync($"INSERT INTO {Quote(tableName)} SELECT * FROM `TestEntities`"); + await ActDbContext.Database.ExecuteSqlRawAsync("DELETE FROM `TestEntities`"); + } + + private async Task> LoadColumnAsync(string tableName, string columnName) + { + return await AssertDbContext.Database.SqlQueryRaw($"SELECT {Quote(columnName)} AS `Value` FROM {Quote(tableName)}").ToListAsync(); + } + + private async Task DropTablesAsync(params string[] tableNames) + { + foreach (var tableName in tableNames) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"DROP TABLE IF EXISTS {Quote(tableName)}"); + } + } + + private static string Quote(string identifier) + { + return $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`"; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsync.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsync.cs new file mode 100644 index 0000000..78fac73 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsync.cs @@ -0,0 +1,36 @@ +using Thinktecture.EntityFrameworkCore.BulkOperations; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkUpdateFromQueryAsync : MySqlIntegrationTestsBase +{ + private const string _TARGET_TABLE = "query_targets_override"; + + public BulkUpdateFromQueryAsync(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_update_the_table_selected_by_the_table_name_provider() + { + await ArrangeDbContext.Database.ExecuteSqlRawAsync($"CREATE TABLE `{_TARGET_TABLE}` LIKE `QueryTargets`"); + await ArrangeDbContext.Database.ExecuteSqlRawAsync($"INSERT INTO `{_TARGET_TABLE}` (`Id`, `Name`, `Count`) VALUES (1, 'before', 1)"); + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "after", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources.AsNoTracking(), + target => target.Id, + source => source.Id, + setters => setters + .Set(target => target.Name, (_, source) => source.Name) + .Set(target => target.Count, (_, source) => source.Count), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_TARGET_TABLE) }); + + affectedRows.Should().Be(1); + (await AssertDbContext.Database.SqlQueryRaw($"SELECT `Name` AS `Value` FROM `{_TARGET_TABLE}`").SingleAsync()).Should().Be("after"); + ExecutedCommands.Should().Contain(command => command.Contains($"UPDATE `{_TARGET_TABLE}`", StringComparison.Ordinal)); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsyncTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsyncTests.cs new file mode 100644 index 0000000..6fbfb9e --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/BulkUpdateFromQueryAsyncTests.cs @@ -0,0 +1,1602 @@ +using System.Text.RegularExpressions; +using System.Transactions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.TestDatabaseContext; + +#pragma warning disable CS0618 // TableName and Schema are obsolete; one test covers the obsolete path on purpose. + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +// "UPDATE target AS t INNER JOIN source AS s ON keys SET ... [WHERE filter]" is executed by EF Core (ExecuteSqlRawAsync), so the statement is visible in ExecutedCommands. +// ExecutedCommands contains the setup statements of the tests as well, so the tests pick the statement by its prefix. +// - The number of affected rows is the number of matched rows (MySqlConnector requests the found rows), i.e. rows with unchanged values are counted as well. +// - The keys are compared with "=", i.e. rows with a NULL key never match. +// - A MySQL temp table can be referenced once per statement, which is all that the join needs. The target table can be the source table as well, +// because MySQL materializes a derived table. +// - A single statement is atomic, i.e. a failing statement leaves the target table unchanged. +// - The temp table of the source is not available inside a TransactionScope (MySQL XA transactions reject temp tables), +// so the tests with a transaction use regular tables as the source. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkUpdateFromQueryAsyncTests : MySqlIntegrationTestsBase +{ + private const string _REDIRECT_TABLE = "TestEntities_QueryUpdateRedirect"; + + private static readonly Guid _TEST_ENTITY_ID = new("40B5CA93-5C02-48AD-B8A1-12BC13313866"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("8AF163D7-D316-4B2D-A62F-6326A80C8BEE"); + private static readonly Guid _THIRD_TEST_ENTITY_ID = new("5B9587A3-2312-43DF-9681-38EC22AD8606"); + private static readonly Guid _MISSING_TEST_ENTITY_ID = new("506E664A-9ADC-4221-9577-71DCFD73DE64"); + private static readonly ConvertibleClass _staticConvertibleClass = new(42); + + private static string StaticName => "StaticValue"; + + private MySqlBulkOperationExecutor SUT => field ??= ActDbContext.GetService(); + + public BulkUpdateFromQueryAsyncTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_update_entities_from_temp_table() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName" }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName" }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated1", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Updated2", RequiredName = "RequiredName", Count = 20 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Name).ToListAsync(); + loadedEntities.Select(e => e.Name).Should().Equal("Updated1", "Updated2"); + loadedEntities.Select(e => e.Count).Should().Equal(10, 20); + } + + [Fact] + public async Task Should_update_multiple_properties() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "OrigReq", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated", RequiredName = "UpdatedReq", Count = 42 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.RequiredName, (e, f) => f.RequiredName) + .Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().Be("Updated"); + loadedEntity.RequiredName.Should().Be("UpdatedReq"); + loadedEntity.Count.Should().Be(42); + } + + [Fact] + public async Task Should_update_only_the_rows_with_matching_keys() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName" }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Untouched", RequiredName = "RequiredName" }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated1", RequiredName = "RequiredName" }, + new TestEntity { Id = _MISSING_TEST_ENTITY_ID, Name = "Missing", RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync(tempTable.Query, e => e.Id, f => f.Id, builder => builder.Set(e => e.Name, (e, f) => f.Name)); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Name).ToListAsync(); + loadedEntities.Select(e => e.Name).Should().Equal("Untouched", "Updated1"); + } + + [Fact] + public async Task Should_return_0_if_no_matching_rows() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName" }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _MISSING_TEST_ENTITY_ID, Name = "Updated", RequiredName = "RequiredName", Count = 99 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync(tempTable.Query, e => e.Id, f => f.Id, builder => builder.Set(e => e.Name, (e, f) => f.Name)); + + affectedRows.Should().Be(0); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("Original"); + } + + [Fact] + public async Task Should_count_matched_rows_whose_values_do_not_change() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "OtherName", RequiredName = "RequiredName", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name", RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Changed", RequiredName = "RequiredName", Count = 2 }); + + // MySqlConnector requests the number of found rows instead of the number of changed rows. + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(2); + } + + [Fact] + public async Task Should_return_0_if_temp_table_is_empty() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName" }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync(tempTable.Query, e => e.Id, f => f.Id, builder => builder.Set(e => e.Name, (e, f) => f.Name)); + + affectedRows.Should().Be(0); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("Original"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_return_0_if_source_query_returns_no_rows(bool useConstantPredicate) + { + await ArrangeQueryRowsAsync(); + + var sourceQuery = useConstantPredicate + ? ActDbContext.QuerySources.Where(s => false) + : ActDbContext.QuerySources.Where(s => s.Count > 100); + + var affectedRows = await ActDbContext.QueryTargets.BulkUpdateAsync(sourceQuery, t => t.Id, s => s.Id, builder => builder.Set(t => t.Name, (t, s) => s.Name)); + + affectedRows.Should().Be(0); + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be("Target"); + } + + [Fact] + public async Task Should_generate_correct_sql_shape() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated", RequiredName = "RequiredName" }); + + await ActDbContext.TestEntities.BulkUpdateAsync(tempTable.Query, e => e.Id, f => f.Id, builder => builder.Set(e => e.Name, (e, f) => f.Name)); + + // MySQL has no "UPDATE ... FROM": the source is joined and the keys are part of the join condition. + var statement = GetExecutedStatement("UPDATE "); + statement.Should().StartWith($"UPDATE `TestEntities` AS `t` INNER JOIN `{tempTable.Name}` AS `t0` ON `t`.`Id` = `t0`.`Id` SET `t`.`Name` = `t0`.`Name`"); + statement.Should().NotContain(" FROM "); + statement.Should().NotContain(" WHERE "); + } + + [Fact] + public async Task Should_update_from_regular_dbset_query() + { + await ArrangeQueryRowsAsync(); + + var affectedRows = await UpdateQueryTargetsAsync(); + + affectedRows.Should().Be(1); + + var loadedTarget = await AssertDbContext.QueryTargets.SingleAsync(); + loadedTarget.Name.Should().Be("Source"); + loadedTarget.Count.Should().Be(2); + // The aliases are derived from the names of the entities. + GetExecutedStatement("UPDATE ").Should().MatchRegex(@"^UPDATE `QueryTargets` AS `(\w+)` INNER JOIN `QuerySources` AS `(\w+)` ON `\1`\.`Id` = `\2`\.`Id` SET "); + } + + [Fact] + public async Task Should_update_from_regular_dbset_with_different_column_names() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + ArrangeDbContext.TestEntitiesWithDifferentColumnNames.Add(new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "FromSource", Count = 42 }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + ActDbContext.TestEntitiesWithDifferentColumnNames, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().Be("FromSource"); + loadedEntity.Count.Should().Be(42); + + GetExecutedStatement("UPDATE ").Should().Contain("ON `t`.`Id` = `t0`.`entity_id` SET `t`.`Name` = `t0`.`display_name`, `t`.`Count` = `t0`.`item_count`"); + } + + [Fact] + public async Task Should_update_target_with_different_column_names() + { + ArrangeDbContext.TestEntitiesWithDifferentColumnNames.Add(new TestEntityWithDifferentColumnNames { Id = _TEST_ENTITY_ID, Name = "Original", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated", RequiredName = "RequiredName", Count = 42 }); + + var affectedRows = await ActDbContext.TestEntitiesWithDifferentColumnNames.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntitiesWithDifferentColumnNames.SingleAsync(); + loadedEntity.Name.Should().Be("Updated"); + loadedEntity.Count.Should().Be(42); + + GetExecutedStatement("UPDATE ").Should().Contain("ON `t`.`entity_id` = `t0`.`Id` SET `t`.`display_name` = `t0`.`Name`, `t`.`item_count` = `t0`.`Count`"); + } + + [Fact] + public async Task Should_update_from_projection_of_the_same_table_by_nullable_foreign_key() + { + var parent = new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Parent", Count = 99 }; + var child = new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Child", ParentId = _TEST_ENTITY_ID, Count = 0 }; + var orphan = new TestEntity { Id = _THIRD_TEST_ENTITY_ID, RequiredName = "Orphan", ParentId = null, Count = 0 }; + ArrangeDbContext.TestEntities.AddRange(parent, child, orphan); + await ArrangeDbContext.SaveChangesAsync(); + + // The source rows come from the table that is updated. + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + ActDbContext.TestEntities.Select(e => new { e.Id, e.Count }), + e => e.ParentId, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).ToListAsync(); + loadedEntities.Select(e => e.RequiredName).Should().Equal("Child", "Orphan", "Parent"); + loadedEntities.Select(e => e.Count).Should().Equal(99, 0, 99); + } + + [Fact] + public async Task Should_update_from_the_same_table_as_source() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "First", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Second", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + ActDbContext.TestEntities, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => f.Count + 10)); + + affectedRows.Should().Be(2); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).ToListAsync(); + loadedEntities.Select(e => e.Count).Should().Equal(11, 12); + } + + [Fact] + public async Task Should_update_entity_with_composite_primary_key_from_the_same_table() + { + ArrangeDbContext.CompositeEntities.AddRange(new MySqlCompositeEntity { RegionId = 1, ItemId = 1, Name = "1-1" }, + new MySqlCompositeEntity { RegionId = 1, ItemId = 2, Name = "1-2" }, + new MySqlCompositeEntity { RegionId = 2, ItemId = 1, Name = "2-1" }, + new MySqlCompositeEntity { RegionId = 2, ItemId = 3, Name = "2-3" }); + await ArrangeDbContext.SaveChangesAsync(); + + // Copies the names of region 1 to the rows of region 2 with the same item. + var sourceQuery = ActDbContext.CompositeEntities.Where(e => e.RegionId == 1).Select(e => new { RegionId = e.RegionId + 1, e.ItemId, e.Name }); + + var affectedRows = await ActDbContext.CompositeEntities.BulkUpdateAsync( + sourceQuery, + e => new { e.RegionId, e.ItemId }, + f => new { f.RegionId, f.ItemId }, + builder => builder.Set(e => e.Name, (e, f) => f.Name)); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.CompositeEntities.OrderBy(e => e.RegionId).ThenBy(e => e.ItemId).ToListAsync(); + loadedEntities.Select(e => e.Name).Should().Equal("1-1", "1-2", "1-1", "2-3"); + } + + [Fact] + public async Task Should_apply_parameters_of_the_source_query() + { + ArrangeDbContext.QueryTargets.AddRange(new MySqlQueryTarget { Id = 1, Name = "Target 1", Count = 0 }, + new MySqlQueryTarget { Id = 2, Name = "Target 2", Count = 0 }, + new MySqlQueryTarget { Id = 3, Name = "Target 3", Count = 0 }, + new MySqlQueryTarget { Id = 4, Name = "Target 4", Count = 0 }); + ArrangeDbContext.QuerySources.AddRange(new MySqlQuerySource { Id = 1, Name = "Source 1", Count = 1 }, + new MySqlQuerySource { Id = 2, Name = "Source 2", Count = 5 }, + new MySqlQuerySource { Id = 3, Name = "Source 3", Count = 6 }, + new MySqlQuerySource { Id = 4, Name = "Source 4", Count = 9 }); + await ArrangeDbContext.SaveChangesAsync(); + var minCount = 5; + var take = 2; + + var affectedRows = await ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources.Where(s => s.Count >= minCount).OrderBy(s => s.Count).Take(take), + t => t.Id, + s => s.Id, + builder => builder.Set(t => t.Name, (t, s) => s.Name)); + + affectedRows.Should().Be(2); + + var loadedTargets = await AssertDbContext.QueryTargets.OrderBy(t => t.Id).ToListAsync(); + loadedTargets.Select(t => t.Name).Should().Equal("Target 1", "Source 2", "Source 3", "Target 4"); + } + + [Fact] + public async Task Should_update_with_composite_key_join() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "RequiredName", Count = 0 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Name2", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "RequiredName", Count = 42 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "WrongName", RequiredName = "RequiredName", Count = 99 }); + + // Join on both Id and Name: only the first entity matches + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => new { e.Id, e.Name }, + f => new { f.Id, f.Name }, + builder => builder.Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Name).ToListAsync(); + loadedEntities.Select(e => e.Count).Should().Equal(42, 0); + GetExecutedStatement("UPDATE ").Should().Contain("ON (`t`.`Id` = `t0`.`Id`) AND (`t`.`Name` = `t0`.`Name`)"); + } + + [Fact] + public async Task Should_not_match_rows_with_null_keys() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Target 1", NullableCount = 1, Count = 0 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Target 2", NullableCount = null, Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _THIRD_TEST_ENTITY_ID, RequiredName = "Source 1", NullableCount = 1, Count = 10 }, + new TestEntity { Id = _MISSING_TEST_ENTITY_ID, RequiredName = "Source 2", NullableCount = null, Count = 20 }); + + // NULL = NULL is not true, so the keys are not matched null-safe (unlike the entity-based bulk update). + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.NullableCount, + f => f.NullableCount, + builder => builder.Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).ToListAsync(); + loadedEntities.Select(e => e.Count).Should().Equal(10, 0); + } + + [Fact] + public async Task Should_update_with_key_of_converted_type() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "Target 1", ConvertibleClass = new ConvertibleClass(1), Count = 0 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "Target 2", ConvertibleClass = new ConvertibleClass(2), Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _THIRD_TEST_ENTITY_ID, RequiredName = "Source", ConvertibleClass = new ConvertibleClass(1), Count = 10 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.ConvertibleClass, + f => f.ConvertibleClass, + builder => builder.Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).ToListAsync(); + loadedEntities.Select(e => e.Count).Should().Equal(10, 0); + } + + [Fact] + public async Task Should_update_entities_in_table_name_override() + { + await CreateRedirectTableAsync(); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName", Count = 99 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count), + options: new MySqlBulkUpdateFromQueryOptions { TableName = _REDIRECT_TABLE }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("UpdatedName"); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("Original", "the table of the model is not changed"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_update_entities_in_table_from_ForRootEntity_provider() + { + await CreateRedirectTableAsync(); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName", Count = 99 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("UpdatedName"); + (await LoadColumnAsync(_REDIRECT_TABLE, "Count")).Should().Equal(99); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("Original", "the table of the model is not changed"); + GetExecutedStatement("UPDATE ").Should().StartWith($"UPDATE `{_REDIRECT_TABLE}` AS `t` INNER JOIN `{tempTable.Name}` AS `t0` ON "); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_update_entities_in_table_of_the_database_from_ForRootEntity_provider() + { + // MySQL has no schemas below the database, so the schema of the table name is the name of the database (here: the current one). + var database = await ActDbContext.Database.SqlQueryRaw("SELECT DATABASE() AS `Value`").SingleAsync(); + await CreateRedirectTableAsync(); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE, database) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("UpdatedName"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_update_entities_in_table_from_table_name_provider() + { + await CreateRedirectTableAsync(); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName" }); + var provider = new SuffixTableNameProvider("_QueryUpdateRedirect"); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = provider }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("UpdatedName"); + provider.RequestedEntityTypes.Should().ContainSingle().Which.ClrType.Should().Be(typeof(TestEntity)); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_escape_table_name_of_table_name_provider() + { + const string tableName = "TestEntities`; DROP TABLE TestEntities; -- Redirect"; + await CreateRedirectTableAsync(tableName); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(tableName) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(tableName, "Name")).Should().Equal("UpdatedName"); + } + finally + { + await DropTablesAsync(tableName); + } + } + + [Fact(Skip = "Known limitation: the schema of a table name is dropped by DelimitIdentifier of the MySQL SQL generation helper (Pomelo SchemaBehavior), so the statement runs against the current database without an error.")] + public async Task Should_not_write_to_the_current_database_if_the_schema_of_the_table_name_names_another_database() + { + await CreateRedirectTableAsync(); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Name = "UpdatedName" }); + + // The schema of a MySQL table name is the name of the database. The statement has to address that database (or fail), + // it must not silently update the table of the same name in the current database. + var act = () => ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE, "other_database") }); + + await act.Should().ThrowAsync(); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("Redirected"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact(Skip = "Known defect of the shared EfCoreValueExpressionTranslator.ApplyTableName (all providers): the table name of the model is replaced everywhere in the generated SQL, including the source query and string literals, instead of the target table only.")] + public async Task Should_read_the_source_rows_from_the_table_of_the_model_if_the_target_table_is_overridden() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Live", RequiredName = "RequiredName", Count = 5 }); + await ArrangeDbContext.SaveChangesAsync(); + await CreateTableCopyAsync("TestEntities", _REDIRECT_TABLE); + + try + { + await ActDbContext.Database.ExecuteSqlRawAsync($"INSERT INTO {Quote(_REDIRECT_TABLE)} SELECT * FROM `TestEntities`"); + await ActDbContext.Database.ExecuteSqlRawAsync($"UPDATE {Quote(_REDIRECT_TABLE)} SET `Name` = 'Archive', `Count` = 0"); + + // Source: the table of the model. Target: its copy. + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + ActDbContext.TestEntities, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("Live"); + (await LoadColumnAsync(_REDIRECT_TABLE, "Count")).Should().Equal(5); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("Live"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact(Skip = "Known defect of the shared EfCoreValueExpressionTranslator.ApplyTableName (all providers): the table name of the model is replaced everywhere in the generated SQL, including the source query and string literals, instead of the target table only.")] + public async Task Should_not_change_string_values_that_contain_the_name_of_the_table_of_the_model_if_the_target_table_is_overridden() + { + await CreateRedirectTableAsync(); + + try + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => "`TestEntities`"), + options: new MySqlBulkUpdateFromQueryOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_REDIRECT_TABLE) }); + + affectedRows.Should().Be(1); + (await LoadColumnAsync(_REDIRECT_TABLE, "Name")).Should().Equal("`TestEntities`"); + } + finally + { + await DropTablesAsync(_REDIRECT_TABLE); + } + } + + [Fact] + public async Task Should_throw_if_no_set_entries() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var act = () => ActDbContext.TestEntities.BulkUpdateAsync(tempTable.Query, e => e.Id, f => f.Id, builder => builder); + + await act.Should().ThrowAsync().WithMessage("*at least one*"); + } + + [Fact] + public async Task Should_throw_if_key_count_mismatch() + { + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var act = () => ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => new TestKey { Id = e.Id, Name = e.Name }, + f => new TestKey { Id = f.Id }, + builder => builder.Set(e => e.Name, (e, f) => f.Name)); + + await act.Should().ThrowAsync().WithMessage("*number of target key*"); + } + + [Fact] + public async Task Should_throw_if_target_property_is_not_mapped() + { + var act = () => ActDbContext.TestEntities.BulkUpdateAsync( + ActDbContext.TestEntities, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Parent, (e, f) => f.Parent)); + + await act.Should().ThrowAsync().WithMessage("*'Parent' is not a mapped property*"); + } + + [Fact] + public async Task Should_throw_if_target_selector_is_not_a_simple_property_access() + { + var act = () => ActDbContext.TestEntities.BulkUpdateAsync( + ActDbContext.TestEntities, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name!.Length, (e, f) => f.Count)); + + await act.Should().ThrowAsync().WithMessage("*Only simple property access*"); + } + + [Fact] + public async Task Should_throw_if_value_expression_cannot_be_translated() + { + var act = () => ActDbContext.TestEntities.BulkUpdateAsync( + ActDbContext.TestEntities, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => NotTranslatable(f.Name))); + + await act.Should().ThrowAsync().WithMessage("*Could not translate value expression*"); + } + + [Fact] + public async Task Should_update_only_filtered_target_rows() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated1", RequiredName = "RequiredName", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Updated2", RequiredName = "RequiredName", Count = 0 }); + + // Only update target rows where Count > 0 + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => e.Count > 0); + + affectedRows.Should().Be(1); + await AssertNamesAsync("Updated1", "Original2"); + } + + [Fact] + public async Task Should_return_0_when_filter_excludes_all_rows() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated", RequiredName = "RequiredName", Count = 0 }); + + // Filter excludes all rows (Count > 100, but actual is 0) + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => e.Count > 100); + + affectedRows.Should().Be(0); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("Original"); + } + + [Fact] + public async Task Should_handle_parameterized_filter() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName", Count = 3 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated1", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Updated2", RequiredName = "RequiredName", Count = 3 }); + var threshold = 5; + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => e.Count > threshold); + + affectedRows.Should().Be(1); + await AssertNamesAsync("Updated1", "Original2"); + } + + [Fact] + public async Task Should_generate_correct_sql_with_filter() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 1 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated", RequiredName = "RequiredName", Count = 1 }); + + await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => e.Count > 0); + + // The filter is a WHERE clause after the SET clause, the keys stay in the join condition. + GetExecutedStatement("UPDATE ").Should().EndWith("ON `t`.`Id` = `t0`.`Id` SET `t`.`Name` = `t0`.`Name` WHERE `t`.`Count` > 0"); + } + + [Fact] + public async Task Should_combine_filter_with_composite_key_join() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Name2", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name1", RequiredName = "UpdatedReq1", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Name2", RequiredName = "UpdatedReq2", Count = 0 }); + + // Composite key join + filter: only the first entity matches the filter (Count > 0) + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => new { e.Id, e.Name }, + f => new { f.Id, f.Name }, + builder => builder.Set(e => e.RequiredName, (e, f) => f.RequiredName), + filter: (e, f) => e.Count > 0); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Name).ToListAsync(); + loadedEntities.Select(e => e.RequiredName).Should().Equal("UpdatedReq1", "RequiredName"); + } + + [Fact] + public async Task Should_filter_using_source_property() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName", Count = 0 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated1", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Updated2", RequiredName = "RequiredName", Count = 0 }); + + // Only update rows where the source Count > 0 + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => f.Count > 0); + + affectedRows.Should().Be(1); + await AssertNamesAsync("Updated1", "Original2"); + } + + [Fact] + public async Task Should_filter_using_both_target_and_source_properties() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName", Count = 5 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName", Count = 20 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated1", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Updated2", RequiredName = "RequiredName", Count = 10 }); + + // Only update where target Count < source Count (first: 5 < 10 = true, second: 20 < 10 = false) + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => e.Count < f.Count); + + affectedRows.Should().Be(1); + await AssertNamesAsync("Updated1", "Original2"); + GetExecutedStatement("UPDATE ").Should().EndWith("WHERE `t`.`Count` < `t0`.`Count`"); + } + + [Fact] + public async Task Should_filter_with_arithmetic_on_both() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName", Count = 3 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated1", RequiredName = "RequiredName", Count = 6 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Updated2", RequiredName = "RequiredName", Count = 5 }); + + // Only update where target + source Count > 15 (first: 10 + 6 = 16 > 15 = true, second: 3 + 5 = 8 > 15 = false) + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => e.Count + f.Count > 15); + + affectedRows.Should().Be(1); + await AssertNamesAsync("Updated1", "Original2"); + } + + [Fact] + public async Task Should_filter_with_string_contains() + { + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName", Count = 0 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "KeepThis", RequiredName = "RequiredName", Count = 10 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "SkipThis", RequiredName = "RequiredName", Count = 20 }); + + // Only update rows where source Name contains "Keep" + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => f.Count), + filter: (e, f) => f.Name!.Contains("Keep")); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TestEntities.OrderBy(e => e.Name).ToListAsync(); + loadedEntities.Select(e => e.Count).Should().Equal(10, 0); + } + + [Fact] + public async Task Should_update_with_constant_value() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => 42)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(42); + } + + [Fact] + public async Task Should_update_with_null() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", NullableCount = 5 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => null) + .Set(e => e.NullableCount, (e, f) => f.NullableCount)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().BeNull(); + loadedEntity.NullableCount.Should().BeNull(); + } + + [Fact] + public async Task Should_update_with_captured_variable() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + var newName = "CapturedValue"; + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => newName)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("CapturedValue"); + } + + [Fact] + public async Task Should_update_with_captured_string_with_special_characters_as_is() + { + await ArrangeQueryRowsAsync(); + var newName = "O'Reilly \\ \"quoted\" `tick`; DROP TABLE QueryTargets; -- \\'"; + + var affectedRows = await ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources, + t => t.Id, + s => s.Id, + builder => builder.Set(t => t.Name, (t, s) => newName)); + + affectedRows.Should().Be(1); + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be(newName); + } + + [Fact] + public async Task Should_update_with_captured_string_containing_curly_braces_as_is() + { + await ArrangeQueryRowsAsync(); + var newName = """{"key": "value"} {0} {1} {{}}"""; + + var affectedRows = await ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources, + t => t.Id, + s => s.Id, + builder => builder.Set(t => t.Name, (t, s) => newName)); + + affectedRows.Should().Be(1); + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be(newName); + } + + [Fact] + public async Task Should_update_with_captured_string_that_looks_like_a_format_placeholder_as_is_if_the_source_query_has_parameters() + { + await ArrangeQueryRowsAsync(); + var minCount = 1; + + var affectedRows = await ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources.Where(s => s.Count >= minCount), + t => t.Id, + s => s.Id, + builder => builder.Set(t => t.Name, (t, s) => "{0}")); + + affectedRows.Should().Be(1); + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be("{0}"); + } + + [Fact] + public async Task Should_update_with_static_member_value() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => StaticName)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("StaticValue"); + } + + [Fact] + public async Task Should_update_with_static_member_of_converted_type() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(1) }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.ConvertibleClass, (e, f) => _staticConvertibleClass)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.ConvertibleClass).SingleAsync()).Should().Be(new ConvertibleClass(42)); + } + + [Fact] + public async Task Should_update_with_captured_variable_of_converted_type() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(1) }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + var newValue = new ConvertibleClass(43); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.ConvertibleClass, (e, f) => newValue)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.ConvertibleClass).SingleAsync()).Should().Be(new ConvertibleClass(43)); + } + + [Fact] + public async Task Should_update_with_captured_values_of_different_types() + { + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = _TEST_ENTITY_ID, Amount = 1m, CreatedAt = new DateTime(2000, 1, 1), Status = MySqlTestStatus.Draft, Flag = false }, + new MySqlTypedEntity { Id = _OTHER_TEST_ENTITY_ID, Amount = 2m, CreatedAt = new DateTime(2000, 1, 1), Status = MySqlTestStatus.Draft, Flag = true }); + await ArrangeDbContext.SaveChangesAsync(); + var amount = 1234.5678m; + var payload = "[1, 2, 3]"; + var createdAt = new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Unspecified); + var data = new byte[] { 1, 2, 3 }; + + // Only the row with Flag = true is part of the source. + var affectedRows = await ActDbContext.TypedEntities.BulkUpdateAsync( + ActDbContext.TypedEntities.Where(e => e.Flag), + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Amount, (e, f) => amount) + .Set(e => e.Payload, (e, f) => payload) + .Set(e => e.CreatedAt, (e, f) => createdAt) + .Set(e => e.Status, (e, f) => MySqlTestStatus.Published) + .Set(e => e.Data, (e, f) => data) + .Set(e => e.Flag, (e, f) => false)); + + affectedRows.Should().Be(1); + + var loadedEntities = await AssertDbContext.TypedEntities.OrderBy(e => e.Amount).ToListAsync(); + loadedEntities.Should().HaveCount(2); + loadedEntities[0].Id.Should().Be(_TEST_ENTITY_ID); + loadedEntities[0].Amount.Should().Be(1m); + loadedEntities[0].Status.Should().Be(MySqlTestStatus.Draft); + loadedEntities[1].Id.Should().Be(_OTHER_TEST_ENTITY_ID); + loadedEntities[1].Amount.Should().Be(amount); + loadedEntities[1].Payload.Should().Be(payload); + loadedEntities[1].CreatedAt.Should().Be(createdAt); + loadedEntities[1].Status.Should().Be(MySqlTestStatus.Published); + loadedEntities[1].Data.Should().Equal(data); + loadedEntities[1].Flag.Should().BeFalse(); + } + + [Fact(Skip = "Known defect of the shared EfCoreValueExpressionTranslator (all providers): a DateTime constant is inlined as a SQL literal without fractional seconds, the microseconds are lost. The insert path binds a parameter and keeps them.")] + public async Task Should_update_with_captured_datetime_with_fractional_seconds() + { + ArrangeDbContext.TypedEntities.Add(new MySqlTypedEntity { Id = _TEST_ENTITY_ID, Amount = 1m, CreatedAt = new DateTime(2000, 1, 1), Status = MySqlTestStatus.Draft }); + await ArrangeDbContext.SaveChangesAsync(); + var createdAt = new DateTime(2024, 5, 6, 7, 8, 9, DateTimeKind.Unspecified).AddTicks(1234560); // the column is datetime(6): 123456 microseconds + + var affectedRows = await ActDbContext.TypedEntities.BulkUpdateAsync( + ActDbContext.TypedEntities, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.CreatedAt, (e, f) => createdAt)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TypedEntities.Select(e => e.CreatedAt).SingleAsync()).Should().Be(createdAt); + } + + [Fact] + public async Task Should_translate_datetime_now_server_side_instead_of_evaluating_it() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => DateTime.Now.Year)); + + affectedRows.Should().Be(1); + + // DateTime.Now must not be evaluated to a constant at translation time but be translated server-side. + GetExecutedStatement("UPDATE ").Should().ContainAny("CURRENT_TIMESTAMP", "NOW("); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(DateTime.Now.Year); + } + + [Fact] + public async Task Should_translate_datetime_utcnow_server_side_instead_of_evaluating_it() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => DateTime.UtcNow.Year)); + + affectedRows.Should().Be(1); + + GetExecutedStatement("UPDATE ").Should().Contain("UTC_TIMESTAMP"); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(DateTime.UtcNow.Year); + } + + [Fact] + public async Task Should_update_with_source_arithmetic_expression() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 5 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 7 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => f.Count + 10)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(17); + } + + [Fact] + public async Task Should_update_with_mixed_target_and_source_expression() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 5 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 3 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => e.Count + f.Count)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(8); + } + + [Fact] + public async Task Should_update_with_multiplication_and_constant() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 5 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", Count = 4 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => f.Count * 3)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(12); + } + + [Fact] + public async Task Should_update_with_multiple_complex_and_simple_sets() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 10 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Updated", RequiredName = "RequiredName", Count = 5 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => e.Count + f.Count * 2)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().Be("Updated"); + loadedEntity.Count.Should().Be(20); + } + + [Fact] + public async Task Should_update_from_source_temp_table_with_different_column_names() + { + ConfigureModel = ConfigureTempSourceWithMappedColumns; + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TempSourceWithMappedColumns { Id = _TEST_ENTITY_ID, Name = "Updated", Count = 42 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count)); + + affectedRows.Should().Be(1); + + var loadedEntity = await AssertDbContext.TestEntities.SingleAsync(); + loadedEntity.Name.Should().Be("Updated"); + loadedEntity.Count.Should().Be(42); + } + + [Fact] + public async Task Should_generate_correct_sql_with_source_temp_table_having_different_column_names() + { + ConfigureModel = ConfigureTempSourceWithMappedColumns; + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TempSourceWithMappedColumns { Id = _TEST_ENTITY_ID, Name = "Updated", Count = 42 }); + + await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder + .Set(e => e.Name, (e, f) => f.Name) + .Set(e => e.Count, (e, f) => f.Count)); + + // Source columns use the mapped names (the alias of the temp table entity is derived from its name, so it is not part of the expectation), + // target columns use the names of the target (which are not mapped differently). + var statement = GetExecutedStatement("UPDATE "); + statement.Should().MatchRegex(@"ON `t`\.`Id` = `[^`]+`\.`entity_id` SET `t`\.`Name` = `[^`]+`\.`display_name`, `t`\.`Count` = `[^`]+`\.`item_count`$"); + } + + [Fact] + public async Task Should_update_from_source_temp_table_with_different_column_names_using_arithmetic_expression() + { + ConfigureModel = ConfigureTempSourceWithMappedColumns; + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 5 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TempSourceWithMappedColumns { Id = _TEST_ENTITY_ID, Name = "Updated", Count = 3 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => e.Count + f.Count)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(8); + } + + [Fact] + public async Task Should_update_from_source_temp_table_with_different_column_names_and_filter() + { + ConfigureModel = ConfigureTempSourceWithMappedColumns; + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original1", RequiredName = "RequiredName", Count = 5 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, Name = "Original2", RequiredName = "RequiredName", Count = 20 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TempSourceWithMappedColumns { Id = _TEST_ENTITY_ID, Name = "Updated1", Count = 10 }, + new TempSourceWithMappedColumns { Id = _OTHER_TEST_ENTITY_ID, Name = "Updated2", Count = 10 }); + + // Only update where target Count < source Count (first: 5 < 10 = true, second: 20 < 10 = false) + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name), + filter: (e, f) => e.Count < f.Count); + + affectedRows.Should().Be(1); + await AssertNamesAsync("Updated1", "Original2"); + GetExecutedStatement("UPDATE ").Should().MatchRegex(@"WHERE `t`\.`Count` < `[^`]+`\.`item_count`$"); + } + + [Fact] + public async Task Should_update_from_source_temp_table_with_different_column_names_and_constant_value() + { + ConfigureModel = ConfigureTempSourceWithMappedColumns; + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TempSourceWithMappedColumns { Id = _TEST_ENTITY_ID }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Count, (e, f) => 42)); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Count).SingleAsync()).Should().Be(42); + } + + [Fact] + public async Task Should_update_with_string_to_upper() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "updated", RequiredName = "RequiredName", Count = 0 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => f.Name!.ToUpper())); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("UPDATED"); + } + + [Fact] + public async Task Should_update_with_string_concat_and_to_lower() + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await CreateSourceAsync(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Hello", RequiredName = "World", Count = 0 }); + + var affectedRows = await ActDbContext.TestEntities.BulkUpdateAsync( + tempTable.Query, + e => e.Id, + f => f.Id, + builder => builder.Set(e => e.Name, (e, f) => (f.Name + "_" + f.RequiredName).ToLower())); + + affectedRows.Should().Be(1); + (await AssertDbContext.TestEntities.Select(e => e.Name).SingleAsync()).Should().Be("hello_world"); + } + + [Fact] + public async Task Should_not_change_any_row_if_the_statement_fails() + { + ArrangeDbContext.Entities.AddRange(new MySqlTestEntity { Id = 1, Name = "A" }, + new MySqlTestEntity { Id = 2, Name = "B" }, + new MySqlTestEntity { Id = 3, Name = "C" }); + ArrangeDbContext.QuerySources.AddRange(new MySqlQuerySource { Id = 1, Name = "Changed" }, + new MySqlQuerySource { Id = 2, Name = "C" }); + await ArrangeDbContext.SaveChangesAsync(); + + // 1062: the second row would get the name of the third row, which violates the unique index of MySqlTestEntity.Name + var exception = await ActDbContext.Entities + .Awaiting(set => set.BulkUpdateAsync(ActDbContext.QuerySources, + e => e.Id, + s => s.Id, + builder => builder.Set(e => e.Name, (e, s) => s.Name))) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1062); + + (await AssertDbContext.Entities.OrderBy(e => e.Id).Select(e => e.Name).ToListAsync()).Should().Equal("A", "B", "C"); + } + + [Fact] + public async Task Should_throw_when_cancellation_is_requested() + { + await ArrangeQueryRowsAsync(); + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + var act = () => ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources, + t => t.Id, + s => s.Id, + builder => builder.Set(t => t.Name, (t, s) => s.Name), + cancellationToken: cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be("Target"); + } + + [Fact] + public async Task Should_not_end_the_transaction_of_the_caller_and_commit_rows_with_it() + { + await ArrangeQueryRowsAsync(); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await UpdateQueryTargetsAsync(); + + affectedRows.Should().Be(1); + (await AssertDbContext.QueryTargets.AsNoTracking().Select(t => t.Name).SingleAsync()).Should().Be("Target", "the transaction of the caller is not committed yet"); + + await transaction.CommitAsync(); + + (await AssertDbContext.QueryTargets.AsNoTracking().Select(t => t.Name).SingleAsync()).Should().Be("Source"); + } + + [Fact] + public async Task Should_not_update_rows_if_the_transaction_of_the_caller_is_rolled_back() + { + await ArrangeQueryRowsAsync(); + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await UpdateQueryTargetsAsync(); + + affectedRows.Should().Be(1); + + await transaction.RollbackAsync(); + + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be("Target"); + } + + [Fact] + public async Task Should_update_rows_in_the_nested_transaction_of_the_caller() + { + await ArrangeQueryRowsAsync(); + await using var rootTransaction = await ActDbContext.Database.BeginTransactionAsync(); + await using var nestedTransaction = await ActDbContext.Database.BeginTransactionAsync(); + + var affectedRows = await UpdateQueryTargetsAsync(); + + affectedRows.Should().Be(1); + + // A nested transaction is logical only; the root transaction decides about the rows. + await nestedTransaction.CommitAsync(); + (await AssertDbContext.QueryTargets.AsNoTracking().Select(t => t.Name).SingleAsync()).Should().Be("Target"); + + await rootTransaction.RollbackAsync(); + (await AssertDbContext.QueryTargets.AsNoTracking().Select(t => t.Name).SingleAsync()).Should().Be("Target"); + } + + [Fact] + public async Task Should_update_and_commit_inside_a_transaction_scope() + { + await ArrangeQueryRowsAsync(); + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var affectedRows = await UpdateQueryTargetsAsync(); + + affectedRows.Should().Be(1); + scope.Complete(); + } + + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be("Source"); + } + + [Fact] + public async Task Should_roll_back_the_update_if_the_transaction_scope_is_not_completed() + { + await ArrangeQueryRowsAsync(); + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + var affectedRows = await UpdateQueryTargetsAsync(); + + affectedRows.Should().Be(1); + } + + (await AssertDbContext.QueryTargets.Select(t => t.Name).SingleAsync()).Should().Be("Target"); + } + + // Target: { Id = 1, Name = "Target", Count = 1 }, source: { Id = 1, Name = "Source", Count = 2 } + private async Task ArrangeQueryRowsAsync() + { + ArrangeDbContext.QueryTargets.Add(new MySqlQueryTarget { Id = 1, Name = "Target", Count = 1 }); + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source", Count = 2 }); + await ArrangeDbContext.SaveChangesAsync(); + } + + private Task UpdateQueryTargetsAsync() + { + return ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources, + t => t.Id, + s => s.Id, + builder => builder + .Set(t => t.Name, (t, s) => s.Name) + .Set(t => t.Count, (t, s) => s.Count)); + } + + private async Task AssertNamesAsync(string expectedNameOfFirstEntity, string expectedNameOfSecondEntity) + { + var names = await AssertDbContext.TestEntities.ToDictionaryAsync(e => e.Id, e => e.Name); + + names.Should().HaveCount(2); + names[_TEST_ENTITY_ID].Should().Be(expectedNameOfFirstEntity); + names[_OTHER_TEST_ENTITY_ID].Should().Be(expectedNameOfSecondEntity); + } + + private static string NotTranslatable(string? value) + { + return value ?? String.Empty; + } + + private static void ConfigureTempSourceWithMappedColumns(ModelBuilder builder) + { + builder.ConfigureTempTableEntity(false, b => + { + b.HasKey(e => e.Id); + b.Property(e => e.Id).HasColumnName("entity_id"); + b.Property(e => e.Name).HasColumnName("display_name"); + b.Property(e => e.Count).HasColumnName("item_count"); + }); + } + + private async Task> CreateSourceAsync(params T[] entities) + where T : class + { + return await SUT.BulkInsertIntoTempTableAsync(entities, new MySqlTempTableBulkInsertOptions()); + } + + // Line breaks of the generated SQL depend on the platform, so whitespace is normalized. + private string GetExecutedStatement(string prefix) + { + return Regex.Replace(ExecutedCommands.Last(command => command.StartsWith(prefix, StringComparison.Ordinal)), @"\s+", " "); + } + + // Creates a copy of the table "TestEntities" that contains a row with the name "Redirected" and, as a decoy, adds a row with the name "Original" + // to the table of the model. Both rows have the id of _TEST_ENTITY_ID. + private async Task CreateRedirectTableAsync(string tableName = _REDIRECT_TABLE) + { + ArrangeDbContext.TestEntities.Add(new TestEntity { Id = _TEST_ENTITY_ID, Name = "Original", RequiredName = "RequiredName", Count = 0 }); + await ArrangeDbContext.SaveChangesAsync(); + + await CreateTableCopyAsync("TestEntities", tableName); + await ActDbContext.Database.ExecuteSqlRawAsync($"INSERT INTO {Quote(tableName)} SELECT * FROM `TestEntities`"); + await ActDbContext.Database.ExecuteSqlRawAsync($"UPDATE {Quote(tableName)} SET `Name` = 'Redirected'"); + } + + private async Task> LoadColumnAsync(string tableName, string columnName) + { + return await AssertDbContext.Database.SqlQueryRaw($"SELECT {Quote(columnName)} AS `Value` FROM {Quote(tableName)} ORDER BY {Quote(columnName)}").ToListAsync(); + } + + // LIKE copies the columns, the indexes and the primary key, but no foreign keys. + private async Task CreateTableCopyAsync(string sourceTableName, string targetTableName) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"CREATE TABLE {Quote(targetTableName)} LIKE {Quote(sourceTableName)}"); + } + + private async Task DropTablesAsync(params string[] tableNames) + { + foreach (var tableName in tableNames) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"DROP TABLE IF EXISTS {Quote(tableName)}"); + } + } + + private static string Quote(string identifier) + { + return $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`"; + } + + private sealed class TestKey + { + public Guid Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkCopyTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkCopyTests.cs new file mode 100644 index 0000000..aa6ad59 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkCopyTests.cs @@ -0,0 +1,64 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlBulkCopyTests : MySqlIntegrationTestsBase +{ + public MySqlBulkCopyTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_null_values() + { + await using var context = await CreateBulkCopyContextAsync(); + + var affectedRows = await context.GetService().BulkInsertAsync( + [new MySqlTestEntity { Id = 1, Name = null }], + new MySqlBulkInsertOptions { UseBulkCopy = true }); + + affectedRows.Should().Be(1); + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().BeNull(); + } + + [Fact] + public async Task Should_fail_instead_of_silently_dropping_rows_with_duplicate_keys() + { + await using var context = await CreateBulkCopyContextAsync(); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "Existing" }]); + + var act = () => context.GetService().BulkInsertAsync( + [new MySqlTestEntity { Id = 2, Name = "New" }, new MySqlTestEntity { Id = 3, Name = "Existing" }], + new MySqlBulkInsertOptions { UseBulkCopy = true }); + + // MySqlConnector compares the copied and inserted row counts itself; the warnings check covers the remaining silent cases. + await act.Should().ThrowAsync().WithMessage("*only 1 was inserted*"); + (await context.Entities.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Should_insert_typed_values() + { + await using var context = await CreateBulkCopyContextAsync(); + var id = Guid.NewGuid(); + + await context.GetService().BulkInsertAsync( + [new MySqlTypedEntity { Id = id, Amount = 12.5m, Status = MySqlTestStatus.Published, CreatedAt = new DateTime(2020, 1, 2, 3, 4, 5), Data = [1, 2], Flag = true }], + new MySqlBulkInsertOptions { UseBulkCopy = true }); + + var loaded = await context.TypedEntities.AsNoTracking().SingleAsync(); + loaded.Id.Should().Be(id); + loaded.Status.Should().Be(MySqlTestStatus.Published); + loaded.Data.Should().Equal(1, 2); + } + + private async Task CreateBulkCopyContextAsync() + { + var context = await CreateContextAsync(); + var builder = new MySqlConnector.MySqlConnectionStringBuilder(ConnectionString) { AllowLoadLocalInfile = true }; + context.Database.SetConnectionString(builder.ConnectionString); + return context; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertOrUpdateTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertOrUpdateTests.cs new file mode 100644 index 0000000..b86054e --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertOrUpdateTests.cs @@ -0,0 +1,132 @@ +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlBulkInsertOrUpdateTests : MySqlIntegrationTestsBase +{ + public MySqlBulkInsertOrUpdateTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_when_key_does_not_exist() + { + await using var context = await CreateContextAsync(); + + var affectedRows = await context.BulkInsertOrUpdateAsync( + [new MySqlTestEntity { Id = 301, Name = "Inserted" }], + propertiesToInsert: entity => new { entity.Id, entity.Name }, + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + affectedRows.Should().Be(1); + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("Inserted"); + } + + [Fact] + public async Task Should_update_when_primary_key_conflicts() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 302, Name = "Before" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkInsertOrUpdateAsync( + [new MySqlTestEntity { Id = 302, Name = "After" }], + propertiesToInsert: entity => new { entity.Id, entity.Name }, + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("After"); + } + + [Fact] + public async Task Should_leave_unselected_properties_unchanged_on_conflict() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 303, Name = "Before" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkInsertOrUpdateAsync( + [new MySqlTestEntity { Id = 303, Name = "After" }], + propertiesToInsert: entity => new { entity.Id, entity.Name }, + propertiesToUpdate: entity => entity.Id, + propertiesToMatchOn: entity => entity.Id); + + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("Before"); + } + + [Fact] + public async Task Should_upsert_using_a_composite_primary_key() + { + await using var context = await CreateContextAsync(); + context.CompositeEntities.Add(new MySqlCompositeEntity { RegionId = 8, ItemId = 12, Name = "Before" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkInsertOrUpdateAsync( + [new MySqlCompositeEntity { RegionId = 8, ItemId = 12, Name = "After" }], + propertiesToInsert: entity => new { entity.RegionId, entity.ItemId, entity.Name }, + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => new { entity.RegionId, entity.ItemId }); + + (await context.CompositeEntities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("After"); + } + + [Fact] + public async Task Should_not_modify_other_row_when_unique_index_conflicts() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 1, Name = "a" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkInsertOrUpdateAsync( + [new MySqlTestEntity { Id = 9, Name = "a" }], + propertiesToInsert: entity => new { entity.Id, entity.Name }, + propertiesToUpdate: entity => new { entity.Id, entity.Name }, + propertiesToMatchOn: entity => entity.Id); + + var rows = await context.Entities.AsNoTracking().ToListAsync(); + rows.Should().ContainSingle(); + rows[0].Id.Should().Be(1); + rows[0].Name.Should().Be("a"); + } + + [Fact] + public async Task Should_upsert_mixed_batch_with_unique_index_conflict() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 1, Name = "a" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkInsertOrUpdateAsync( + [ + new MySqlTestEntity { Id = 9, Name = "a" }, + new MySqlTestEntity { Id = 10, Name = "new" }, + new MySqlTestEntity { Id = 1, Name = "a3" } + ], + propertiesToInsert: entity => new { entity.Id, entity.Name }, + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + var rows = await context.Entities.AsNoTracking().OrderBy(entity => entity.Id).Select(entity => new { entity.Id, entity.Name }).ToListAsync(); + rows.Should().BeEquivalentTo(new[] { new { Id = 1, Name = "a3" }, new { Id = 10, Name = "new" } }, options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Should_return_zero_for_empty_input() + { + await using var context = await CreateContextAsync(); + + var affectedRows = await context.BulkInsertOrUpdateAsync( + Array.Empty(), + propertiesToInsert: entity => new { entity.Id, entity.Name }, + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + affectedRows.Should().Be(0); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertTests.cs new file mode 100644 index 0000000..3b47c0f --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkInsertTests.cs @@ -0,0 +1,80 @@ +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlBulkInsertTests : MySqlIntegrationTestsBase +{ + public MySqlBulkInsertTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_selected_columns() + { + await using var context = await CreateContextAsync(); + + var affectedRows = await context.BulkInsertAsync( + [new MySqlTestEntity { Id = 101, Name = "First" }, new MySqlTestEntity { Id = 102, Name = "Second" }], + entity => new { entity.Id, entity.Name }); + + affectedRows.Should().Be(2); + (await context.Entities.AsNoTracking().OrderBy(entity => entity.Id).Select(entity => entity.Name).ToArrayAsync()) + .Should().Equal("First", "Second"); + } + + [Fact] + public async Task Should_return_zero_for_empty_input() + { + await using var context = await CreateContextAsync(); + + var affectedRows = await context.BulkInsertAsync(Array.Empty()); + + affectedRows.Should().Be(0); + } + + [Fact] + public async Task Should_throw_when_unique_index_is_duplicated() + { + var act = async () => + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 151, Name = "Unique" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkInsertAsync([new MySqlTestEntity { Id = 152, Name = "Unique" }], entity => new { entity.Id, entity.Name }); + }; + + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1062); + } + + [Fact] + public async Task Should_insert_in_multiple_batches() + { + await using var context = await CreateContextAsync(); + var entities = Enumerable.Range(1, 501) + .Select(id => new MySqlTestEntity { Id = id, Name = $"Row-{id}" }) + .ToArray(); + + var affectedRows = await context.BulkInsertAsync(entities, entity => new { entity.Id, entity.Name }); + + affectedRows.Should().Be(501); + (await context.Entities.CountAsync()).Should().Be(501); + } + + [Fact] + public async Task Should_throw_when_primary_key_is_duplicated() + { + var act = async () => + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 150, Name = "Existing" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkInsertAsync([new MySqlTestEntity { Id = 150, Name = "Duplicate" }], entity => new { entity.Id, entity.Name }); + }; + + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1062); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationBehaviorTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationBehaviorTests.cs new file mode 100644 index 0000000..61b6286 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationBehaviorTests.cs @@ -0,0 +1,243 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Thinktecture.EntityFrameworkCore.TempTables; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlBulkOperationBehaviorTests : MySqlIntegrationTestsBase +{ + public MySqlBulkOperationBehaviorTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_round_trip_typed_values_through_bulk_insert() + { + await using var context = await CreateContextAsync(); + var id = Guid.NewGuid(); + var createdAt = new DateTime(2020, 1, 2, 3, 4, 5, 123, DateTimeKind.Unspecified).AddTicks(4560); + + await context.BulkInsertAsync([ + new MySqlTypedEntity + { + Id = id, + Amount = 1234.5678m, + Payload = "{\"a\":1}", + CreatedAt = createdAt, + Status = MySqlTestStatus.Published, + Data = [1, 2, 3], + Flag = true + } + ]); + + context.ChangeTracker.Clear(); + var loaded = await context.TypedEntities.AsNoTracking().SingleAsync(); + loaded.Id.Should().Be(id); + loaded.Amount.Should().Be(1234.5678m); + loaded.Payload.Should().Contain("\"a\""); + loaded.CreatedAt.Should().Be(createdAt); + loaded.Status.Should().Be(MySqlTestStatus.Published); + loaded.Data.Should().Equal(1, 2, 3); + loaded.Flag.Should().BeTrue(); + } + + [Fact] + public async Task Should_insert_with_mysql_bulk_copy_when_enabled() + { + await using var context = await CreateContextAsync(); + var connectionString = new MySqlConnector.MySqlConnectionStringBuilder(context.Database.GetConnectionString() + ?? throw new InvalidOperationException("The MySQL connection string is not configured.")) + { + AllowLoadLocalInfile = true + }; + context.Database.SetConnectionString(connectionString.ConnectionString); + await context.Database.OpenConnectionAsync(); + + try + { + var affectedRows = await context.GetService().BulkInsertAsync( + [new MySqlTestEntity { Id = 901, Name = "BulkCopy-A" }, new MySqlTestEntity { Id = 902, Name = "BulkCopy-B" }], + new MySqlBulkInsertOptions { UseBulkCopy = true }); + + affectedRows.Should().Be(2); + (await context.Entities.AsNoTracking().OrderBy(entity => entity.Id).Select(entity => entity.Name).ToArrayAsync()) + .Should().Equal("BulkCopy-A", "BulkCopy-B"); + } + finally + { + await context.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_not_leave_partial_rows_when_a_later_batch_of_an_insert_fails() + { + await using var context = await CreateContextAsync(); + var executor = context.GetService(); + var options = new MySqlBulkInsertOptions { BatchSize = 1 }; + + var act = () => executor.BulkInsertAsync( + [ + new MySqlTestEntity { Id = 1, Name = "A" }, + new MySqlTestEntity { Id = 2, Name = "B" }, + new MySqlTestEntity { Id = 3, Name = new string('x', 300) } + ], + options); + + // 1406: data too long for the column (strict mode) + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1406); + (await context.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_leave_partial_rows_when_a_later_batch_of_an_upsert_fails() + { + await using var context = await CreateContextAsync(); + var executor = context.GetService(); + var options = new MySqlBulkInsertOrUpdateOptions { BatchSize = 1 }; + + var act = () => executor.BulkInsertOrUpdateAsync( + [ + new MySqlTestEntity { Id = 1, Name = "A" }, + new MySqlTestEntity { Id = 2, Name = "B" }, + new MySqlTestEntity { Id = 3, Name = new string('x', 300) } + ], + options); + + // 1406: data too long for the column (strict mode) + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1406); + (await context.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_modify_the_callers_options_when_inserting_into_an_existing_temp_table() + { + await using var context = await CreateContextAsync(); + var executor = context.GetService(); + var options = new MySqlBulkInsertOptions(); + var tempTable = await context.CreateTempTableAsync(new TempTableCreationOptions()); + + try + { + await executor.BulkInsertIntoTempTableAsync([new MySqlTestTempEntity { Id = 1, Name = "Temp" }], tempTable, options, CancellationToken.None); + options.TableNameProvider.Should().BeSameAs(IBulkOperationTableNameProvider.Default); + + await executor.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "Regular" }], options); + (await context.Entities.CountAsync()).Should().Be(1); + } + finally + { + await tempTable.DisposeAsync(); + } + } + + [Fact] + public async Task Should_insert_regular_entities_into_a_temp_table() + { + await using var context = await CreateContextAsync(); + + await using var tempTable = await context.BulkInsertIntoTempTableAsync( + [new MySqlTestEntity { Id = 1, Name = "Regular" }], + context.GetService().CreateOptions()); + + (await tempTable.Query.Select(entity => entity.Name).SingleAsync()).Should().Be("Regular"); + } + + [Fact] + public async Task Should_update_many_rows_with_a_staging_table_in_multiple_batches() + { + await using var context = await CreateContextAsync(); + var originals = Enumerable.Range(1, 1000).Select(id => new MySqlTestEntity { Id = id, Name = $"Old-{id}" }).ToArray(); + await context.BulkInsertAsync(originals); + + var affectedRows = await context.GetService().BulkUpdateAsync( + originals.Select(entity => new MySqlTestEntity { Id = entity.Id, Name = $"New-{entity.Id}" }).ToArray(), + new MySqlBulkUpdateOptions + { + BatchSize = 100, + PropertiesToUpdate = IEntityPropertiesProvider.Include(entity => entity.Name) + }); + + affectedRows.Should().Be(1000); + (await context.Entities.AsNoTracking().CountAsync(entity => entity.Name!.StartsWith("New-"))).Should().Be(1000); + } + + [Fact] + public async Task Should_insert_owned_types_stored_in_separate_tables() + { + await using var context = await CreateContextAsync(); + + // Owned entities with their own table must be tracked. + var owner = new MySqlOwnerEntity + { + Id = 1, + Address = new MySqlAddress { City = "Berlin" }, + Phones = [new MySqlPhone { Number = "1" }, new MySqlPhone { Number = "2" }] + }; + context.Add(owner); + + await context.BulkInsertAsync([owner]); + + context.ChangeTracker.Clear(); + var loaded = await context.Owners.AsNoTracking().SingleAsync(); + loaded.Address!.City.Should().Be("Berlin"); + loaded.Phones.Select(phone => phone.Number).Should().BeEquivalentTo("1", "2"); + } + + [Fact] + public async Task Should_match_string_keys_with_the_collation_of_the_model() + { + await using var context = await CreateContextAsync(); + await context.BulkInsertAsync([ + new MySqlCollatedEntity { Id = 1, Name = "Aa" }, + new MySqlCollatedEntity { Id = 2, Name = "aa" } + ]); + + var affectedRows = await context.GetService().BulkUpdateAsync( + [new MySqlCollatedEntity { Name = "Aa", Count = 5 }], + new MySqlBulkUpdateOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Include(entity => entity.Count), + KeyProperties = IEntityPropertiesProvider.Include(entity => entity.Name) + }); + + affectedRows.Should().Be(1); + (await context.CollatedEntities.AsNoTracking().OrderBy(entity => entity.Id).Select(entity => entity.Count).ToArrayAsync()).Should().Equal(5, 0); + } + + [Fact] + public async Task Should_dispose_a_temp_table_reference_twice_without_error() + { + await using var context = await CreateContextAsync(); + var tempTable = await context.CreateTempTableAsync(new TempTableCreationOptions()); + + await tempTable.DisposeAsync(); + await tempTable.DisposeAsync(); + tempTable.Dispose(); + + context.Database.GetDbConnection().State.Should().Be(System.Data.ConnectionState.Closed); + } + + [Fact] + public void Should_reject_window_functions_on_servers_that_do_not_support_them() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", new MySqlServerVersion(new Version(5, 7, 40)), builder => builder.AddWindowFunctionsSupport()) + .Options; + var act = () => new MySqlTestDbContext(options).Entities.ToQueryString(); + + act.Should().Throw().WithMessage("*Window functions require*"); + } + + [Fact] + public void Should_reject_collection_parameters_on_servers_without_json_table() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", new MySqlServerVersion(new Version(8, 0, 3)), builder => builder.AddCollectionParameterSupport()) + .Options; + var act = () => new MySqlTestDbContext(options).Entities.ToQueryString(); + + act.Should().Throw().WithMessage("*JSON_TABLE*"); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationSemanticsTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationSemanticsTests.cs new file mode 100644 index 0000000..bdfd3e2 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkOperationSemanticsTests.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlBulkOperationSemanticsTests : MySqlIntegrationTestsBase +{ + public MySqlBulkOperationSemanticsTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_return_the_number_of_processed_rows_for_a_mixed_upsert() + { + await using var context = await CreateContextAsync(); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "a" }, new MySqlTestEntity { Id = 2, Name = "b" }]); + + var affectedRows = await context.GetService().BulkInsertOrUpdateAsync( + [ + new MySqlTestEntity { Id = 1, Name = "a2" }, + new MySqlTestEntity { Id = 2, Name = "b2" }, + new MySqlTestEntity { Id = 3, Name = "c" }, + new MySqlTestEntity { Id = 4, Name = "d" }, + new MySqlTestEntity { Id = 5, Name = "e" } + ], + new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(5); + (await context.Entities.CountAsync()).Should().Be(5); + } + + [Fact] + public async Task Should_return_one_for_an_upsert_without_changes() + { + await using var context = await CreateContextAsync(); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "a" }]); + + var affectedRows = await context.GetService().BulkInsertOrUpdateAsync( + [new MySqlTestEntity { Id = 1, Name = "a" }], + new MySqlBulkInsertOrUpdateOptions()); + + affectedRows.Should().Be(1); + } + + [Fact] + public async Task Should_not_rewrite_the_primary_key_when_upserting_by_another_key() + { + await using var context = await CreateContextAsync(); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "a" }]); + + await context.GetService().BulkInsertOrUpdateAsync( + [new MySqlTestEntity { Id = 9, Name = "a" }], + new MySqlBulkInsertOrUpdateOptions { KeyProperties = IEntityPropertiesProvider.Include(entity => entity.Name) }); + + (await context.Entities.AsNoTracking().Select(entity => entity.Id).ToListAsync()).Should().Equal(1); + } + + [Fact] + public async Task Should_throw_when_a_bulk_update_has_no_property_to_update() + { + await using var context = await CreateContextAsync(); + + var act = () => context.GetService().BulkUpdateAsync( + [new MySqlTestEntity { Id = 1, Name = "a" }], + new MySqlBulkUpdateOptions { PropertiesToUpdate = IEntityPropertiesProvider.Include(entity => entity.Id) }); + + await act.Should().ThrowAsync().WithMessage("*cannot be 0*"); + } + + [Fact] + public async Task Should_not_renumber_explicit_ids_in_the_staging_table_of_a_bulk_update() + { + await using var context = await CreateContextAsync(); + await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE `AutoIncrementEntities`"); + context.AutoIncrementEntities.AddRange(new MySqlAutoIncrementEntity { Name = "first" }, new MySqlAutoIncrementEntity { Name = "second" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.GetService().BulkUpdateAsync( + [new MySqlAutoIncrementEntity { Id = 0, Name = "changed" }], + new MySqlBulkUpdateOptions { PropertiesToUpdate = IEntityPropertiesProvider.Include(entity => entity.Name) }); + + affectedRows.Should().Be(0); + (await context.AutoIncrementEntities.AsNoTracking().OrderBy(entity => entity.Id).Select(entity => entity.Name).ToListAsync()) + .Should().Equal("first", "second"); + } + + [Fact] + public async Task Should_update_identity_rows_by_their_ids() + { + await using var context = await CreateContextAsync(); + await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE `AutoIncrementEntities`"); + context.AutoIncrementEntities.AddRange(new MySqlAutoIncrementEntity { Name = "first" }, new MySqlAutoIncrementEntity { Name = "second" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.GetService().BulkUpdateAsync( + [new MySqlAutoIncrementEntity { Id = 2, Name = "changed" }], + new MySqlBulkUpdateOptions { PropertiesToUpdate = IEntityPropertiesProvider.Include(entity => entity.Name) }); + + affectedRows.Should().Be(1); + (await context.AutoIncrementEntities.AsNoTracking().OrderBy(entity => entity.Id).Select(entity => entity.Name).ToListAsync()) + .Should().Equal("first", "changed"); + } + + [Fact] + public async Task Should_reset_the_auto_increment_counter_when_truncating_outside_of_a_transaction() + { + await using var context = await CreateContextAsync(); + await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE `AutoIncrementEntities`"); + context.AutoIncrementEntities.AddRange(new MySqlAutoIncrementEntity { Name = "first" }, new MySqlAutoIncrementEntity { Name = "second" }); + await context.SaveChangesAsync(); + + await context.TruncateTableAsync(); + context.ChangeTracker.Clear(); + + var entity = new MySqlAutoIncrementEntity { Name = "after" }; + context.AutoIncrementEntities.Add(entity); + await context.SaveChangesAsync(); + entity.Id.Should().Be(1); + } + + [Fact] + public async Task Should_not_reset_the_auto_increment_counter_when_truncating_inside_a_transaction() + { + await using var context = await CreateContextAsync(); + await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE `AutoIncrementEntities`"); + context.AutoIncrementEntities.AddRange(new MySqlAutoIncrementEntity { Name = "first" }, new MySqlAutoIncrementEntity { Name = "second" }); + await context.SaveChangesAsync(); + + await using (var transaction = await context.Database.BeginTransactionAsync()) + { + await context.TruncateTableAsync(); + await transaction.CommitAsync(); + } + + context.ChangeTracker.Clear(); + var entity = new MySqlAutoIncrementEntity { Name = "after" }; + context.AutoIncrementEntities.Add(entity); + await context.SaveChangesAsync(); + entity.Id.Should().Be(3); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkUpdateTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkUpdateTests.cs new file mode 100644 index 0000000..ce4904c --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlBulkUpdateTests.cs @@ -0,0 +1,107 @@ +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlBulkUpdateTests : MySqlIntegrationTestsBase +{ + public MySqlBulkUpdateTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_update_only_selected_property() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 201, Name = "Before" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.BulkUpdateAsync( + [new MySqlTestEntity { Id = 201, Name = "After" }], + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + affectedRows.Should().Be(1); + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("After"); + } + + [Fact] + public async Task Should_return_zero_when_no_key_matches() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 202, Name = "Existing" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.BulkUpdateAsync( + [new MySqlTestEntity { Id = 999, Name = "Missing" }], + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + affectedRows.Should().Be(0); + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("Existing"); + } + + [Fact] + public async Task Should_return_zero_for_empty_input() + { + await using var context = await CreateContextAsync(); + + var affectedRows = await context.BulkUpdateAsync( + Array.Empty(), + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + affectedRows.Should().Be(0); + } + + [Fact] + public async Task Should_match_on_a_non_primary_property() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 211, Name = "Match" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.BulkUpdateAsync( + [new MySqlTestEntity { Id = 212, Name = "Match" }], + propertiesToUpdate: entity => entity.Id, + propertiesToMatchOn: entity => entity.Name); + + affectedRows.Should().Be(1); + (await context.Entities.AsNoTracking().Select(entity => entity.Id).SingleAsync()).Should().Be(212); + } + + [Fact] + public async Task Should_update_nullable_column_to_null() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 213, Name = "Before" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + await context.BulkUpdateAsync( + [new MySqlTestEntity { Id = 213, Name = null }], + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().BeNull(); + } + + [Fact] + public async Task Should_update_using_a_composite_primary_key() + { + await using var context = await CreateContextAsync(); + context.CompositeEntities.Add(new MySqlCompositeEntity { RegionId = 7, ItemId = 11, Name = "Before" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.BulkUpdateAsync( + [new MySqlCompositeEntity { RegionId = 7, ItemId = 11, Name = "After" }], + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => new { entity.RegionId, entity.ItemId }); + + affectedRows.Should().Be(1); + (await context.CompositeEntities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("After"); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlQueryBasedBulkIntegrationTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlQueryBasedBulkIntegrationTests.cs new file mode 100644 index 0000000..51b16bf --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlQueryBasedBulkIntegrationTests.cs @@ -0,0 +1,157 @@ +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlQueryBasedBulkIntegrationTests : MySqlIntegrationTestsBase +{ + public MySqlQueryBasedBulkIntegrationTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task BulkInsertFromQuery_Should_insert_mapped_columns() + { + await using var context = await CreateContextAsync(); + context.QuerySources.AddRange( + new MySqlQuerySource { Id = 601, Name = "Source A", Count = 10 }, + new MySqlQuerySource { Id = 602, Name = "Source B", Count = 20 }); + await context.SaveChangesAsync(); + + var affectedRows = await context.QueryTargets.BulkInsertAsync( + context.QuerySources.AsNoTracking(), + map => map.Map(target => target.Id, source => source.Id) + .Map(target => target.Name, source => source.Name) + .Map(target => target.Count, source => source.Count)); + + affectedRows.Should().Be(2); + var rows = await context.QueryTargets.AsNoTracking().OrderBy(target => target.Id).ToListAsync(); + rows.Select(row => row.Name).Should().Equal("Source A", "Source B"); + rows.Select(row => row.Count).Should().Equal(10, 20); + } + + [Fact] + public async Task BulkInsertFromQuery_Should_insert_constant_mapping() + { + await using var context = await CreateContextAsync(); + context.QuerySources.Add(new MySqlQuerySource { Id = 603, Name = "Source", Count = 10 }); + await context.SaveChangesAsync(); + + await context.QueryTargets.BulkInsertAsync( + context.QuerySources.AsNoTracking(), + map => map.Map(target => target.Id, source => source.Id) + .Map(target => target.Name, source => "Mapped") + .Map(target => target.Count, source => source.Count)); + + var row = await context.QueryTargets.AsNoTracking().SingleAsync(); + row.Name.Should().Be("Mapped"); + row.Count.Should().Be(10); + } + + [Fact] + public async Task BulkInsertFromQuery_Should_insert_expression_and_captured_value_mappings() + { + await using var context = await CreateContextAsync(); + const string prefix = "Mapped-"; + context.QuerySources.Add(new MySqlQuerySource { Id = 604, Name = "Source", Count = 10 }); + await context.SaveChangesAsync(); + + await context.QueryTargets.BulkInsertAsync( + context.QuerySources.AsNoTracking(), + map => map.Map(target => target.Id, source => source.Id) + .Map(target => target.Name, source => prefix + source.Name) + .Map(target => target.Count, source => source.Count * 2 + 1)); + + var row = await context.QueryTargets.AsNoTracking().SingleAsync(); + row.Name.Should().Be("Mapped-Source"); + row.Count.Should().Be(21); + } + + [Fact] + public async Task BulkUpdateFromQuery_Should_apply_join_and_filter() + { + ArrangeDbContext.QueryTargets.AddRange( + new MySqlQueryTarget { Id = 611, Name = "Target A", Count = 5 }, + new MySqlQueryTarget { Id = 612, Name = "Target B", Count = 30 }); + ArrangeDbContext.QuerySources.AddRange( + new MySqlQuerySource { Id = 611, Name = "Source A", Count = 10 }, + new MySqlQuerySource { Id = 612, Name = "Source B", Count = 20 }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await ActDbContext.QueryTargets.BulkUpdateAsync( + ActDbContext.QuerySources.AsNoTracking(), + target => target.Id, + source => source.Id, + setters => setters + .Set(target => target.Name, (target, source) => source.Name) + .Set(target => target.Count, (target, source) => source.Count), + (target, source) => target.Count < source.Count); + + affectedRows.Should().Be(1); + ExecutedCommands.Should().Contain(command => command.Contains("UPDATE `QueryTargets`", StringComparison.Ordinal) + && command.Contains("INNER JOIN", StringComparison.Ordinal) + && command.Contains("WHERE", StringComparison.Ordinal)); + var rows = await AssertDbContext.QueryTargets.AsNoTracking().OrderBy(target => target.Id).ToListAsync(); + rows[0].Name.Should().Be("Source A"); + rows[0].Count.Should().Be(10); + rows[1].Name.Should().Be("Target B"); + rows[1].Count.Should().Be(30); + } + + [Fact] + public async Task BulkUpdateFromQuery_Should_apply_arithmetic_to_matching_rows() + { + await using var context = await CreateContextAsync(); + context.QueryTargets.Add(new MySqlQueryTarget { Id = 621, Name = "Target", Count = 2 }); + context.QuerySources.Add(new MySqlQuerySource { Id = 621, Name = "Source", Count = 5 }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.QueryTargets.BulkUpdateAsync( + context.QuerySources.AsNoTracking(), + target => target.Id, + source => source.Id, + setters => setters.Set(target => target.Count, (target, source) => target.Count + source.Count), + (target, source) => target.Count < source.Count); + + affectedRows.Should().Be(1); + (await context.QueryTargets.AsNoTracking().Select(target => target.Count).SingleAsync()).Should().Be(7); + } + + [Fact] + public async Task BulkUpdateFromQuery_Should_apply_a_captured_value() + { + await using var context = await CreateContextAsync(); + context.QueryTargets.Add(new MySqlQueryTarget { Id = 622, Name = "Target", Count = 2 }); + context.QuerySources.Add(new MySqlQuerySource { Id = 622, Name = "Source", Count = 5 }); + await context.SaveChangesAsync(); + const string name = "Captured"; + + var affectedRows = await context.QueryTargets.BulkUpdateAsync( + context.QuerySources.AsNoTracking(), + target => target.Id, + source => source.Id, + setters => setters.Set(target => target.Name, (_, _) => name)); + + affectedRows.Should().Be(1); + (await context.QueryTargets.AsNoTracking().Select(target => target.Name).SingleAsync()).Should().Be(name); + } + + [Fact] + public async Task BulkUpdateFromQuery_Should_return_zero_when_no_key_matches() + { + await using var context = await CreateContextAsync(); + context.QueryTargets.Add(new MySqlQueryTarget { Id = 631, Name = "Target", Count = 2 }); + context.QuerySources.Add(new MySqlQuerySource { Id = 999, Name = "Source", Count = 5 }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var affectedRows = await context.QueryTargets.BulkUpdateAsync( + context.QuerySources.AsNoTracking(), + target => target.Id, + source => source.Id, + setters => setters.Set(target => target.Name, (target, source) => source.Name)); + + affectedRows.Should().Be(0); + (await context.QueryTargets.AsNoTracking().Select(target => target.Name).SingleAsync()).Should().Be("Target"); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTableNameProviderTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTableNameProviderTests.cs new file mode 100644 index 0000000..717f07a --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTableNameProviderTests.cs @@ -0,0 +1,73 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlTableNameProviderTests : MySqlIntegrationTestsBase +{ + private const string _TARGET = "tn_target"; + + public MySqlTableNameProviderTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_into_the_table_of_the_provider() + { + await using var context = await CreateContextWithTargetTableAsync(); + + await context.GetService().BulkInsertAsync( + [new MySqlTestEntity { Id = 1, Name = "a" }], + new MySqlBulkInsertOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_TARGET) }); + + (await CountAsync(context, _TARGET)).Should().Be(1); + (await context.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_upsert_into_the_table_of_the_provider() + { + await using var context = await CreateContextWithTargetTableAsync(); + var options = new MySqlBulkInsertOrUpdateOptions { TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_TARGET) }; + var executor = context.GetService(); + + await executor.BulkInsertOrUpdateAsync([new MySqlTestEntity { Id = 1, Name = "a" }], options); + await executor.BulkInsertOrUpdateAsync([new MySqlTestEntity { Id = 1, Name = "b" }], options); + + (await CountAsync(context, _TARGET)).Should().Be(1); + (await context.Database.SqlQueryRaw($"SELECT `Name` AS `Value` FROM `{_TARGET}`").SingleAsync()).Should().Be("b"); + (await context.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_update_the_table_of_the_provider() + { + await using var context = await CreateContextWithTargetTableAsync(); + await context.Database.ExecuteSqlRawAsync($"INSERT INTO `{_TARGET}` (`Id`, `Name`) VALUES (1, 'before')"); + + var affectedRows = await context.GetService().BulkUpdateAsync( + [new MySqlTestEntity { Id = 1, Name = "after" }], + new MySqlBulkUpdateOptions + { + PropertiesToUpdate = IEntityPropertiesProvider.Include(entity => entity.Name), + TableNameProvider = IBulkOperationTableNameProvider.ForRootEntity(_TARGET) + }); + + affectedRows.Should().Be(1); + (await context.Database.SqlQueryRaw($"SELECT `Name` AS `Value` FROM `{_TARGET}`").SingleAsync()).Should().Be("after"); + } + + private async Task CreateContextWithTargetTableAsync() + { + var context = await CreateContextAsync(); + await context.Database.ExecuteSqlRawAsync($"DROP TABLE IF EXISTS `{_TARGET}`"); + await context.Database.ExecuteSqlRawAsync($"CREATE TABLE `{_TARGET}` LIKE `Entities`"); + return context; + } + + private static async Task CountAsync(MySqlTestDbContext context, string table) + { + return await context.Database.SqlQueryRaw($"SELECT COUNT(*) AS `Value` FROM `{table}`").SingleAsync(); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTempTableBulkInsertTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTempTableBulkInsertTests.cs new file mode 100644 index 0000000..e53bbc6 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTempTableBulkInsertTests.cs @@ -0,0 +1,191 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlTempTableBulkInsertTests : MySqlIntegrationTestsBase +{ + public MySqlTempTableBulkInsertTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_entities_and_drop_table_on_dispose() + { + await using var context = await CreateContextAsync(); + string tableName; + + // Keep the connection open: a closed connection is reset by the pool, which drops temp tables on its own. + await context.Database.OpenConnectionAsync(); + + try + { + await using (var tempTable = await context.BulkInsertIntoTempTableAsync( + [new MySqlTestTempEntity { Id = 501, Name = "Temporary" }], + context.GetService().CreateOptions())) + { + tableName = tempTable.Name; + tempTable.NumberOfInsertedRows.Should().Be(1); + (await tempTable.Query.Select(entity => entity.Name).SingleAsync()).Should().Be("Temporary"); + } + + var act = async () => await context.Database.ExecuteSqlRawAsync($"SELECT 1 FROM `{tableName}` LIMIT 1"); + + // 1146: table doesn't exist + (await act.Should().ThrowAsync()).Which.GetBaseException().Should().BeOfType() + .Which.Number.Should().Be(1146); + } + finally + { + await context.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_fail_with_error_1137_when_temp_table_is_referenced_twice_in_one_statement() + { + await using var context = await CreateContextAsync(); + await using var tempTable = await context.BulkInsertIntoTempTableAsync( + [new MySqlTestTempEntity { Id = 601, Name = "Twice" }], + context.GetService().CreateOptions()); + + var act = async () => await tempTable.Query.Join(tempTable.Query, left => left.Id, right => right.Id, (left, right) => left.Id).ToListAsync(); + + (await act.Should().ThrowAsync()).Which.Number.Should().Be(1137); + } + + [Fact] + public async Task Should_insert_primitive_values_into_temp_table() + { + await using var context = await CreateContextAsync(); + var executor = context.GetService(); + + await using var tempTable = await executor.BulkInsertValuesIntoTempTableAsync( + new[] { 11, 22, 33 }, + executor.CreateOptions(), + CancellationToken.None); + + tempTable.NumberOfInsertedRows.Should().Be(3); + (await tempTable.Query.OrderBy(value => value).ToArrayAsync()) + .Should().Equal(11, 22, 33); + } + + [Fact] + public async Task Should_append_entities_to_an_existing_temp_table() + { + await using var context = await CreateContextAsync(); + var tempTableReference = await context.CreateTempTableAsync(new TempTableCreationOptions()); + + try + { + await context.BulkInsertIntoTempTableAsync( + [new MySqlTestTempEntity { Id = 511, Name = "Appended" }], + tempTableReference, + entity => new { entity.Id, entity.Name }); + + var entityName = EntityNameProvider.GetTempTableName(typeof(MySqlTestTempEntity)); + var query = context.Set(entityName).FromTempTable(new TempTableInfo(tempTableReference.Name)); + (await query.Select(entity => entity.Name).SingleAsync()).Should().Be("Appended"); + } + finally + { + await tempTableReference.DisposeAsync(); + } + } + + [Fact] + public async Task Should_keep_session_temp_table_when_drop_on_dispose_is_disabled() + { + await using var context = await CreateContextAsync(); + var options = new TempTableCreationOptions + { + TableNameProvider = new FixedTempTableNameProvider("keep_temp_table"), + DropTableOnDispose = false + }; + var tempTable = await context.CreateTempTableAsync(options); + + try + { + await tempTable.DisposeAsync(); + + context.Database.GetDbConnection().State.Should().Be(System.Data.ConnectionState.Open); + var rowCount = await context.Database.SqlQueryRaw("SELECT COUNT(*) AS `Value` FROM `keep_temp_table`").SingleAsync(); + rowCount.Should().Be(0); + } + finally + { + await context.Database.ExecuteSqlRawAsync("DROP TEMPORARY TABLE IF EXISTS `keep_temp_table`"); + await context.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_drop_and_recreate_existing_temp_table_when_requested() + { + await using var context = await CreateContextAsync(); + var provider = new FixedTempTableNameProvider("reuse_temp_table"); + var keepOptions = new TempTableCreationOptions { TableNameProvider = provider, DropTableOnDispose = false }; + var firstReference = await context.CreateTempTableAsync(keepOptions); + + try + { + await context.BulkInsertIntoTempTableAsync( + [new MySqlTestTempEntity { Id = 521, Name = "Old" }], + firstReference, + entity => new { entity.Id, entity.Name }); + await firstReference.DisposeAsync(); + + var recreateOptions = new TempTableCreationOptions + { + TableNameProvider = provider, + TruncateTableIfExists = true, + DropTableOnDispose = false + }; + var secondReference = await context.CreateTempTableAsync(recreateOptions); + + try + { + var tableEntityName = EntityNameProvider.GetTempTableName(typeof(MySqlTestTempEntity)); + var query = context.Set(tableEntityName).FromTempTable(new TempTableInfo(secondReference.Name)); + (await query.CountAsync()).Should().Be(0); + + await context.BulkInsertIntoTempTableAsync( + [new MySqlTestTempEntity { Id = 522, Name = "New" }], + secondReference, + entity => new { entity.Id, entity.Name }); + + (await query.Select(entity => entity.Name).SingleAsync()).Should().Be("New"); + } + finally + { + await secondReference.DisposeAsync(); + } + } + finally + { + await context.Database.ExecuteSqlRawAsync("DROP TEMPORARY TABLE IF EXISTS `reuse_temp_table`"); + await context.Database.CloseConnectionAsync(); + } + } + + private sealed class FixedTempTableNameProvider(string name) : ITempTableNameProvider + { + public ITempTableNameLease LeaseName(DbContext context, IEntityType entityType) + { + return new FixedTempTableNameLease(name); + } + } + + private sealed class FixedTempTableNameLease(string name) : ITempTableNameLease + { + public string Name { get; } = name; + + public void Dispose() + { + } + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTransactionScopeTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTransactionScopeTests.cs new file mode 100644 index 0000000..63f9756 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTransactionScopeTests.cs @@ -0,0 +1,89 @@ +using System.Transactions; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlTransactionScopeTests : MySqlIntegrationTestsBase +{ + public MySqlTransactionScopeTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_and_commit_inside_a_transaction_scope() + { + await using var context = await CreateContextAsync(); + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "Scoped" }]); + scope.Complete(); + } + + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("Scoped"); + } + + [Fact] + public async Task Should_roll_back_an_insert_when_the_transaction_scope_is_not_completed() + { + await using var context = await CreateContextAsync(); + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "Scoped" }]); + } + + (await context.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_upsert_inside_a_transaction_scope() + { + await using var context = await CreateContextAsync(); + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + await context.BulkInsertOrUpdateAsync( + [new MySqlTestEntity { Id = 1, Name = "Scoped" }], + propertiesToInsert: entity => new { entity.Id, entity.Name }, + propertiesToUpdate: entity => entity.Name, + propertiesToMatchOn: entity => entity.Id); + scope.Complete(); + } + + (await context.Entities.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Should_update_inside_a_transaction_scope() + { + await using var context = await CreateContextAsync(); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "Before" }]); + + using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + await context.BulkUpdateAsync( + [new MySqlTestEntity { Id = 1, Name = "After" }], + entity => entity.Name, + entity => entity.Id); + scope.Complete(); + } + + (await context.Entities.AsNoTracking().Select(entity => entity.Name).SingleAsync()).Should().Be("After"); + } + + [Fact] + public async Task Should_not_commit_a_truncate_when_the_transaction_scope_is_not_completed() + { + await using var context = await CreateContextAsync(); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "One" }, new MySqlTestEntity { Id = 2, Name = "Two" }]); + + using (new TransactionScope(TransactionScopeAsyncFlowOption.Enabled)) + { + await context.TruncateTableAsync(); + } + + (await context.Entities.CountAsync()).Should().Be(2); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTruncateTableTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTruncateTableTests.cs new file mode 100644 index 0000000..710d178 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/MySqlTruncateTableTests.cs @@ -0,0 +1,107 @@ +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlTruncateTableTests : MySqlIntegrationTestsBase +{ + public MySqlTruncateTableTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_remove_all_rows() + { + await using var context = await CreateContextAsync(); + context.Entities.AddRange(new MySqlTestEntity { Id = 401, Name = "One" }, new MySqlTestEntity { Id = 402, Name = "Two" }); + await context.SaveChangesAsync(); + + await context.TruncateTableAsync(); + + (await context.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_truncate_a_table_referenced_by_a_foreign_key() + { + await using var context = await CreateContextAsync(); + context.Entities.Add(new MySqlTestEntity { Id = 410, Name = "Parent" }); + await context.SaveChangesAsync(); + + await context.TruncateTableAsync(); + + (await context.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_commit_the_surrounding_transaction() + { + await using var context = await CreateContextAsync(); + context.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source" }); + await context.SaveChangesAsync(); + + await using (var transaction = await context.Database.BeginTransactionAsync()) + { + context.Entities.Add(new MySqlTestEntity { Id = 420, Name = "Inside" }); + await context.SaveChangesAsync(); + + await context.TruncateTableAsync(); + + await transaction.RollbackAsync(); + } + + context.ChangeTracker.Clear(); + (await context.Entities.CountAsync()).Should().Be(0); + (await context.QuerySources.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Should_use_TRUNCATE_for_a_table_that_is_not_referenced_by_a_foreign_key() + { + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source" }); + await ArrangeDbContext.SaveChangesAsync(); + + await ActDbContext.TruncateTableAsync(); + + ExecutedCommands.Should().Contain(command => command.Contains("TRUNCATE TABLE `QuerySources`", StringComparison.Ordinal)); + (await AssertDbContext.QuerySources.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_use_DELETE_for_a_table_referenced_by_a_foreign_key() + { + ArrangeDbContext.Entities.Add(new MySqlTestEntity { Id = 430, Name = "Parent" }); + await ArrangeDbContext.SaveChangesAsync(); + + await ActDbContext.TruncateTableAsync(); + + ExecutedCommands.Should().Contain(command => command.Contains("DELETE FROM `Entities`", StringComparison.Ordinal)); + ExecutedCommands.Should().NotContain(command => command.Contains("TRUNCATE TABLE", StringComparison.Ordinal)); + (await AssertDbContext.Entities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_use_DELETE_inside_a_transaction() + { + ArrangeDbContext.QuerySources.Add(new MySqlQuerySource { Id = 1, Name = "Source" }); + await ArrangeDbContext.SaveChangesAsync(); + + await using (var transaction = await ActDbContext.Database.BeginTransactionAsync()) + { + await ActDbContext.TruncateTableAsync(); + await transaction.CommitAsync(); + } + + ExecutedCommands.Should().Contain(command => command.Contains("DELETE FROM `QuerySources`", StringComparison.Ordinal)); + ExecutedCommands.Should().NotContain(command => command.Contains("TRUNCATE TABLE", StringComparison.Ordinal)); + } + + [Fact] + public async Task Should_truncate_an_empty_table() + { + await using var context = await CreateContextAsync(); + + await context.TruncateTableAsync(); + + (await context.Entities.CountAsync()).Should().Be(0); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/OwnedTypeBulkOperationTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/OwnedTypeBulkOperationTests.cs new file mode 100644 index 0000000..85fa120 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/OwnedTypeBulkOperationTests.cs @@ -0,0 +1,28 @@ +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class OwnedTypeBulkOperationTests : MySqlIntegrationTestsBase +{ + public OwnedTypeBulkOperationTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_reject_bulk_updates_of_separately_mapped_owned_types() + { + var act = () => ActDbContext.BulkUpdateAsync( + [new MySqlOwnerEntity { Id = 1, Address = new MySqlAddress { City = "Berlin" } }]); + + await act.Should().ThrowAsync().WithMessage("*owned entities*"); + } + + [Fact] + public async Task Should_reject_upserts_of_separately_mapped_owned_types() + { + var act = () => ActDbContext.BulkInsertOrUpdateAsync( + [new MySqlOwnerEntity { Id = 1, Address = new MySqlAddress { City = "Berlin" } }]); + + await act.Should().ThrowAsync().WithMessage("*owned entities*"); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/QueryBasedBulkOperationsTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/QueryBasedBulkOperationsTests.cs new file mode 100644 index 0000000..d9f9f61 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/BulkOperations/MySqlBulkOperationExecutorTests/QueryBasedBulkOperationsTests.cs @@ -0,0 +1,94 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.Logging; +using Thinktecture.EntityFrameworkCore.BulkOperations.Internal; + +namespace Thinktecture.EntityFrameworkCore.BulkOperations.MySqlBulkOperationExecutorTests; + +[SuppressMessage("Usage", "EF1001:Internal EF Core API usage.")] +public sealed class QueryBasedBulkOperationsTests +{ + [Fact] + public void Should_generate_insert_select_with_mapped_columns() + { + using var context = CreateContext(); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var translator = new EfCoreValueExpressionTranslator(loggerFactory.CreateLogger("MySQL tests"), context); + var mapping = new InsertPropertyBuilder() + .Map(target => target.Name, source => source.Name); + + var (sql, parameters) = translator.TranslateInsertFromQuery( + context.Entities, + mapping.Entries, + IBulkOperationTableNameProvider.ForRootEntity("insert_target")); + + sql.Should().Contain("INSERT INTO `insert_target` (`Name`)"); + sql.Should().Contain("SELECT"); + parameters.Should().BeEmpty(); + } + + [Fact] + public void Should_generate_update_join_with_filter() + { + using var context = CreateContext(); + using var loggerFactory = LoggerFactory.Create(_ => { }); + var translator = new EfCoreValueExpressionTranslator(loggerFactory.CreateLogger("MySQL tests"), context); + var assignments = new SetPropertyBuilder() + .Set(target => target.Name, (target, source) => source.Name); + Expression> filter = (target, source) => target.Id < source.Id; + + var (sql, _) = translator.TranslateUpdateFromQuery( + context.Entities, + target => target.Id, + source => source.Id, + assignments.Entries, + filter, + IBulkOperationTableNameProvider.Default); + + sql.Should().Contain("UPDATE"); + sql.Should().Contain("JOIN"); + sql.Should().Contain(" ON "); + sql.Should().Contain("SET"); + sql.Should().Contain("WHERE"); + } + + [Fact] + public async Task Should_reject_query_insert_without_mappings() + { + var act = async () => + { + using var context = CreateContext(); + var executor = context.GetService(); + await executor.BulkInsertAsync(context.Entities, builder => builder); + }; + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Should_reject_query_update_without_assignments() + { + var act = async () => + { + using var context = CreateContext(); + var executor = context.GetService(); + await executor.BulkUpdateAsync( + context.Entities, + target => target.Id, + source => source.Id, + builder => builder); + }; + + await act.Should().ThrowAsync(); + } + + private static MySqlTestDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddBulkOperationSupport()) + .Options; + + return new MySqlTestDbContext(options); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionTests.cs new file mode 100644 index 0000000..378b60d --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Infrastructure/MySqlDbContextOptionsExtensionTests.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Thinktecture.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.Infrastructure.MySqlDbContextOptionsExtensionTests; + +public sealed class MySqlDbContextOptionsExtensionTests +{ + [Fact] + public void Should_enable_the_custom_factories_through_window_functions() + { + var extension = CreateExtension(builder => builder.AddWindowFunctionsSupport()); + + extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory.Should().BeTrue(); + extension.AddCustomQuerySqlGeneratorFactory.Should().BeTrue(); + extension.AddCustomRelationalParameterBasedSqlProcessorFactory.Should().BeTrue(); + } + + [Fact] + public void Should_enable_only_the_queryable_factory_through_collection_parameters() + { + var extension = CreateExtension(builder => builder.AddCollectionParameterSupport()); + + extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory.Should().BeTrue(); + extension.AddCustomQuerySqlGeneratorFactory.Should().BeFalse(); + extension.AddCustomRelationalParameterBasedSqlProcessorFactory.Should().BeFalse(); + } + + [Fact] + public void Should_enable_the_queryable_factory_explicitly() + { + var extension = CreateExtension(builder => builder.AddCustomQueryableMethodTranslatingExpressionVisitorFactory()); + + extension.AddCustomQueryableMethodTranslatingExpressionVisitorFactory.Should().BeTrue(); + extension.AddCustomQuerySqlGeneratorFactory.Should().BeFalse(); + } + + [Fact] + public void Should_not_share_a_service_provider_between_different_feature_sets() + { + var withBulk = CreateExtension(builder => builder.AddBulkOperationSupport()); + var withWindow = CreateExtension(builder => builder.AddWindowFunctionsSupport()); + + withBulk.Info.ShouldUseSameServiceProvider(withWindow.Info).Should().BeFalse(); + withBulk.Info.GetServiceProviderHashCode().Should().NotBe(withWindow.Info.GetServiceProviderHashCode()); + } + + [Fact] + public void Should_share_a_service_provider_between_equal_feature_sets() + { + var first = CreateExtension(builder => builder.AddBulkOperationSupport().AddWindowFunctionsSupport()); + var second = CreateExtension(builder => builder.AddBulkOperationSupport().AddWindowFunctionsSupport()); + + first.Info.ShouldUseSameServiceProvider(second.Info).Should().BeTrue(); + first.Info.GetServiceProviderHashCode().Should().Be(second.Info.GetServiceProviderHashCode()); + } + + [Fact] + public void Should_report_the_features_in_the_debug_info() + { + var extension = CreateExtension(builder => builder.AddBulkOperationSupport().AddWindowFunctionsSupport()); + var debugInfo = new Dictionary(); + + extension.Info.PopulateDebugInfo(debugInfo); + + debugInfo["Thinktecture:MySqlBulkOperationSupport"].Should().Be("True"); + debugInfo["Thinktecture:MySqlWindowFunctionsSupport"].Should().Be("True"); + debugInfo["Thinktecture:MySqlCollectionParameterSupport"].Should().Be("False"); + debugInfo["Thinktecture:MySqlCustomQuerySqlGeneratorFactory"].Should().Be("True"); + } + + [Fact] + public void Should_report_the_features_in_the_log_fragment() + { + var extension = CreateExtension(builder => builder.AddBulkOperationSupport().AddCollectionParameterSupport()); + + extension.Info.LogFragment.Should().Contain("BulkOperationSupport").And.Contain("CollectionParameterSupport").And.NotContain("WindowFunctionsSupport"); + } + + private static MySqlDbContextOptionsExtension CreateExtension(Action configure) + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, configure) + .Options; + + return options.FindExtension() ?? throw new InvalidOperationException("The MySQL extension is missing."); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/MySqlIntegrationTestsBase.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/MySqlIntegrationTestsBase.cs new file mode 100644 index 0000000..3180fda --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/MySqlIntegrationTestsBase.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Thinktecture.EntityFrameworkCore.Diagnostics; +using Thinktecture.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore; + +[CollectionDefinition(NAME, DisableParallelization = true)] +public sealed class MySqlTestCollection : ICollectionFixture +{ + public const string NAME = "MySqlTests"; +} + +public abstract class MySqlIntegrationTestsBase : IAsyncLifetime, IAsyncDisposable +{ + private readonly MySqlContainerFixture _fixture; + private readonly CommandCapturingInterceptor _commandCapturingInterceptor = new(); + private MySqlTestDbContext? _arrangeDbContext; + private MySqlTestDbContext? _actDbContext; + private MySqlTestDbContext? _assertDbContext; + + protected MySqlIntegrationTestsBase(MySqlContainerFixture fixture) + { + _fixture = fixture; + } + + protected string ConnectionString => _fixture.ConnectionString; + protected Action? ConfigureModel { get; set; } + protected Action? Configure { get; set; } + + /// + /// Configures the MySQL specific options (for example AddCollectionParameterSupport()) of the Arrange/Act/Assert contexts. + /// Has to be set before the first context is accessed. + /// + protected Action? ConfigureMySql { get; set; } + + protected IReadOnlyCollection ExecutedCommands => _commandCapturingInterceptor.Commands; + protected MySqlTestDbContext ArrangeDbContext => _arrangeDbContext ??= CreateDbContext(); + protected MySqlTestDbContext ActDbContext => _actDbContext ??= CreateDbContext(); + protected MySqlTestDbContext AssertDbContext => _assertDbContext ??= CreateDbContext(); + + public async Task InitializeAsync() + { + await using var context = CreateDbContext(); + + await context.Database.EnsureCreatedAsync(); + await TruncateNonEmptyTablesAsync(context); + } + + // Every test starts with empty tables, whatever entities it uses. Only tables that contain rows are truncated (TRUNCATE is DDL and slow), + // which also resets AUTO_INCREMENT counters. + private static async Task TruncateNonEmptyTablesAsync(MySqlTestDbContext context) + { + var tables = await context.Database + .SqlQueryRaw("SELECT TABLE_NAME AS `Value` FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'") + .ToListAsync(); + + if (tables.Count == 0) + return; + + var probe = String.Join(" UNION ALL ", tables.Select(table => $"(SELECT '{Escape(table, '\'')}' AS `Value` FROM `{Escape(table, '`')}` LIMIT 1)")); + var nonEmptyTables = await context.Database.SqlQueryRaw(probe).ToListAsync(); + + if (nonEmptyTables.Count == 0) + return; + + var statements = String.Concat(nonEmptyTables.Select(table => $"TRUNCATE TABLE `{Escape(table, '`')}`;")); + + // The setting is session scoped; it is switched back on explicitly because the connection returns to the pool. + await context.Database.ExecuteSqlRawAsync($"SET FOREIGN_KEY_CHECKS = 0;{statements}SET FOREIGN_KEY_CHECKS = 1;"); + } + + private static string Escape(string value, char quote) + { + return value.Replace(quote.ToString(), new string(quote, 2), StringComparison.Ordinal); + } + + protected Task CreateContextAsync(Action? configure = null) + { + return Task.FromResult(CreateDbContext(configure)); + } + + private MySqlTestDbContext CreateDbContext(Action? configure = null) + { + return new MySqlTestDbContext(CreateOptions(configure)) + { + Configure = Configure, + ConfigureModel = ConfigureModel + }; + } + + private DbContextOptions CreateOptions(Action? configure) + { + var builder = new DbContextOptionsBuilder() + .UseMySql(_fixture.ConnectionString, MySqlContainerFixture.ServerVersion, mySqlBuilder => + { + mySqlBuilder.AddBulkOperationSupport().AddWindowFunctionsSupport(); + ConfigureMySql?.Invoke(mySqlBuilder); + configure?.Invoke(mySqlBuilder); + }) + .AddNestedTransactionSupport() + .AddInterceptors(_commandCapturingInterceptor); + + // EF caches the model per context type; a per-test model configuration must not leak into other tests. + if (ConfigureModel is not null) + builder.ReplaceService(); + + return builder.Options; + } + + public async ValueTask DisposeAsync() + { + if (_arrangeDbContext is not null) + await _arrangeDbContext.DisposeAsync(); + + if (_actDbContext is not null) + await _actDbContext.DisposeAsync(); + + if (_assertDbContext is not null) + await _assertDbContext.DisposeAsync(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await DisposeAsync(); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterExecutionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterExecutionTests.cs new file mode 100644 index 0000000..e89c17a --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterExecutionTests.cs @@ -0,0 +1,134 @@ +using System.Text.Json; + +namespace Thinktecture.EntityFrameworkCore.Parameters; + +[Collection(MySqlTestCollection.NAME)] +public sealed class CollectionParameterExecutionTests : MySqlIntegrationTestsBase +{ + public CollectionParameterExecutionTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_round_trip_decimals_with_more_than_15_significant_digits() + { + await using var context = CreateContext(); + + var result = await context.CreateScalarCollectionParameter(new[] { 12345678901234567.89m }, applyDistinct: false).ToListAsync(); + + result.Should().Equal(12345678901234567.89m); + } + + [Fact] + public async Task Should_round_trip_time_spans_of_more_than_a_day() + { + await using var context = CreateContext(); + var value = TimeSpan.FromHours(36); + + var result = await context.CreateScalarCollectionParameter(new[] { value, TimeSpan.FromMinutes(-90) }, applyDistinct: false).ToListAsync(); + + result.Should().BeEquivalentTo(new[] { value, TimeSpan.FromMinutes(-90) }); + } + + [Fact] + public async Task Should_round_trip_date_times_with_fractional_seconds() + { + await using var context = CreateContext(); + var value = new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Unspecified).AddTicks(1234560); + + var result = await context.CreateScalarCollectionParameter(new[] { value }, applyDistinct: false).ToListAsync(); + + result.Should().Equal(value); + } + + [Fact] + public async Task Should_round_trip_common_scalar_types() + { + await using var context = CreateContext(); + var guid = Guid.NewGuid(); + + (await context.CreateScalarCollectionParameter(new[] { 1, 2, 3 }, applyDistinct: false).OrderBy(value => value).ToListAsync()).Should().Equal(1, 2, 3); + (await context.CreateScalarCollectionParameter(new[] { 1L << 40 }, applyDistinct: false).ToListAsync()).Should().Equal(1L << 40); + (await context.CreateScalarCollectionParameter(new[] { "a", "b" }, applyDistinct: false).OrderBy(value => value).ToListAsync()).Should().Equal("a", "b"); + (await context.CreateScalarCollectionParameter(new[] { guid }, applyDistinct: false).ToListAsync()).Should().Equal(guid); + (await context.CreateScalarCollectionParameter(new[] { 1.5d }, applyDistinct: false).ToListAsync()).Should().Equal(1.5d); + } + + [Fact] + public async Task Should_round_trip_boolean_and_date_time_offset_values() + { + await using var context = CreateContext(); + var value = new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.FromHours(2)).AddTicks(1234560); + + (await context.CreateScalarCollectionParameter(new[] { true, false }, applyDistinct: false).OrderBy(item => item).ToListAsync()).Should().Equal(false, true); + (await context.CreateScalarCollectionParameter(new[] { value }, applyDistinct: false).ToListAsync()).Should().Equal(value); + } + + [Fact] + public async Task Should_apply_distinct() + { + await using var context = CreateContext(); + + var result = await context.CreateScalarCollectionParameter(new[] { 1, 1, 2 }, applyDistinct: true).OrderBy(value => value).ToListAsync(); + + result.Should().Equal(1, 2); + } + + [Fact] + public async Task Should_join_a_scalar_collection_with_a_table() + { + await using var context = await CreateContextAsync(builder => builder.AddCollectionParameterSupport()); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "a" }, new MySqlTestEntity { Id = 2, Name = "b" }, new MySqlTestEntity { Id = 3, Name = "c" }]); + var ids = context.CreateScalarCollectionParameter(new[] { 1, 3 }, applyDistinct: true); + + var names = await context.Entities.Where(entity => ids.Contains(entity.Id)).OrderBy(entity => entity.Id).Select(entity => entity.Name).ToListAsync(); + + names.Should().Equal("a", "c"); + } + + [Fact] + public async Task Should_read_complex_objects_using_the_json_naming_policy() + { + await using var context = CreateContext(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + + var result = await context.CreateComplexCollectionParameter(new[] { new MySqlTestComplexParameter(7), new MySqlTestComplexParameter(8) }, applyDistinct: false) + .OrderBy(parameter => parameter.Id) + .ToListAsync(); + + result.Select(parameter => parameter.Id).Should().Equal(7, 8); + } + + [Fact] + public async Task Should_read_complex_objects_using_json_property_name_attributes() + { + await using var context = CreateContext(); + + var result = await context.CreateComplexCollectionParameter(new[] { new MySqlTestJsonNamedComplexParameter(7) }, applyDistinct: false) + .SingleAsync(); + + result.Value.Should().Be(7); + } + + [Fact] + public async Task Should_apply_distinct_to_complex_collection_parameters() + { + await using var context = CreateContext(); + + var result = await context.CreateComplexCollectionParameter( + [new MySqlTestComplexParameter(7), new MySqlTestComplexParameter(7)], + applyDistinct: true) + .ToListAsync(); + + result.Should().ContainSingle().Which.Id.Should().Be(7); + } + + private MySqlTestDbContext CreateContext(JsonSerializerOptions? jsonSerializerOptions = null) + { + var options = new DbContextOptionsBuilder() + .UseMySql(ConnectionString, MySqlContainerFixture.ServerVersion, builder => builder.AddBulkOperationSupport().AddCollectionParameterSupport(jsonSerializerOptions)) + .Options; + + return new MySqlTestDbContext(options); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterTests.cs new file mode 100644 index 0000000..186ad2e --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Parameters/CollectionParameterTests.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.Parameters; + +public sealed class CollectionParameterTests +{ + [Fact] + public void Should_generate_scalar_json_table_query_with_limit() + { + using var context = CreateContext(); + var query = context.GetService() + .CreateScalarQuery(context, new[] { 1, 2, 3 }, applyDistinct: false); + var sql = query.ToQueryString(); + + sql.Should().Contain("JSON_TABLE"); + sql.Should().Contain("LIMIT 3"); + sql.Should().NotContain("SELECT DISTINCT"); + } + + [Fact] + public void Should_apply_distinct_to_scalar_collection_query() + { + using var context = CreateContext(); + var query = context.GetService() + .CreateScalarQuery(context, new[] { 1, 1, 2 }, applyDistinct: true); + var sql = query.ToQueryString(); + + sql.Should().Contain("SELECT DISTINCT"); + sql.Should().Contain("LIMIT 3"); + } + + [Fact] + public void Should_generate_empty_scalar_collection_query() + { + using var context = CreateContext(); + var query = context.GetService() + .CreateScalarQuery(context, Array.Empty(), applyDistinct: false); + var sql = query.ToQueryString(); + + sql.Should().Contain("JSON_TABLE"); + sql.Should().Contain("LIMIT 0"); + } + + [Fact] + public void Should_generate_complex_json_table_query() + { + using var context = CreateContext(); + var query = context.GetService() + .CreateComplexQuery(context, new[] { new MySqlTestComplexParameter(1) }, applyDistinct: false); + var sql = query.ToQueryString(); + + sql.Should().Contain("JSON_TABLE"); + sql.Should().Contain("PATH '$.\"Id\"'"); + sql.Should().NotContain("SELECT DISTINCT"); + } + + [Fact] + public void Should_apply_distinct_to_complex_collection_query() + { + using var context = CreateContext(); + var query = context.GetService() + .CreateComplexQuery(context, new[] { new MySqlTestComplexParameter(1) }, applyDistinct: true); + var sql = query.ToQueryString(); + + sql.Should().Contain("SELECT DISTINCT"); + sql.Should().Contain("JSON_TABLE"); + } + + private static MySqlTestDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddCollectionParameterSupport()) + .Options; + + return new MySqlTestDbContext(options); + } +} \ No newline at end of file diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateExecutionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateExecutionTests.cs new file mode 100644 index 0000000..183da15 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateExecutionTests.cs @@ -0,0 +1,101 @@ +namespace Thinktecture.EntityFrameworkCore.Query; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlAggregateExecutionTests : MySqlIntegrationTestsBase +{ + public MySqlAggregateExecutionTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_aggregate_bitwise_over_multiple_rows() + { + await using var context = await SeedAsync(1, 2, 4); + + var result = await context.Entities + .GroupBy(entity => entity.Id < 100) + .Select(group => new + { + Or = EF.Functions.BitOr(group.Select(entity => entity.Id)), + And = EF.Functions.BitAnd(group.Select(entity => entity.Id)), + Xor = EF.Functions.BitXor(group.Select(entity => entity.Id)) + }) + .SingleAsync(); + + result.Or.Should().Be(7); + result.And.Should().Be(0); + result.Xor.Should().Be(7); + } + + [Fact] + public async Task Should_read_negative_result_into_int() + { + await using var context = await SeedAsync(-1); + + var result = await context.Entities + .GroupBy(entity => entity.Id < 100) + .Select(group => new + { + Or = EF.Functions.BitOr(group.Select(entity => entity.Id)), + And = EF.Functions.BitAnd(group.Select(entity => entity.Id)) + }) + .SingleAsync(); + + result.Or.Should().Be(-1); + result.And.Should().Be(-1); + } + + [Fact] + public async Task Should_read_negative_result_into_long() + { + await using var context = await SeedAsync(-1); + + var result = await context.Entities + .GroupBy(entity => entity.Id < 100) + .Select(group => EF.Functions.BitOr(group.Select(entity => (long)entity.Id))) + .SingleAsync(); + + result.Should().Be(-1L); + } + + [Fact] + public async Task Should_return_null_for_group_without_matching_rows() + { + await using var context = await SeedAsync(1, 2); + + var result = await context.Entities + .GroupBy(entity => entity.Id < 100) + .Select(group => new + { + Or = EF.Functions.BitOr(group.Where(entity => entity.Id > 100).Select(entity => (int?)entity.Id)), + And = EF.Functions.BitAnd(group.Where(entity => entity.Id > 100).Select(entity => (int?)entity.Id)) + }) + .SingleAsync(); + + result.Or.Should().BeNull(); + result.And.Should().BeNull(); + } + + [Fact] + public async Task Should_execute_bit_or_with_distinct() + { + await using var context = await SeedAsync(1, 3, 4); + + var result = await context.Entities + .GroupBy(entity => entity.Id < 100) + .Select(group => EF.Functions.BitOr(group.Select(entity => entity.Id % 2).Distinct())) + .SingleAsync(); + + result.Should().Be(1); + } + + private async Task SeedAsync(params int[] ids) + { + var context = await CreateContextAsync(); + context.Entities.AddRange(ids.Select(id => new MySqlTestEntity { Id = id, Name = $"n{id}" })); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + return context; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateTests.cs new file mode 100644 index 0000000..4ab72df --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlAggregateTests.cs @@ -0,0 +1,46 @@ +namespace Thinktecture.EntityFrameworkCore.Query; + +public sealed class MySqlAggregateTests +{ + [Fact] + public void Should_translate_bit_or() + { + var sql = CreateContext().Entities + .GroupBy(entity => entity.Name) + .Select(group => EF.Functions.BitOr(group.Select(entity => entity.Id))) + .ToQueryString(); + + sql.Should().Contain("BIT_OR("); + } + + [Fact] + public void Should_translate_bit_and() + { + var sql = CreateContext().Entities + .GroupBy(entity => entity.Name) + .Select(group => EF.Functions.BitAnd(group.Select(entity => entity.Id))) + .ToQueryString(); + + sql.Should().Contain("BIT_AND("); + } + + [Fact] + public void Should_translate_bit_xor() + { + var sql = CreateContext().Entities + .GroupBy(entity => entity.Name) + .Select(group => EF.Functions.BitXor(group.Select(entity => entity.Id))) + .ToQueryString(); + + sql.Should().Contain("BIT_XOR("); + } + + private static MySqlTestDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddWindowFunctionsSupport()) + .Options; + + return new MySqlTestDbContext(options); + } +} \ No newline at end of file diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlNtileTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlNtileTests.cs new file mode 100644 index 0000000..8075b43 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlNtileTests.cs @@ -0,0 +1,35 @@ +namespace Thinktecture.EntityFrameworkCore.Query; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlNtileTests : MySqlIntegrationTestsBase +{ + public MySqlNtileTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_apply_partitions_when_executing_ntile() + { + ArrangeDbContext.QuerySources.AddRange( + new MySqlQuerySource { Id = 1, Name = "A", Count = 1 }, + new MySqlQuerySource { Id = 2, Name = "A", Count = 2 }, + new MySqlQuerySource { Id = 3, Name = "A", Count = 3 }, + new MySqlQuerySource { Id = 4, Name = "B", Count = 1 }, + new MySqlQuerySource { Id = 5, Name = "B", Count = 2 }, + new MySqlQuerySource { Id = 6, Name = "B", Count = 3 }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.QuerySources + .OrderBy(entity => entity.Id) + .Select(entity => new + { + entity.Name, + Bucket = EF.Functions.NTile(2, entity.Name, EF.Functions.OrderBy(entity.Count)) + }) + .ToListAsync(); + + result.Where(item => item.Name == "A").Select(item => item.Bucket).Should().Equal(1, 1, 2); + result.Where(item => item.Name == "B").Select(item => item.Bucket).Should().Equal(1, 1, 2); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlQueryableMethodTranslationTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlQueryableMethodTranslationTests.cs new file mode 100644 index 0000000..52a602c --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlQueryableMethodTranslationTests.cs @@ -0,0 +1,28 @@ +namespace Thinktecture.EntityFrameworkCore.Query; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlQueryableMethodTranslationTests : MySqlIntegrationTestsBase +{ + public MySqlQueryableMethodTranslationTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_execute_a_subquery_when_bulk_operations_enable_the_custom_queryable_factory() + { + ArrangeDbContext.Entities.AddRange( + new MySqlTestEntity { Id = 1, Name = "first" }, + new MySqlTestEntity { Id = 2, Name = "second" }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.Entities + .Select(entity => new { entity.Id, entity.Name }) + .AsSubQuery() + .Where(entity => entity.Name == "second") + .ToListAsync(); + + result.Should().ContainSingle().Which.Id.Should().Be(2); + ExecutedCommands.Should().Contain(command => command.Contains("FROM (", StringComparison.Ordinal)); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlWindowFunctionExecutionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlWindowFunctionExecutionTests.cs new file mode 100644 index 0000000..fe0c17c --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/MySqlWindowFunctionExecutionTests.cs @@ -0,0 +1,268 @@ +namespace Thinktecture.EntityFrameworkCore.Query; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlWindowFunctionExecutionTests : MySqlIntegrationTestsBase +{ + private static readonly WindowFunction _countAll = new("COUNT", useAsteriskWhenNoArguments: true); + private static readonly WindowFunction _sum = new("SUM"); + private static readonly WindowFunction _max = new("MAX"); + private static readonly WindowFunction _bitOr = new("BIT_OR"); + private static readonly WindowFunction _rank = new("RANK"); + private static readonly WindowFunction _lead = new("LEAD"); + + public MySqlWindowFunctionExecutionTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_execute_count_star() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_countAll)) + .ToListAsync(); + + result.Should().Equal(3, 3, 3); + } + + [Fact] + public async Task Should_execute_sum() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_sum, entity.Id)) + .ToListAsync(); + + result.Should().Equal(7, 7, 7); + } + + [Fact] + public async Task Should_execute_max() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_max, entity.Id)) + .ToListAsync(); + + result.Should().Equal(4, 4, 4); + } + + [Fact] + public async Task Should_execute_bit_or() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_bitOr, entity.Id)) + .ToListAsync(); + + result.Should().Equal(7, 7, 7); + } + + [Fact] + public async Task Should_execute_rank_with_order_by() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_rank, EF.Functions.OrderBy(entity.Id))) + .ToListAsync(); + + result.Should().Equal(1, 2, 3); + } + + [Fact] + public async Task Should_execute_row_number() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.RowNumber(EF.Functions.OrderByDescending(entity.Id))) + .ToListAsync(); + + result.Should().Equal(3, 2, 1); + } + + [Fact] + public async Task Should_execute_ntile() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.NTile(2, EF.Functions.OrderBy(entity.Id))) + .ToListAsync(); + + result.Should().Equal(1, 1, 2); + } + + [Fact] + public async Task Should_execute_lead_with_multiple_arguments() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_lead, entity.Id, 1, EF.Functions.OrderBy(entity.Id))) + .ToListAsync(); + + result.Should().Equal(2, 4, null); + } + + [Fact] + public async Task Should_execute_partitioned_count() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_countAll, EF.Functions.PartitionBy(entity.Name))) + .ToListAsync(); + + result.Should().Equal(1, 1, 1); + } + + [Fact] + public async Task Should_execute_sum_average_max_and_min_window_functions() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => new + { + entity.Id, + Sum = EF.Functions.Sum(entity.Id, entity.Id % 2), + Average = EF.Functions.Average(entity.Id, entity.Id % 2), + Max = EF.Functions.Max(entity.Id, entity.Id % 2), + Min = EF.Functions.Min(entity.Id, entity.Id % 2) + }) + .ToListAsync(); + + result.Select(row => row.Sum).Should().Equal(1, 6, 6); + result.Select(row => row.Average).Should().Equal(1, 3, 3); + result.Select(row => row.Max).Should().Equal(1, 4, 4); + result.Select(row => row.Min).Should().Equal(1, 2, 2); + } + + [Fact] + public async Task Should_execute_ordered_sum_window_function() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.Sum(entity.Id, entity.Id % 2, EF.Functions.OrderBy(entity.Id))) + .ToListAsync(); + + result.Should().Equal(1, 2, 6); + } + + [Fact] + public async Task Should_execute_aggregate_window_functions_with_five_partition_columns() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => new + { + Max = EF.Functions.Max(entity.Id, + entity.Id % 2, + entity.Id % 3, + entity.Name, + entity.Id > 0, + entity.Id + 1, + EF.Functions.OrderBy(entity.Id)), + Min = EF.Functions.Min(entity.Id, + entity.Id % 2, + entity.Id % 3, + entity.Name, + entity.Id > 0, + entity.Id + 1) + }) + .ToListAsync(); + + result.Select(row => row.Max).Should().Equal(1, 2, 4); + result.Select(row => row.Min).Should().Equal(1, 2, 4); + } + + [Fact] + public async Task Should_execute_ntile_with_five_partition_columns() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.NTile(2, + entity.Id % 2, + entity.Id % 3, + entity.Name, + entity.Id > 1, + entity.Id + 1, + EF.Functions.OrderBy(entity.Id))) + .ToListAsync(); + + result.Should().Equal(1, 1, 1); + } + + [Fact] + public async Task Should_execute_ntile_with_two_to_four_partition_columns() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => new + { + Two = EF.Functions.NTile(2, entity.Id % 2, entity.Id % 3), + Three = EF.Functions.NTile(2, entity.Id % 2, entity.Id % 3, entity.Name), + Four = EF.Functions.NTile(2, entity.Id % 2, entity.Id % 3, entity.Name, entity.Id > 0) + }) + .ToListAsync(); + + // Every row is alone in its partition; ignoring the partitions would yield the buckets 1, 1, 2. + result.Select(row => row.Two).Should().Equal(1, 1, 1); + result.Select(row => row.Three).Should().Equal(1, 1, 1); + result.Select(row => row.Four).Should().Equal(1, 1, 1); + } + + [Fact] + public async Task Should_execute_max_and_min_over_a_value_converted_column() + { + await using var context = await CreateContextAsync(); + context.TypedEntities.AddRange(new MySqlTypedEntity { Id = Guid.NewGuid(), Status = MySqlTestStatus.Draft, Flag = true }, + new MySqlTypedEntity { Id = Guid.NewGuid(), Status = MySqlTestStatus.Published, Flag = true }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var result = await context.TypedEntities + .Select(entity => new + { + Max = EF.Functions.Max(entity.Status, entity.Flag), + Min = EF.Functions.Min(entity.Status, entity.Flag) + }) + .ToListAsync(); + + result.Should().OnlyContain(row => row.Max == MySqlTestStatus.Published && row.Min == MySqlTestStatus.Draft); + } + + [Fact] + public async Task Should_execute_a_window_function_with_one_argument_and_ordering() + { + await using var context = await SeedAsync(); + + var result = await context.Entities.OrderBy(entity => entity.Id) + .Select(entity => EF.Functions.WindowFunction(_lead, entity.Id, EF.Functions.OrderBy(entity.Id))) + .ToListAsync(); + + result.Should().Equal(2, 4, null); + } + + private async Task SeedAsync() + { + var context = await CreateContextAsync(); + context.Entities.AddRange(new MySqlTestEntity { Id = 1, Name = "a" }, + new MySqlTestEntity { Id = 2, Name = "b" }, + new MySqlTestEntity { Id = 4, Name = "c" }); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + return context; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/WindowFunctionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/WindowFunctionTests.cs new file mode 100644 index 0000000..9e2d4ee --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Query/WindowFunctionTests.cs @@ -0,0 +1,104 @@ +namespace Thinktecture.EntityFrameworkCore.Query; + +public sealed class WindowFunctionTests +{ + private static readonly WindowFunction _rank = new("RANK"); + private static readonly WindowFunction _lead = new("LEAD"); + private static readonly WindowFunction _countAll = new("COUNT", useAsteriskWhenNoArguments: true); + + [Fact] + public void Should_generate_row_number_sql() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddWindowFunctionsSupport()) + .Options; + + using var context = new MySqlTestDbContext(options); + var sql = context.Entities + .Select(entity => new { entity.Id, RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(entity.Id)) }) + .ToQueryString(); + + sql.Should().Contain("ROW_NUMBER() OVER(ORDER BY"); + } + + [Fact] + public void Should_generate_custom_window_function_sql() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddWindowFunctionsSupport()) + .Options; + + using var context = new MySqlTestDbContext(options); + var sql = context.Entities + .Select(entity => new { entity.Id, Rank = EF.Functions.WindowFunction(_rank, EF.Functions.OrderBy(entity.Id)) }) + .ToQueryString(); + + sql.Should().Contain("RANK() OVER"); + } + + [Fact] + public void Should_generate_multi_argument_window_function_sql() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddWindowFunctionsSupport()) + .Options; + + using var context = new MySqlTestDbContext(options); + var sql = context.Entities + .Select(entity => EF.Functions.WindowFunction(_lead, entity.Id, 1, EF.Functions.OrderBy(entity.Id))) + .ToQueryString(); + + sql.Should().Contain("LEAD("); + sql.Should().Contain("OVER"); + } + + [Fact] + public void Should_generate_partition_only_window_function_sql() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddWindowFunctionsSupport()) + .Options; + + using var context = new MySqlTestDbContext(options); + var sql = context.Entities + .Select(entity => EF.Functions.WindowFunction(_countAll, EF.Functions.PartitionBy(entity.Name))) + .ToQueryString(); + + sql.Should().Contain("COUNT(*) OVER"); + sql.Should().Contain("PARTITION BY"); + } + + [Fact] + public void Should_generate_ntile_sql() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddWindowFunctionsSupport()) + .Options; + + using var context = new MySqlTestDbContext(options); + var sql = context.Entities + .Select(entity => new { entity.Id, Bucket = EF.Functions.NTile(2, EF.Functions.OrderBy(entity.Id)) }) + .ToQueryString(); + + sql.Should().Contain("NTILE("); + sql.Should().Contain("OVER"); + } + + [Fact] + public void Should_generate_partitioned_ntile_sql() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddWindowFunctionsSupport()) + .Options; + + using var context = new MySqlTestDbContext(options); + var sql = context.Entities + .Select(entity => new { entity.Id, Bucket = EF.Functions.NTile(2, entity.Name, EF.Functions.OrderBy(entity.Id)) }) + .ToQueryString(); + + sql.Should().Contain("NTILE("); + sql.Should().Contain("PARTITION BY"); + sql.Should().Contain("ORDER BY"); + } + +} \ No newline at end of file diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/MySqlNestedTransactionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/MySqlNestedTransactionTests.cs new file mode 100644 index 0000000..f382166 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/MySqlNestedTransactionTests.cs @@ -0,0 +1,86 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace Thinktecture.EntityFrameworkCore.Storage; + +[Collection(MySqlTestCollection.NAME)] +public sealed class MySqlNestedTransactionTests : MySqlIntegrationTestsBase +{ + public MySqlNestedTransactionTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_commit_the_root_transaction_after_a_nested_one() + { + await using var context = await CreateNestedContextAsync(); + + await using (var root = await context.Database.BeginTransactionAsync()) + { + context.Entities.Add(new MySqlTestEntity { Id = 1, Name = "root" }); + await context.SaveChangesAsync(); + + await using (var nested = await context.Database.BeginTransactionAsync()) + { + context.Entities.Add(new MySqlTestEntity { Id = 2, Name = "nested" }); + await context.SaveChangesAsync(); + await nested.CommitAsync(); + } + + await root.CommitAsync(); + } + + (await context.Entities.AsNoTracking().CountAsync()).Should().Be(2); + } + + [Fact] + public async Task Should_roll_back_nested_work_with_the_root_transaction() + { + await using var context = await CreateNestedContextAsync(); + + await using (var root = await context.Database.BeginTransactionAsync()) + { + await using (var nested = await context.Database.BeginTransactionAsync()) + { + context.Entities.Add(new MySqlTestEntity { Id = 1, Name = "nested" }); + await context.SaveChangesAsync(); + await nested.CommitAsync(); + } + + await root.RollbackAsync(); + } + + (await context.Entities.AsNoTracking().CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_run_bulk_operations_inside_a_nested_transaction() + { + await using var context = await CreateNestedContextAsync(); + + await using (var root = await context.Database.BeginTransactionAsync()) + { + await using var nested = await context.Database.BeginTransactionAsync(); + await context.BulkInsertAsync([new MySqlTestEntity { Id = 1, Name = "bulk" }]); + await nested.CommitAsync(); + await root.RollbackAsync(); + } + + (await context.Entities.AsNoTracking().CountAsync()).Should().Be(0); + } + + private async Task CreateNestedContextAsync() + { + // The base creates the schema and clears the tables. + await using (await CreateContextAsync()) + { + } + + var options = new DbContextOptionsBuilder() + .UseMySql(ConnectionString, MySqlContainerFixture.ServerVersion, builder => builder.AddBulkOperationSupport()) + .AddNestedTransactionSupport() + .Options; + + return new MySqlTestDbContext(options); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/NestedTransactionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/NestedTransactionTests.cs new file mode 100644 index 0000000..c74221d --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/Storage/NestedTransactionTests.cs @@ -0,0 +1,375 @@ +using System.Data.Common; +using System.Transactions; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Thinktecture.TestDatabaseContext; +using IsolationLevel = System.Data.IsolationLevel; + +namespace Thinktecture.EntityFrameworkCore.Storage; + +// Ported from the PostgreSQL/SQL Server tests. Besides plain SaveChanges, the bulk operations (which write with raw ADO commands) have to take part in the nested transactions as well. +// MySqlNestedTransactionTests contains the smaller, MySQL-specific scenarios. +[Collection(MySqlTestCollection.NAME)] +public sealed class NestedTransactionTests : MySqlIntegrationTestsBase +{ + private static readonly Guid _FIRST_ID = new("7631FF3B-E5B1-485A-ABD5-386C6B37E991"); + private static readonly Guid _SECOND_ID = new("7A4397B0-C9B6-46FF-B8C8-F39A9D6F5105"); + private static readonly Guid _THIRD_ID = new("CB682609-1503-4FBE-8A29-66E2DF8F00E5"); + + private NestedRelationalTransactionManager SUT => field ??= (NestedRelationalTransactionManager)ActDbContext.GetService(); + + public NestedTransactionTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_create_new_root_transaction() + { + SUT.CurrentTransaction.Should().BeNull(); + + await using var tx = await SUT.BeginTransactionAsync(); + + tx.Should().NotBeNull(); + tx.Should().Be(SUT.CurrentTransaction); + tx.GetDbTransaction().Should().NotBeNull(); + (await IsTransactionUsableAsync(tx.GetDbTransaction())).Should().BeTrue(); + } + + [Fact] + public async Task Should_create_new_root_transaction_and_commit_correctly() + { + await using (var tx = await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await tx.CommitAsync(); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Should_create_new_root_transaction_and_rollback_correctly() + { + await using (var tx = await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await tx.RollbackAsync(); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_create_new_root_transaction_and_dispose_correctly() + { + // Disposing a transaction that is neither committed nor rolled back rolls it back. + await using (await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_create_new_child_transaction() + { + await using var tx = await SUT.BeginTransactionAsync(); + await using var childTx = await SUT.BeginTransactionAsync(); + + childTx.Should().NotBeNull(); + childTx.Should().Be(SUT.CurrentTransaction); + childTx.GetDbTransaction().Should().NotBeNull(); + childTx.GetDbTransaction().Should().Be(tx.GetDbTransaction()); + (await IsTransactionUsableAsync(childTx.GetDbTransaction())).Should().BeTrue(); + } + + [Fact] + public async Task Should_create_new_child_transaction_and_commit_correctly() + { + await using (var tx = await SUT.BeginTransactionAsync()) + await using (var child = await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await child.CommitAsync(); + await tx.CommitAsync(); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Should_create_new_child_transaction_and_rollback_correctly() + { + await using (var tx = await SUT.BeginTransactionAsync()) + await using (var childTx = await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await childTx.RollbackAsync(); + await tx.RollbackAsync(); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_create_new_child_transaction_and_dispose_correctly() + { + await using (await SUT.BeginTransactionAsync()) + await using (await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_complete_underlying_translations_after_root_is_completed() + { + await using (var tx = await SUT.BeginTransactionAsync()) + await using (var child = await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { Id = _FIRST_ID, RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await child.CommitAsync(); + ActDbContext.TestEntities.Add(new TestEntity { Id = _SECOND_ID, RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await tx.RollbackAsync(); + + // outside of the transaction + ActDbContext.TestEntities.Add(new TestEntity { Id = _THIRD_ID, RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + } + + var ids = await AssertDbContext.TestEntities.Select(e => e.Id).ToListAsync(); + ids.Should().Equal(_THIRD_ID); + } + + [Fact] + public async Task Should_rollback_if_a_sibling_transaction_rollbacks() + { + await using (var tx = await SUT.BeginTransactionAsync()) + { + await using (var child = await SUT.BeginTransactionAsync()) + { + await child.RollbackAsync(); + } + + await using (var child = await SUT.BeginTransactionAsync()) + { + await child.CommitAsync(); + } + + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await tx.Awaiting(rootTx => rootTx.CommitAsync()) + .Should().ThrowAsync().WithMessage("The transaction has aborted."); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_dispose_child_transactions_if_root_transactions_is_disposed() + { + await using (await SUT.BeginTransactionAsync()) + { + // The child is intentionally neither completed nor disposed. + await SUT.BeginTransactionAsync(); + + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + } + + SUT.CurrentTransaction.Should().BeNull(); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_dispose_inner_child_transactions_if_outer_transactions_is_disposed() + { + await using (await SUT.BeginTransactionAsync()) + { + var child = await SUT.BeginTransactionAsync(); + + // The inner child is intentionally neither completed nor disposed. + await SUT.BeginTransactionAsync(); + + ActDbContext.TestEntities.Add(new TestEntity { RequiredName = "RequiredName" }); + await ActDbContext.SaveChangesAsync(); + + await child.DisposeAsync(); + } + + SUT.CurrentTransaction.Should().BeNull(); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_not_allow_create_child_transactions_with_higher_level() + { + await using var rootTx = await SUT.BeginTransactionAsync(IsolationLevel.RepeatableRead); + + rootTx.GetDbTransaction().IsolationLevel.Should().Be(IsolationLevel.RepeatableRead); + + await SUT.Awaiting(sut => sut.BeginTransactionAsync(IsolationLevel.Serializable)) + .Should().ThrowAsync().WithMessage("The isolation level 'RepeatableRead' of the parent transaction is not compatible to the provided isolation level 'Serializable'."); + } + + // MySQL's default isolation level is REPEATABLE READ, so a child asking for REPEATABLE READ or a weaker level is compatible. + // MySqlConnector reports IsolationLevel.Unspecified for a transaction that was started without a level, and the nested + // transaction rejects every child level below a parent reporting Unspecified (NestedDbContextTransaction.EnsureIsolationLevelCompatible). + [Theory] + [InlineData(IsolationLevel.ReadCommitted)] + [InlineData(IsolationLevel.RepeatableRead)] + public async Task Should_honor_default_isolation_level_if_no_level_is_provided(IsolationLevel childIsolationLevel) + { + await using var rootTx = await SUT.BeginTransactionAsync(); + + await using var childTx = await SUT.BeginTransactionAsync(childIsolationLevel); + + childTx.GetDbTransaction().Should().Be(rootTx.GetDbTransaction()); + } + + [Theory] + [InlineData(IsolationLevel.ReadUncommitted, "READ-UNCOMMITTED")] + [InlineData(IsolationLevel.ReadCommitted, "READ-COMMITTED")] + [InlineData(IsolationLevel.RepeatableRead, "REPEATABLE-READ")] + [InlineData(IsolationLevel.Serializable, "SERIALIZABLE")] + public async Task Should_apply_isolation_level_to_root_transaction(IsolationLevel isolationLevel, string expectedServerIsolationLevel) + { + await using var rootTx = await SUT.BeginTransactionAsync(isolationLevel); + + (await GetServerIsolationLevelAsync()).Should().Be(expectedServerIsolationLevel); + } + + [Fact] + public async Task Should_use_repeatable_read_for_root_transaction_if_no_level_is_provided() + { + await using var rootTx = await SUT.BeginTransactionAsync(); + + (await GetServerIsolationLevelAsync()).Should().Be("REPEATABLE-READ"); + } + + [Fact] + public async Task Should_commit_bulk_insert_and_save_changes_of_nested_transactions() + { + await using (var tx = await SUT.BeginTransactionAsync()) + { + ActDbContext.TestEntities.Add(new TestEntity { Id = _FIRST_ID, RequiredName = "SaveChanges" }); + await ActDbContext.SaveChangesAsync(); + + await using (var child = await SUT.BeginTransactionAsync()) + { + await ActDbContext.BulkInsertAsync([new TestEntity { Id = _SECOND_ID, RequiredName = "BulkInsert" }]); + await child.CommitAsync(); + } + + await tx.CommitAsync(); + } + + var names = await AssertDbContext.TestEntities.OrderBy(e => e.RequiredName).Select(e => e.RequiredName).ToListAsync(); + names.Should().Equal("BulkInsert", "SaveChanges"); + } + + [Fact] + public async Task Should_keep_bulk_insert_of_nested_transactions_invisible_until_the_root_transaction_is_committed() + { + await using (var tx = await SUT.BeginTransactionAsync()) + { + await using (var child = await SUT.BeginTransactionAsync()) + { + await ActDbContext.BulkInsertAsync([new TestEntity { Id = _FIRST_ID, RequiredName = "BulkInsert" }]); + await child.CommitAsync(); + } + + // Committing the child doesn't commit anything on the database. + (await ActDbContext.TestEntities.CountAsync()).Should().Be(1); + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + + await tx.CommitAsync(); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Should_roll_back_bulk_insert_if_a_child_transaction_rolls_back() + { + await using (var tx = await SUT.BeginTransactionAsync()) + { + await using (var child = await SUT.BeginTransactionAsync()) + { + await ActDbContext.BulkInsertAsync([new TestEntity { Id = _FIRST_ID, RequiredName = "BulkInsert" }]); + await child.RollbackAsync(); + } + + await tx.Awaiting(rootTx => rootTx.CommitAsync()) + .Should().ThrowAsync().WithMessage("The transaction has aborted."); + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_roll_back_bulk_update_and_bulk_upsert_of_nested_transactions_with_the_root_transaction() + { + await ArrangeDbContext.BulkInsertAsync([new TestEntity { Id = _FIRST_ID, RequiredName = "Before", Count = 1 }]); + + await using (var tx = await SUT.BeginTransactionAsync()) + await using (var child = await SUT.BeginTransactionAsync()) + { + await ActDbContext.BulkUpdateAsync([new TestEntity { Id = _FIRST_ID, RequiredName = "After", Count = 2 }]); + await ActDbContext.BulkInsertOrUpdateAsync([new TestEntity { Id = _SECOND_ID, RequiredName = "Upserted", Count = 3 }]); + + (await ActDbContext.TestEntities.CountAsync()).Should().Be(2); + + await child.CommitAsync(); + await tx.RollbackAsync(); + } + + var entity = await AssertDbContext.TestEntities.SingleAsync(); + entity.Id.Should().Be(_FIRST_ID); + entity.RequiredName.Should().Be("Before"); + entity.Count.Should().Be(1); + } + + private Task GetServerIsolationLevelAsync() + { + return ActDbContext.Database.SqlQueryRaw("SELECT @@transaction_isolation AS `Value`").SingleAsync(); + } + + private async Task IsTransactionUsableAsync(DbTransaction tx) + { + try + { + var connection = ActDbContext.Database.GetDbConnection(); + await using var command = connection.CreateCommand(); + command.Transaction = tx; + command.CommandText = "SELECT VERSION();"; + await command.ExecuteNonQueryAsync(); + } + catch (InvalidOperationException) + { + // MySqlConnector: "The transaction associated with this command is not the connection's active transaction". + return false; + } + + return true; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/CreateTempTableAsync.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/CreateTempTableAsync.cs new file mode 100644 index 0000000..be3e94a --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/CreateTempTableAsync.cs @@ -0,0 +1,1437 @@ +using System.Data; +using System.Data.Common; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore.BulkOperations; +using Thinktecture.Internal; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.EntityFrameworkCore.TempTables.MySqlTempTableCreatorTests; + +// A temp table exists in the session of the connection that created it, so the structure is read through the connection of ActDbContext. +// MySQL has no INFORMATION_SCHEMA entry for temp tables, the tests use SHOW FULL COLUMNS / SHOW KEYS / SHOW CREATE TABLE. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class CreateTempTableAsync : MySqlIntegrationTestsBase +{ + private readonly TempTableCreationOptions _optionsWithNonUniqueName; + private readonly TempTableCreationOptions _options; + + private MySqlTempTableCreator SUT => field ??= (MySqlTempTableCreator)ActDbContext.GetService(); + + public CreateTempTableAsync(MySqlContainerFixture fixture) + : base(fixture) + { + _optionsWithNonUniqueName = new TempTableCreationOptions { TableNameProvider = DefaultTempTableNameProvider.Instance, PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }; + _options = new TempTableCreationOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }; + } + + [Fact] + public async Task Should_create_temp_table_for_keyless_entity() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + tempTable.Name.Should().Be("#CustomTempTable"); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + (await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name)).Should().BeEmpty(); + } + + [Fact] + public async Task Should_execute_create_temporary_table_statement() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + var statement = GetTempTableStatements().Single(); + statement.Should().StartWith("CREATE TEMPORARY TABLE `#CustomTempTable` ("); + statement.Should().Contain("`Column1` int NOT NULL"); + statement.Should().Contain("`Column2` longtext NULL"); + statement.Should().NotContain("PRIMARY KEY"); + } + + [Fact] + public async Task Should_delete_temp_table_on_dispose_if_DropTableOnDispose_is_true() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + _optionsWithNonUniqueName.DropTableOnDispose = true; + + await using var connection = await MySqlTempTableInspector.KeepConnectionOpenAsync(ActDbContext); + + var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTable.Name)).Should().BeTrue(); + + tempTable.Dispose(); + + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTable.Name)).Should().BeFalse(); + } + + [Fact] + public async Task Should_delete_temp_table_on_disposeAsync_if_DropTableOnDispose_is_true() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + _optionsWithNonUniqueName.DropTableOnDispose = true; + + await using var connection = await MySqlTempTableInspector.KeepConnectionOpenAsync(ActDbContext); + + var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + await tempTable.DisposeAsync(); + + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTable.Name)).Should().BeFalse(); + } + + [Fact] + public async Task Should_use_existing_transaction_in_disposeAsync() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + _optionsWithNonUniqueName.DropTableOnDispose = true; + + await using var tx = await ActDbContext.Database.BeginTransactionAsync(); + + string tempTableName; + + await using (var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName)) + { + tempTableName = tempTable.Name; + } + + // The connection is kept open by the transaction. DROP TEMPORARY TABLE would fail if it doesn't use the transaction of the connection. + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTableName)).Should().BeFalse(); + + await tx.CommitAsync(); + } + + [Fact] + public async Task Should_not_delete_temp_table_on_dispose_if_DropTableOnDispose_is_false() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + _optionsWithNonUniqueName.DropTableOnDispose = false; + + var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + try + { + tempTable.Dispose(); + + // The connection stays open, otherwise the session and with it the temp table would be gone. + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Open); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + } + finally + { + await DropAndReleaseAsync(tempTable); + } + } + + [Fact] + public async Task Should_not_delete_temp_table_on_disposeAsync_if_DropTableOnDispose_is_false() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + _optionsWithNonUniqueName.DropTableOnDispose = false; + + var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + try + { + await tempTable.DisposeAsync(); + + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Open); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + } + finally + { + await DropAndReleaseAsync(tempTable); + } + } + + [Fact] + public async Task Should_dispose_temp_table_reference_multiple_times() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + await tempTable.DisposeAsync(); + await tempTable.DisposeAsync(); + tempTable.Dispose(); + + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + // The name is available again, i.e. it has been released once only. + await using var other = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + other.Name.Should().Be(tempTable.Name); + } + + [Fact] + public async Task Should_create_temp_table_with_provided_column_only() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + _optionsWithNonUniqueName.PropertiesToInclude = IEntityPropertiesProvider.Include(t => t.Column1); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(1); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + } + + [Fact] + public async Task Should_create_pk_if_options_flag_is_set() + { + _optionsWithNonUniqueName.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced; + + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(s => s.Column2).IsRequired().HasMaxLength(100)); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _optionsWithNonUniqueName); + + var columns = await GetColumnsAsync(tempTable); + ValidateColumn(columns[0], nameof(TempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(TempTable.Column2), "varchar(100)", false); + + (await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name)) + .Should().Equal(nameof(TempTable.Column1), nameof(TempTable.Column2)); + GetTempTableStatements().Single().Should().Contain("PRIMARY KEY (`Column1`, `Column2`)"); + } + + [Fact] + public async Task Should_throw_if_some_pk_columns_are_missing() + { + _optionsWithNonUniqueName.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.EntityTypeConfiguration; + _optionsWithNonUniqueName.PropertiesToInclude = IEntityPropertiesProvider.Include(t => t.Column1); + + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, typeBuilder => + { + typeBuilder.Property(s => s.Column2).HasMaxLength(100); + typeBuilder.HasKey(s => new { s.Column1, s.Column2 }); + }); + + await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName)) + .Should().ThrowAsync().WithMessage(""" + Cannot create PRIMARY KEY because not all key columns are part of the temp table. + You may use other key properties providers like 'IPrimaryKeyPropertiesProvider.AdaptiveEntityTypeConfiguration' instead of 'IPrimaryKeyPropertiesProvider.EntityTypeConfiguration' to get different behaviors. + Missing columns: Column2. + """); + + // Neither a table nor an open connection is left behind. + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_not_throw_if_some_pk_columns_are_missing_and_provider_is_Adaptive(bool forced) + { + _optionsWithNonUniqueName.PrimaryKeyCreation = forced ? IPrimaryKeyPropertiesProvider.AdaptiveForced : IPrimaryKeyPropertiesProvider.AdaptiveEntityTypeConfiguration; + _optionsWithNonUniqueName.PropertiesToInclude = IEntityPropertiesProvider.Include(t => t.Column1); + + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, typeBuilder => + { + typeBuilder.Property(s => s.Column2).HasMaxLength(100); + typeBuilder.HasKey(s => new { s.Column1, s.Column2 }); + }); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + (await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name)).Should().Equal(nameof(CustomTempTable.Column1)); + } + + [Fact] + public async Task Should_create_pk_of_entity_type_configuration_by_default() + { + var options = new TempTableCreationOptions(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + (await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name)).Should().Equal(nameof(TestEntity.Id)); + } + + [Fact] + public async Task Should_create_pk_with_provided_properties() + { + _options.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.From(e => new { e.Count, e.Id }); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + (await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name)).Should().Equal(nameof(TestEntity.Count), nameof(TestEntity.Id)); + } + + [Fact] + public async Task Should_throw_when_trying_to_create_pk_on_nullable_column() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + _options.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced; + + // 1171: all parts of a PRIMARY KEY must be NOT NULL + var exception = await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType>(), _options)) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1171); + + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_throw_when_trying_to_create_pk_on_longtext_column() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1).IsRequired()); + + _options.PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced; + + // 1170: BLOB/TEXT column used in key specification without a key length + var exception = await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType>(), _options)) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1170); + } + + [Fact] + public async Task Should_create_temp_table_with_int() + { + await AssertColumnAsync("int", false); + } + + [Fact] + public async Task Should_create_temp_table_with_nullable_int() + { + await AssertColumnAsync("int", true); + } + + [Fact] + public async Task Should_make_nullable_int_to_non_nullable_if_set_via_modelbuilder() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1).IsRequired()); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + var columns = await GetColumnsAsync(tempTable); + ValidateColumn(columns[0], nameof(TempTable.Column1), "int", false); + } + + [Fact] + public async Task Should_create_temp_table_with_long() + { + await AssertColumnAsync("bigint", false); + } + + [Fact] + public async Task Should_create_temp_table_with_short() + { + await AssertColumnAsync("smallint", false); + } + + [Fact] + public async Task Should_create_temp_table_with_byte() + { + await AssertColumnAsync("tinyint unsigned", false); + } + + [Fact] + public async Task Should_create_temp_table_with_string() + { + await AssertColumnAsync("longtext", true); + } + + [Fact] + public async Task Should_create_temp_table_with_string_with_max_length() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1).HasMaxLength(50)); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + var columns = await GetColumnsAsync(tempTable); + ValidateColumn(columns[0], nameof(TempTable.Column1), "varchar(50)", true); + } + + [Fact] + public async Task Should_create_temp_table_with_string_with_custom_collation() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(b => b.Property(e => e.Column2).UseCollation("utf8mb4_bin")); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true, collation: "utf8mb4_bin"); + GetTempTableStatements().Single().Should().Contain("`Column2` longtext COLLATE utf8mb4_bin NULL"); + } + + [Fact] + public async Task Should_create_temp_table_with_string_with_custom_charset_and_collation() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(b => b.Property(e => e.Column2).HasMaxLength(50).HasCharSet("latin1").UseCollation("latin1_general_cs")); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + var columns = await GetColumnsAsync(tempTable); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "varchar(50)", true, collation: "latin1_general_cs"); + GetTempTableStatements().Single().Should().Contain("`Column2` varchar(50) CHARACTER SET latin1 COLLATE latin1_general_cs NULL"); + } + + [Fact] + public async Task Should_create_temp_table_with_enum_column_with_charset() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(b => + { + b.Property(e => e.Status).HasColumnType("enum('Draft','Published')").HasCharSet("utf8mb4"); + }); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = await GetColumnsAsync(tempTable); + ValidateColumn(columns[0], nameof(EnumTempTable.Status), "enum('Draft','Published')", true); + columns[0].Collation.Should().StartWith("utf8mb4_"); + GetTempTableStatements().Single().Should().Contain("`Status` enum('Draft','Published') CHARACTER SET utf8mb4 NULL"); + } + + [Fact] + public async Task Should_create_temp_table_with_bool() + { + await AssertColumnAsync("tinyint(1)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_double() + { + await AssertColumnAsync("double", false); + } + + [Fact] + public async Task Should_create_temp_table_with_float() + { + await AssertColumnAsync("float", false); + } + + [Fact] + public async Task Should_create_temp_table_with_decimal() + { + await AssertColumnAsync("decimal(38,18)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_decimal_with_explicit_precision() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1).HasPrecision(20, 5)); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + var columns = await GetColumnsAsync(tempTable); + ValidateColumn(columns[0], nameof(TempTable.Column1), "decimal(20,5)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_guid() + { + await AssertColumnAsync("char(36)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_date_time() + { + await AssertColumnAsync("datetime(6)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_date_time_offset() + { + await AssertColumnAsync("datetime(6)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_date_only() + { + await AssertColumnAsync("date", false); + } + + [Fact] + public async Task Should_create_temp_table_with_time_only() + { + await AssertColumnAsync("time(6)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_time_span() + { + await AssertColumnAsync("time(6)", false); + } + + [Fact] + public async Task Should_create_temp_table_with_byte_array() + { + await AssertColumnAsync("longblob", true); + } + + [Fact] + public async Task Should_create_temp_table_with_enum() + { + await AssertColumnAsync("smallint", false); + } + + [Fact] + public async Task Should_create_temp_table_with_2_columns() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(TempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(TempTable.Column2), "longtext", true); + } + + [Fact] + public async Task Should_create_temp_table_with_one_column() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + (await GetColumnsAsync(tempTable)).Should().HaveCount(1); + } + + [Fact] + public async Task Should_create_temp_table_without_primary_key() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + (await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name)).Should().BeEmpty(); + } + + [Fact] + public async Task Should_create_temp_table_with_converter_and_default_value() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1) + .HasConversion(c => c.Key, k => new ConvertibleClass(k)) + .HasDefaultValue(new ConvertibleClass(1))); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + var columns = await GetColumnsAsync(tempTable); + ValidateColumn(columns[0], nameof(TempTable.Column1), "int", true, defaultValue: "1"); + } + + [Fact] + public async Task Should_create_temp_table_for_entityType() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(9); + + ValidateColumn(columns[0], nameof(TestEntity.ConvertibleClass), "int", true); + ValidateColumn(columns[1], nameof(TestEntity.Count), "int", false); + ValidateColumn(columns[2], nameof(TestEntity.Id), "char(36)", false); + ValidateColumn(columns[3], nameof(TestEntity.Name), "longtext", true); + ValidateColumn(columns[4], nameof(TestEntity.NullableCount), "int", true); + ValidateColumn(columns[5], nameof(TestEntity.ParentId), "char(36)", true); + ValidateColumn(columns[6], nameof(TestEntity.PropertyWithBackingField), "int", false); + ValidateColumn(columns[7], nameof(TestEntity.RequiredName), "longtext", false); + ValidateColumn(columns[8], "_privateField", "int", false); + } + + [Fact] + public async Task Should_create_temp_table_for_keyless_entityType() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(1); + ValidateColumn(columns[0], nameof(KeylessTestEntity.IntColumn), "int", false); + } + + [Fact] + public async Task Should_create_temp_table_for_entity_with_properties_of_base_class() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(2); + ValidateColumn(columns[0], nameof(TestEntityWithBaseClass.Id), "char(36)", false); + ValidateColumn(columns[1], nameof(TestEntityWithBaseClass.Name), "longtext", false); + } + + [Fact] + public async Task Should_create_temp_table_for_entity_with_shadow_properties() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(4); + ValidateColumn(columns[0], nameof(TestEntityWithShadowProperties.Id), "char(36)", false); + ValidateColumn(columns[1], nameof(TestEntityWithShadowProperties.Name), "longtext", true); + ValidateColumn(columns[2], "ShadowIntProperty", "int", true); + ValidateColumn(columns[3], "ShadowStringProperty", "varchar(50)", true); + } + + [Fact] + public async Task Should_create_temp_table_for_entity_with_different_column_names() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(3); + ValidateColumn(columns[0], "display_name", "longtext", true); + ValidateColumn(columns[1], "entity_id", "char(36)", false); + ValidateColumn(columns[2], "item_count", "int", false); + } + + [Fact] + public async Task Should_create_temp_table_for_entity_with_flags_enum() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(3); + ValidateColumn(columns[0], nameof(TestEntityWithFlags.GroupId), "int", false); + ValidateColumn(columns[1], nameof(TestEntityWithFlags.Id), "char(36)", false); + ValidateColumn(columns[2], nameof(TestEntityWithFlags.Phase), "smallint", false); + } + + [Fact] + public async Task Should_create_temp_table_for_entity_with_json_column_without_charset() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(2); + ValidateColumn(columns[0], nameof(TestEntityWithJsonColumns.Id), "char(36)", false); + ValidateColumn(columns[1], nameof(TestEntityWithJsonColumns.JsonColumn), "json", true); + columns[1].Collation.Should().BeNull(); + } + + [Fact] + public async Task Should_create_temp_table_with_sql_default_values() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(5); + + // MySQL requires an expression default like UUID() to be enclosed in parentheses. + ValidateColumn(columns[0], nameof(TestEntityWithSqlDefaultValues.Id), "char(36)", false); + columns[0].Default.Should().BeEquivalentTo("(uuid())"); + ValidateColumn(columns[1], nameof(TestEntityWithSqlDefaultValues.Int), "int", false, defaultValue: "1"); + ValidateColumn(columns[2], nameof(TestEntityWithSqlDefaultValues.NullableInt), "int", true, defaultValue: "2"); + ValidateColumn(columns[3], nameof(TestEntityWithSqlDefaultValues.NullableString), "varchar(100)", true, defaultValue: "4"); + ValidateColumn(columns[4], nameof(TestEntityWithSqlDefaultValues.String), "varchar(100)", false, defaultValue: "3"); + } + + [Fact] + public async Task Should_use_sql_default_values_for_columns_that_are_not_inserted() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + await ActDbContext.BulkInsertIntoTempTableAsync([new TestEntityWithSqlDefaultValues { Int = 42 }], tempTable, e => e.Int); + + var loadedEntity = await ActDbContext.TestEntitiesWithDefaultValues.FromTempTable(new TempTableInfo(tempTable.Name)).AsNoTracking().SingleAsync(); + loadedEntity.Id.Should().NotBeEmpty(); + loadedEntity.Int.Should().Be(42); + loadedEntity.NullableInt.Should().Be(2); + loadedEntity.String.Should().Be("3"); + loadedEntity.NullableString.Should().Be("4"); + } + + [Fact] + public async Task Should_create_temp_table_with_dotnet_default_values() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(5); + + ValidateColumn(columns[0], nameof(TestEntityWithDotnetDefaultValues.Id), "char(36)", false, defaultValue: "0b151271-79bb-4f6c-b85f-e8f61300ff1b"); + ValidateColumn(columns[1], nameof(TestEntityWithDotnetDefaultValues.Int), "int", false, defaultValue: "1"); + ValidateColumn(columns[2], nameof(TestEntityWithDotnetDefaultValues.NullableInt), "int", true, defaultValue: "2"); + ValidateColumn(columns[3], nameof(TestEntityWithDotnetDefaultValues.NullableString), "varchar(100)", true, defaultValue: "4"); + ValidateColumn(columns[4], nameof(TestEntityWithDotnetDefaultValues.String), "varchar(100)", false, defaultValue: "3"); + } + + [Fact] + public async Task Should_use_dotnet_default_values_for_columns_that_are_not_inserted() + { + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + + await ActDbContext.BulkInsertIntoTempTableAsync([new TestEntityWithDotnetDefaultValues()], tempTable, e => e.NullableString); + + var loadedEntity = await ActDbContext.TestEntitiesWithDotnetDefaultValues.FromTempTable(new TempTableInfo(tempTable.Name)).AsNoTracking().SingleAsync(); + loadedEntity.Id.Should().Be(new Guid("0B151271-79BB-4F6C-B85F-E8F61300FF1B")); + loadedEntity.Int.Should().Be(1); + loadedEntity.NullableInt.Should().Be(2); + loadedEntity.String.Should().Be("3"); + loadedEntity.NullableString.Should().BeNull(); + } + + [Fact] + public async Task Should_create_temp_table_with_auto_increment_if_it_is_primary_key() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, b => b.HasKey(e => e.Id)); + + var options = new TempTableCreationOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.EntityTypeConfiguration }; + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(AutoIncrementTempTable.Id), "int", false); + columns[0].Key.Should().Be("PRI"); + columns[0].Extra.Should().Contain("auto_increment"); + ValidateColumn(columns[1], nameof(AutoIncrementTempTable.Name), "longtext", true); + } + + [Fact] + public async Task Should_generate_values_for_auto_increment_column() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, b => b.HasKey(e => e.Id)); + + var options = new TempTableCreationOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.EntityTypeConfiguration }; + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + await ActDbContext.BulkInsertIntoTempTableAsync([new AutoIncrementTempTable { Name = "first" }, new AutoIncrementTempTable { Name = "second" }], tempTable, e => e.Name); + + var rows = await ActDbContext.Set(EntityNameProvider.GetTempTableName(typeof(AutoIncrementTempTable))) + .FromTempTable(new TempTableInfo(tempTable.Name)) + .AsNoTracking() + .OrderBy(e => e.Id) + .ToListAsync(); + rows.Select(e => (e.Id, e.Name)).Should().Equal((1, "first"), (2, "second")); + } + + [Fact] + public async Task Should_throw_when_creating_temp_table_with_auto_increment_if_it_is_not_primary_key() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, b => b.HasKey(e => e.Id)); + + await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), _options)) + .Should().ThrowAsync() + .WithMessage("MySQL requires auto-increment property 'Id' to be part of the temporary table primary key."); + + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_not_create_auto_increment_column_for_regular_entities() + { + // A staging copy of a regular entity has to keep the explicit ids, i.e. 0 must not be replaced by a generated value. + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), new TempTableCreationOptions()); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(TestEntityWithAutoIncrement.Id), "int", false); + columns[0].Key.Should().Be("PRI"); + columns[0].Extra.Should().NotContain("auto_increment"); + ValidateColumn(columns[1], nameof(TestEntityWithAutoIncrement.Name), "longtext", true); + } + + [Fact] + public async Task Should_throw_if_temp_table_is_not_introduced() + { + await SUT.Awaiting(c => c.CreateTempTableAsync(GetTempTableEntityType>(), _options)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Should_throw_if_type_is_not_known_by_context() + { + await ActDbContext.Awaiting(ctx => ctx.CreateTempTableAsync(_options)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Should_create_temp_table_via_context_extension_for_type() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + await using var tempTable = await ActDbContext.CreateTempTableAsync(typeof(CustomTempTable), _options, CancellationToken.None); + + (await GetColumnsAsync(tempTable)).Select(c => c.Name).Should().Equal(nameof(CustomTempTable.Column1), nameof(CustomTempTable.Column2)); + } + + [Fact] + public async Task Should_create_temp_table_with_truncate_if_exists() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new TempTableCreationOptions + { + TableNameProvider = ReusingTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None, + DropTableOnDispose = false, + TruncateTableIfExists = false + }; + + var first = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + await first.DisposeAsync(); + + options.TruncateTableIfExists = true; + var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + try + { + tempTable.Name.Should().Be(first.Name); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + + // The table is dropped first and created again afterwards. + var statements = GetTempTableStatements(); + statements.Should().HaveCount(3); + statements[1].Should().Be($"DROP TEMPORARY TABLE IF EXISTS `{tempTable.Name}`;"); + statements[2].Should().StartWith($"CREATE TEMPORARY TABLE `{tempTable.Name}` ("); + } + finally + { + await DropAndReleaseAsync(tempTable); + await ActDbContext.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_remove_rows_if_the_temp_table_is_created_again_with_truncate_if_exists() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new TempTableCreationOptions + { + TableNameProvider = DefaultTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None, + DropTableOnDispose = false, + TruncateTableIfExists = true + }; + + var first = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + await ActDbContext.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value")], first); + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, first.Name)).Should().Be(1); + + var second = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + try + { + second.Name.Should().Be(first.Name); + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, second.Name)).Should().Be(0); + } + finally + { + await DropAndReleaseAsync(second); + await ActDbContext.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_throw_if_temp_table_exists_already_and_TruncateTableIfExists_is_false() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + await using var first = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + await ActDbContext.BulkInsertIntoTempTableAsync([new CustomTempTable(1, "value")], first); + + // 1050: table already exists + var exception = await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName)) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1050); + + // The failed attempt neither dropped the existing table nor released its connection. + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Open); + (await MySqlTempTableInspector.CountRowsAsync(ActDbContext, first.Name)).Should().Be(1); + } + + [Fact] + public async Task Should_create_temp_table_with_truncate_if_exists_if_table_does_not_exist() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + _optionsWithNonUniqueName.TruncateTableIfExists = true; + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + + (await GetColumnsAsync(tempTable)).Should().HaveCount(2); + } + + [Fact] + public async Task Should_use_name_of_custom_name_provider() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var provider = new FixedTempTableNameProvider("my temp `table`"); + var options = new TempTableCreationOptions { TableNameProvider = provider, PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }; + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + tempTable.Name.Should().Be("my temp `table`"); + provider.LeasedEntityTypes.Should().ContainSingle().Which.ClrType.Should().Be(); + GetTempTableStatements().Single().Should().StartWith("CREATE TEMPORARY TABLE `my temp ``table``` ("); + (await GetColumnsAsync(tempTable)).Should().HaveCount(2); + provider.DisposedLeases.Should().Be(0); + + await tempTable.DisposeAsync(); + + provider.DisposedLeases.Should().Be(1); + } + + [Fact] + public async Task Should_release_name_lease_if_create_fails() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(false, typeBuilder => typeBuilder.HasKey(t => new { t.Column1, t.Column2 })); + + var provider = new FixedTempTableNameProvider("lease_released"); + var options = new TempTableCreationOptions + { + TableNameProvider = provider, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.EntityTypeConfiguration, + PropertiesToInclude = IEntityPropertiesProvider.Include(t => t.Column1) + }; + + await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), options)) + .Should().ThrowAsync(); + + provider.DisposedLeases.Should().Be(1); + } + + [Fact] + public async Task Should_create_temp_table_with_reusable_name() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new TempTableCreationOptions { TableNameProvider = ReusingTempTableNameProvider.Instance, PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }; + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + tempTable.Name.Should().Be("#CustomTempTable_1"); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + } + + [Fact] + public async Task Should_reuse_name_after_it_is_freed() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new TempTableCreationOptions { TableNameProvider = ReusingTempTableNameProvider.Instance, PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }; + + string firstName; + + await using (var first = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options)) + { + firstName = first.Name; + } + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + tempTable.Name.Should().Be(firstName); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + } + + [Fact] + public async Task Should_reuse_name_after_it_is_freed_although_previously_not_dropped() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new TempTableCreationOptions + { + TableNameProvider = ReusingTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None, + DropTableOnDispose = false + }; + + string firstName; + + await using (var first = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options)) + { + firstName = first.Name; + } + + options.TruncateTableIfExists = true; + var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + try + { + tempTable.Name.Should().Be(firstName); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + } + finally + { + await DropAndReleaseAsync(tempTable); + await ActDbContext.Database.CloseConnectionAsync(); + } + } + + [Fact] + public async Task Should_not_reuse_name_before_it_is_freed() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new TempTableCreationOptions { TableNameProvider = ReusingTempTableNameProvider.Instance, PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }; + + await using (var first = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options)) + { + await using var second = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + first.Name.Should().Be("#CustomTempTable_1"); + second.Name.Should().Be("#CustomTempTable_2"); + (await GetColumnsAsync(second)).Should().HaveCount(2); + } + } + + [Fact] + public async Task Should_reuse_name_in_sorted_order() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + var options = new TempTableCreationOptions { TableNameProvider = ReusingTempTableNameProvider.Instance, PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }; + + string firstName; + + await using (var first = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options)) + { + firstName = first.Name; + + await using (await SUT.CreateTempTableAsync(GetTempTableEntityType(), options)) + { + } + } + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), options); + + tempTable.Name.Should().Be(firstName); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(2); + + ValidateColumn(columns[0], nameof(CustomTempTable.Column1), "int", false); + ValidateColumn(columns[1], nameof(CustomTempTable.Column2), "longtext", true); + } + + [Fact] + public async Task Should_open_connection() + { + await using var con = CreateConnection(); + + var options = CreateOptions(con); + + await using var ctx = new MySqlTestDbContext(options); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + await using var tempTable = await ctx.GetService() + .CreateTempTableAsync(MySqlTempTableInspector.GetTempTableEntityType(ctx), _options); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Open); + } + + [Fact] + public async Task Should_return_reference_to_be_able_to_close_connection_using_disposeAsync() + { + await using var con = CreateConnection(); + + var options = CreateOptions(con); + + await using var ctx = new MySqlTestDbContext(options); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + var tempTableReference = await ctx.GetService() + .CreateTempTableAsync(MySqlTempTableInspector.GetTempTableEntityType(ctx), _options); + await tempTableReference.DisposeAsync(); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_return_reference_to_be_able_to_close_connection_using_dispose() + { + await using var con = CreateConnection(); + + var options = CreateOptions(con); + + await using var ctx = new MySqlTestDbContext(options); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + var tempTableReference = await ctx.GetService() + .CreateTempTableAsync(MySqlTempTableInspector.GetTempTableEntityType(ctx), _options); + tempTableReference.Dispose(); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_return_reference_to_be_able_to_close_connection_even_if_ctx_is_disposed() + { + await using var con = CreateConnection(); + + var options = CreateOptions(con); + + ITempTableReference tempTableReference; + + await using (var ctx = new MySqlTestDbContext(options)) + { + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + tempTableReference = await ctx.GetService() + .CreateTempTableAsync(MySqlTempTableInspector.GetTempTableEntityType(ctx), _options); + } + + con.State.Should().Be(ConnectionState.Open); + await tempTableReference.DisposeAsync(); + con.State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_return_table_ref_that_does_nothing_after_connection_is_disposed() + { + await using var con = CreateConnection(); + + var options = CreateOptions(con); + + await using var ctx = new MySqlTestDbContext(options); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + var tempTableReference = await ctx.GetService() + .CreateTempTableAsync(MySqlTempTableInspector.GetTempTableEntityType(ctx), _options); + await con.DisposeAsync(); + + con.State.Should().Be(ConnectionState.Closed); + await tempTableReference.DisposeAsync(); + con.State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_return_table_ref_that_does_nothing_after_connection_is_closed() + { + await using var con = CreateConnection(); + + var options = CreateOptions(con); + + await using var ctx = new MySqlTestDbContext(options); + + ctx.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + var tempTableReference = await ctx.GetService() + .CreateTempTableAsync(MySqlTempTableInspector.GetTempTableEntityType(ctx), _options); + await con.CloseAsync(); + + await tempTableReference.DisposeAsync(); + con.State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_return_reference_to_remove_temp_table() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + await using var connection = await MySqlTempTableInspector.KeepConnectionOpenAsync(ActDbContext); + + var tempTableReference = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName); + await tempTableReference.DisposeAsync(); + + (await MySqlTempTableInspector.ExistsAsync(ActDbContext, tempTableReference.Name)).Should().BeFalse(); + } + + [Fact] + public async Task Should_throw_and_release_connection_and_name_when_cancellation_is_requested() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + using var cancellationTokenSource = new CancellationTokenSource(); + await cancellationTokenSource.CancelAsync(); + + await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), _options, cancellationTokenSource.Token)) + .Should().ThrowAsync(); + + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), _options); + tempTable.Name.Should().Be("#CustomTempTable_1"); + } + + [Fact] + public async Task Should_not_commit_the_transaction_of_the_caller_when_temp_table_is_created_truncated_and_dropped() + { + ConfigureModel = builder => builder.ConfigureTempTableEntity(); + + // CREATE TEMPORARY TABLE and DROP TEMPORARY TABLE don't end a transaction implicitly (as CREATE TABLE and DROP TABLE do). + var options = new TempTableCreationOptions + { + TableNameProvider = DefaultTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None, + TruncateTableIfExists = true + }; + await using var transaction = await ActDbContext.Database.BeginTransactionAsync(); + ActDbContext.TestEntities.Add(new TestEntity { Id = new Guid("577BFD36-21BC-4F9E-97B4-367B8F29B730"), RequiredName = "Real" }); + await ActDbContext.SaveChangesAsync(); + + await using (await SUT.CreateTempTableAsync(GetTempTableEntityType(), options)) + { + await using (await SUT.CreateTempTableAsync(GetTempTableEntityType(), options)) + { + } + } + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0, "the transaction of the caller is not committed yet"); + + await transaction.RollbackAsync(); + + (await AssertDbContext.TestEntities.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Should_throw_if_temp_table_entity_contains_inlined_owned_type() + { + await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName)) + .Should().ThrowAsync().WithMessage("Temp tables don't support owned entities."); + } + + [Fact] + public async Task Should_throw_if_temp_table_contains_inlined_owned_type() + { + _optionsWithNonUniqueName.PropertiesToInclude = IEntityPropertiesProvider.Include(e => new + { + e.Id, + e.InlineEntity + }); + await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName)) + .Should().ThrowAsync().WithMessage("The entity 'Thinktecture.TestDatabaseContext.TestEntity_Owns_Inline' must not contain owned entities."); + } + + [Fact] + public async Task Should_throw_if_temp_table_entity_contains_separated_owned_type() + { + var ownerEntityType = GetTempTableEntityType(); + await SUT.Awaiting(sut => sut.CreateTempTableAsync(ownerEntityType, _optionsWithNonUniqueName)) + .Should().ThrowAsync().WithMessage("Temp tables don't support owned entities."); + + // Other providers create a table for the owned type, MySQL rejects the owned type itself. + var ownedTypeEntityType = ownerEntityType.GetNavigations().Single().TargetEntityType; + await SUT.Awaiting(sut => sut.CreateTempTableAsync(ownedTypeEntityType, _optionsWithNonUniqueName)) + .Should().ThrowAsync().WithMessage("MySQL temporary tables do not support owned entities."); + + ActDbContext.Database.GetDbConnection().State.Should().Be(ConnectionState.Closed); + } + + [Fact] + public async Task Should_throw_when_selecting_separated_owned_type() + { + _optionsWithNonUniqueName.PropertiesToInclude = IEntityPropertiesProvider.Include(e => new + { + e.Id, + e.SeparateEntity + }); + await SUT.Awaiting(sut => sut.CreateTempTableAsync(GetTempTableEntityType(), _optionsWithNonUniqueName)) + .Should().ThrowAsync() + .WithMessage("The entity 'Thinktecture.TestDatabaseContext.TestEntity_Owns_SeparateOne' must not contain owned entities."); + } + + [Fact] + public async Task Should_throw_if_temp_table_entity_contains_many_owned_types() + { + var ownerEntityType = GetTempTableEntityType(); + await SUT.Awaiting(sut => sut.CreateTempTableAsync(ownerEntityType, _optionsWithNonUniqueName)) + .Should().ThrowAsync().WithMessage("Temp tables don't support owned entities."); + + var ownedTypeEntityType = ownerEntityType.GetNavigations().Single().TargetEntityType; + await SUT.Awaiting(sut => sut.CreateTempTableAsync(ownedTypeEntityType, _optionsWithNonUniqueName)) + .Should().ThrowAsync().WithMessage("MySQL temporary tables do not support owned entities."); + } + + [Fact] + public async Task Should_honor_collation() + { + var entityType = GetTempTableEntityType(); + await using var tempTable = await SUT.CreateTempTableAsync(entityType, _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(3); + + var databaseCollation = await ActDbContext.Database.SqlQueryRaw("SELECT @@collation_database AS `Value`").SingleAsync(); + + ValidateColumn(columns[0], nameof(TestEntityWithCollation.ColumnWithCollation), "varchar(100)", false, collation: "utf8mb4_bin"); + ValidateColumn(columns[1], nameof(TestEntityWithCollation.ColumnWithoutCollation), "varchar(100)", false, collation: databaseCollation); + ValidateColumn(columns[2], nameof(TestEntityWithCollation.Id), "char(36)", false); + } + + [Fact] + public async Task Should_join_with_real_table_on_column_with_collation() + { + // Comparing columns with different collations fails with error 1267, so the temp table has to use the collation of the model. + var id = new Guid("577BFD36-21BC-4F9E-97B4-367B8F29B730"); + ArrangeDbContext.TestEntitiesWithCollation.Add(new TestEntityWithCollation(id, "Value", "Value")); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType(), new TempTableCreationOptions()); + await ActDbContext.BulkInsertIntoTempTableAsync([new TestEntityWithCollation(new Guid("3A1B2FFF-8E11-44E5-80E5-8C7FEEDACEB3"), "value", "value"), + new TestEntityWithCollation(new Guid("C0A98E8F-2715-4764-A02E-033FF5278B9B"), "Value", "Value")], + tempTable); + + var temp = ActDbContext.TestEntitiesWithCollation.FromTempTable(new TempTableInfo(tempTable.Name)); + var matches = await temp.Join(ActDbContext.TestEntitiesWithCollation, t => t.ColumnWithCollation, r => r.ColumnWithCollation, (t, r) => new { TempId = t.Id, RealId = r.Id }) + .ToListAsync(); + + // utf8mb4_bin is case sensitive: only 'Value' matches + matches.Should().BeEquivalentTo([new { TempId = new Guid("C0A98E8F-2715-4764-A02E-033FF5278B9B"), RealId = id }]); + } + + [Fact] + public async Task Should_create_temp_table_for_entity_with_complex_type() + { + var testEntity = GetTempTableEntityType(); + + await using var tempTable = await SUT.CreateTempTableAsync(testEntity, _options); + + var columns = (await GetColumnsAsync(tempTable)).OrderBy(c => c.Name, StringComparer.Ordinal).ToList(); + columns.Should().HaveCount(3); + ValidateColumn(columns[0], $"{nameof(TestEntityWithComplexType.Boundary)}_{nameof(BoundaryValueObject.Lower)}", "int", false); + ValidateColumn(columns[1], $"{nameof(TestEntityWithComplexType.Boundary)}_{nameof(BoundaryValueObject.Upper)}", "int", false); + ValidateColumn(columns[2], nameof(TestEntityWithComplexType.Id), "char(36)", false); + } + + private IEntityType GetTempTableEntityType() + { + return MySqlTempTableInspector.GetTempTableEntityType(ActDbContext); + } + + // ExecutedCommands contains the statements of the test setup as well. + private IReadOnlyList GetTempTableStatements() + { + return ExecutedCommands.Where(command => command.Contains("TEMPORARY TABLE", StringComparison.Ordinal)).ToList(); + } + + private Task> GetColumnsAsync(ITempTableReference tempTable) + { + return MySqlTempTableInspector.GetColumnsAsync(ActDbContext, tempTable.Name); + } + + private async Task AssertColumnAsync(string type, bool isNullable) + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await SUT.CreateTempTableAsync(GetTempTableEntityType>(), _options); + + var columns = await GetColumnsAsync(tempTable); + columns.Should().HaveCount(1); + ValidateColumn(columns[0], nameof(TempTable.Column1), type, isNullable); + } + + // A reference with DropTableOnDispose = false keeps the table and the connection open. + private async Task DropAndReleaseAsync(ITempTableReference tempTable) + { + await ActDbContext.Database.ExecuteSqlRawAsync($"DROP TEMPORARY TABLE IF EXISTS `{tempTable.Name}`"); + await ActDbContext.Database.CloseConnectionAsync(); + } + + private DbConnection CreateConnection() + { + return new MySqlConnection(ConnectionString); + } + + private static DbContextOptions CreateOptions(DbConnection connection) + { + return new DbContextOptionsBuilder() + .UseMySql(connection, MySqlContainerFixture.ServerVersion, builder => builder.AddBulkOperationSupport()) + .Options; + } + + private static void ValidateColumn( + MySqlTempTableColumn column, + string name, + string type, + bool isNullable, + string? defaultValue = null, + string? collation = null) + { + ArgumentNullException.ThrowIfNull(column); + + column.Name.Should().Be(name); + column.Type.Should().Be(type); + column.IsNullable.Should().Be(isNullable); + + if (defaultValue is not null) + column.Default.Should().Be(defaultValue); + + if (collation is not null) + column.Collation.Should().Be(collation); + } + + private sealed class AutoIncrementTempTable + { + public int Id { get; set; } + public string? Name { get; set; } + } + + private sealed class EnumTempTable + { + public string? Status { get; set; } + } + + private sealed class FixedTempTableNameProvider(string name) : ITempTableNameProvider + { + private int _disposedLeases; + + public List LeasedEntityTypes { get; } = []; + public int DisposedLeases => _disposedLeases; + + public ITempTableNameLease LeaseName(DbContext ctx, IEntityType entityType) + { + LeasedEntityTypes.Add(entityType); + return new FixedTempTableNameLease(name, () => Interlocked.Increment(ref _disposedLeases)); + } + } + + private sealed class FixedTempTableNameLease(string name, Action onDispose) : ITempTableNameLease + { + public string Name { get; } = name; + + public void Dispose() + { + onDispose(); + } + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/ModelCharSetTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/ModelCharSetTests.cs new file mode 100644 index 0000000..3550258 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/ModelCharSetTests.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Thinktecture.EntityFrameworkCore.BulkOperations; + +namespace Thinktecture.EntityFrameworkCore.TempTables.MySqlTempTableCreatorTests; + +/// +/// A model-level character set must only be written for columns that can have one. MySQL rejects json CHARACTER SET .... +/// +[Collection(MySqlTestCollection.NAME)] +public sealed class ModelCharSetTests : MySqlIntegrationTestsBase +{ + public ModelCharSetTests(MySqlContainerFixture fixture) + : base(fixture) + { + ConfigureModel = modelBuilder => modelBuilder.HasCharSet("utf8mb4"); + } + + [Fact] + public async Task Should_update_entities_with_a_json_column_when_the_model_has_a_default_charset() + { + var id = Guid.NewGuid(); + ArrangeDbContext.TypedEntities.Add(new MySqlTypedEntity { Id = id, Payload = """{"a":1}""", Status = MySqlTestStatus.Draft, CreatedAt = new DateTime(2020, 1, 2) }); + await ArrangeDbContext.SaveChangesAsync(); + + var affectedRows = await ActDbContext.BulkUpdateAsync( + [new MySqlTypedEntity { Id = id, Payload = """{"a":2}""", Status = MySqlTestStatus.Published }], + entity => new { entity.Payload, entity.Status }, + entity => entity.Id); + + affectedRows.Should().Be(1); + var row = await AssertDbContext.TypedEntities.AsNoTracking().SingleAsync(); + row.Status.Should().Be(MySqlTestStatus.Published); + row.Payload.Should().Contain("2"); + } + + [Fact] + public async Task Should_create_a_temp_table_with_a_json_column_when_the_model_has_a_default_charset() + { + var id = Guid.NewGuid(); + var executor = ActDbContext.GetService(); + + await using var tempTable = await executor.BulkInsertIntoTempTableAsync( + [new MySqlTypedEntity { Id = id, Payload = """{"a":1}""", Status = MySqlTestStatus.Draft }], + executor.CreateOptions(), + CancellationToken.None); + + (await tempTable.Query.Select(entity => entity.Id).SingleAsync()).Should().Be(id); + } + + [Fact] + public async Task Should_write_the_charset_for_text_columns_only() + { + var executor = ActDbContext.GetService(); + + await using var tempTable = await executor.BulkInsertIntoTempTableAsync( + [new MySqlTypedEntity { Id = Guid.NewGuid(), Payload = "{}", Status = MySqlTestStatus.Published }], + executor.CreateOptions(), + CancellationToken.None); + + var createStatement = ExecutedCommands.Single(command => command.Contains("CREATE TEMPORARY TABLE", StringComparison.Ordinal)); + + // A value-converted enum is stored as varchar and still belongs to the model's character set. + createStatement.Should().MatchRegex(@"`Status` varchar\(\d+\) CHARACTER SET utf8mb4"); + createStatement.Should().NotMatchRegex(@"`Payload` json CHARACTER SET"); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/MySqlTempTableInspector.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/MySqlTempTableInspector.cs new file mode 100644 index 0000000..c99ba3e --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/MySqlTempTableInspector.cs @@ -0,0 +1,191 @@ +using System.Data.Common; +using System.Globalization; +using System.Text; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; +using MySqlConnector; +using Thinktecture.Internal; + +namespace Thinktecture.EntityFrameworkCore.TempTables.MySqlTempTableCreatorTests; + +/// +/// Reads the structure and the content of MySQL temporary tables. +/// MySQL has no INFORMATION_SCHEMA entry for temporary tables, and a temporary table is visible to the session that created it only. +/// Therefore all methods run on the connection of the provided context. As long as the temp table is alive, this connection is open. +/// +internal static class MySqlTempTableInspector +{ + /// + /// MySQL error "Table doesn't exist". + /// + public const int TABLE_DOES_NOT_EXIST = 1146; + + /// + /// Gets the entity type used to create a temp table of : the entity type registered via ConfigureTempTableEntity + /// or, if there is none, the regular entity type of . + /// + public static IEntityType GetTempTableEntityType(DbContext context) + { + return context.Model.GetEntityType(EntityNameProvider.GetTempTableName(typeof(T)), typeof(T)); + } + + /// + /// Opens the connection of the context until the returned object is disposed. + /// A closed connection is reset by the connection pool, which drops temp tables on its own. + /// Tests that observe the DROP of the library keep the connection open. + /// + public static async Task KeepConnectionOpenAsync(DbContext context) + { + return await ConnectionScope.OpenAsync(context); + } + + /// + /// Gets the columns of the table in the order of the definition. + /// + public static async Task> GetColumnsAsync(DbContext context, string tableName) + { + await using var scope = await ConnectionScope.OpenAsync(context); + await using var command = scope.CreateCommand($"SHOW FULL COLUMNS FROM {Quote(tableName)}"); + await using var reader = await command.ExecuteReaderAsync(); + var columns = new List(); + + while (await reader.ReadAsync()) + { + columns.Add(new MySqlTempTableColumn(ReadString(reader, "Field") ?? throw new InvalidOperationException("The column has no name."), + ReadString(reader, "Type") ?? throw new InvalidOperationException("The column has no type."), + ReadString(reader, "Collation"), + String.Equals(ReadString(reader, "Null"), "YES", StringComparison.Ordinal), + ReadString(reader, "Key"), + ReadString(reader, "Default"), + ReadString(reader, "Extra"))); + } + + return columns; + } + + /// + /// Gets the names of the columns the primary key consists of in the order of the key. + /// + public static async Task> GetPrimaryKeyColumnsAsync(DbContext context, string tableName) + { + await using var scope = await ConnectionScope.OpenAsync(context); + await using var command = scope.CreateCommand($"SHOW KEYS FROM {Quote(tableName)} WHERE Key_name = 'PRIMARY'"); + await using var reader = await command.ExecuteReaderAsync(); + var columns = new List<(int Position, string Name)>(); + + while (await reader.ReadAsync()) + { + columns.Add((Convert.ToInt32(reader["Seq_in_index"], CultureInfo.InvariantCulture), ReadString(reader, "Column_name") ?? throw new InvalidOperationException("The key column has no name."))); + } + + return columns.OrderBy(column => column.Position).Select(column => column.Name).ToList(); + } + + /// + /// Gets the CREATE TABLE statement of the server. + /// + public static async Task ShowCreateTableAsync(DbContext context, string tableName) + { + await using var scope = await ConnectionScope.OpenAsync(context); + await using var command = scope.CreateCommand($"SHOW CREATE TABLE {Quote(tableName)}"); + await using var reader = await command.ExecuteReaderAsync(); + await reader.ReadAsync(); + + return ReadString(reader, "Create Table") ?? throw new InvalidOperationException("The server returned no statement."); + } + + /// + /// Gets the number of rows of the table. + /// + public static async Task CountRowsAsync(DbContext context, string tableName) + { + await using var scope = await ConnectionScope.OpenAsync(context); + await using var command = scope.CreateCommand($"SELECT COUNT(*) FROM {Quote(tableName)}"); + + return Convert.ToInt32(await command.ExecuteScalarAsync(), CultureInfo.InvariantCulture); + } + + /// + /// Indicates whether the table exists for the session of the connection of the context. + /// + public static async Task ExistsAsync(DbContext context, string tableName) + { + await using var scope = await ConnectionScope.OpenAsync(context); + await using var command = scope.CreateCommand($"SELECT 1 FROM {Quote(tableName)} LIMIT 1"); + + try + { + await command.ExecuteScalarAsync(); + return true; + } + catch (MySqlException ex) when (ex.Number == TABLE_DOES_NOT_EXIST) + { + return false; + } + } + + private static string Quote(string identifier) + { + return $"`{identifier.Replace("`", "``", StringComparison.Ordinal)}`"; + } + + // A column is returned as text or, depending on the server version, as binary text. + private static string? ReadString(DbDataReader reader, string column) + { + return reader[column] switch + { + DBNull => null, + byte[] bytes => Encoding.UTF8.GetString(bytes), + var value => Convert.ToString(value, CultureInfo.InvariantCulture) + }; + } + + // Keeps the connection open while a command runs. If it was open already (for example because of a temp table), it stays open. + private sealed class ConnectionScope : IAsyncDisposable + { + private readonly DbContext _context; + + private ConnectionScope(DbContext context) + { + _context = context; + } + + public static async Task OpenAsync(DbContext context) + { + await context.Database.OpenConnectionAsync(); + return new ConnectionScope(context); + } + + public DbCommand CreateCommand(string commandText) + { + var command = _context.Database.GetDbConnection().CreateCommand(); + command.CommandText = commandText; + command.Transaction = _context.Database.CurrentTransaction?.GetDbTransaction(); + return command; + } + + public async ValueTask DisposeAsync() + { + await _context.Database.CloseConnectionAsync(); + } + } +} + +/// +/// A column as reported by SHOW FULL COLUMNS. +/// +/// Name of the column. +/// Data type including length, precision and unsigned flag, e.g. varchar(50). +/// Collation of text columns, otherwise null. +/// Indication whether the column accepts NULL. +/// Key flag, e.g. PRI. +/// The default value, if there is one. +/// Additional information like auto_increment or DEFAULT_GENERATED. +internal sealed record MySqlTempTableColumn( + string Name, + string Type, + string? Collation, + bool IsNullable, + string? Key, + string? Default, + string? Extra); diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/PrimitiveValueTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/PrimitiveValueTests.cs new file mode 100644 index 0000000..7c78a4b --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/PrimitiveValueTests.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Thinktecture.EntityFrameworkCore.BulkOperations; +using Thinktecture.EntityFrameworkCore.TempTables; + +namespace Thinktecture.EntityFrameworkCore.TempTables.MySqlTempTableCreatorTests; + +[Collection(MySqlTestCollection.NAME)] +public sealed class PrimitiveValueTests : MySqlIntegrationTestsBase +{ + public PrimitiveValueTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_guids_into_a_temp_table() + { + var first = Guid.NewGuid(); + var second = Guid.NewGuid(); + var executor = ActDbContext.GetService(); + + await using var tempTable = await executor.BulkInsertValuesIntoTempTableAsync( + [first, second], + executor.CreateOptions(), + CancellationToken.None); + + (await tempTable.Query.ToArrayAsync()).Should().BeEquivalentTo([first, second]); + } + + [Fact] + public async Task Should_insert_precise_decimals_into_a_temp_table() + { + var executor = ActDbContext.GetService(); + + await using var tempTable = await executor.BulkInsertValuesIntoTempTableAsync( + [12345678901234567.89m, 1.25m], + executor.CreateOptions(), + CancellationToken.None); + + (await tempTable.Query.OrderBy(value => value).ToArrayAsync()).Should().Equal(1.25m, 12345678901234567.89m); + } + + [Fact] + public async Task Should_insert_date_times_into_a_temp_table() + { + var value = new DateTime(2020, 1, 2, 3, 4, 5).AddTicks(1234560); + var executor = ActDbContext.GetService(); + + await using var tempTable = await executor.BulkInsertValuesIntoTempTableAsync( + [value], + executor.CreateOptions(), + CancellationToken.None); + + (await tempTable.Query.SingleAsync()).Should().Be(value); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/TempTableQueryTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/TempTableQueryTests.cs new file mode 100644 index 0000000..b378cf8 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/EntityFrameworkCore/TempTables/MySqlTempTableCreatorTests/TempTableQueryTests.cs @@ -0,0 +1,38 @@ +using Thinktecture.Internal; + +namespace Thinktecture.EntityFrameworkCore.TempTables.MySqlTempTableCreatorTests; + +public sealed class TempTableQueryTests +{ + [Fact] + public void Should_translate_from_temp_table_name() + { + using var context = CreateContext(); + var sql = context.Entities.FromTempTable(new TempTableInfo("my_temp_table")).ToQueryString(); + + sql.Should().Contain("`my_temp_table`"); + sql.Should().NotContain("`Entities` AS"); + } + + [Fact] + public void Should_translate_subquery_after_temp_table_reference() + { + using var context = CreateContext(); + var sql = context.Entities.FromTempTable(new TempTableInfo("my_temp_table")) + .AsSubQuery() + .Where(entity => entity.Id > 1) + .ToQueryString(); + + sql.Should().Contain("`my_temp_table`"); + sql.Should().Contain("WHERE"); + } + + private static MySqlTestDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseMySql("Server=localhost;Database=test", MySqlContainerFixture.ServerVersion, builder => builder.AddBulkOperationSupport()) + .Options; + + return new MySqlTestDbContext(options); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/BulkInsertValuesIntoTempTableAsync_1_Column.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/BulkInsertValuesIntoTempTableAsync_1_Column.cs new file mode 100644 index 0000000..00c4349 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/BulkInsertValuesIntoTempTableAsync_1_Column.cs @@ -0,0 +1,1061 @@ +using System.Text; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore; +using Thinktecture.EntityFrameworkCore.BulkOperations; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.EntityFrameworkCore.TempTables.MySqlTempTableCreatorTests; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.DbContextExtensionsTests; + +// The rows are written with raw ADO commands (multi-row INSERT or MySqlBulkCopy), so the INSERT statements are not visible in ExecutedCommands. +// The data type tests run with both write paths (multi-row INSERT and MySqlBulkCopy), because MySqlBulkCopy converts the values on its own. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class BulkInsertValuesIntoTempTableAsync_1_Column : MySqlIntegrationTestsBase +{ + public BulkInsertValuesIntoTempTableAsync_1_Column(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_insert_int() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var values = new List { 1, 2 }; + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values); + + var tempTable = await query.Query.ToListAsync(); + tempTable.Should().BeEquivalentTo([1, 2]); + } + + [Fact] + public async Task Should_insert_int_into_existing_into_empty_temp_table() + { + // Arrange + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List()); + + // Act + await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { 3, 4 }, query); + + // Assert + var tempTable = await query.Query.ToListAsync(); + tempTable.Should().BeEquivalentTo([3, 4]); + } + + [Fact] + public async Task Should_insert_int_into_existing_into_non_empty_temp_table() + { + // Arrange + ConfigureModel = builder => builder.ConfigureTempTable(); + + var values = new List { 1, 2 }; + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values); + + // Act + await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { 3, 4 }, query); + + // Assert + var tempTable = await query.Query.ToListAsync(); + tempTable.Should().BeEquivalentTo([1, 2, 3, 4]); + } + + [Fact] + public async Task Should_throw_if_inserting_duplicates() + { + ConfigureModel = builder => builder.ConfigureTempTable(false); + + var values = new List { 1, 1 }; + + // 1062: duplicate entry for the primary key + var exception = await ActDbContext.Awaiting(ctx => ctx.BulkInsertValuesIntoTempTableAsync(values)) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1062); + } + + [Fact] + public async Task Should_insert_nullable_int_of_a_keyless_entity() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var values = new List { 1, null }; + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values, new MySqlTempTableBulkInsertOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }); + + var tempTable = await query.Query.ToListAsync(); + tempTable.Should().BeEquivalentTo([1, (int?)null]); + } + + [Fact] + public async Task Should_insert_nullable_int_of_entity_with_a_key() + { + ConfigureModel = builder => builder.ConfigureTempTable(false, typeBuilder => + { + typeBuilder.HasKey(t => t.Column1); + typeBuilder.Property(t => t.Column1).ValueGeneratedNever(); + }); + + var values = new List { 1, 2 }; + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values, new MySqlTempTableBulkInsertOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }); + + var tempTable = await query.Query.ToListAsync(); + tempTable.Should().BeEquivalentTo([1, 2]); + } + + [Fact] + public async Task Should_insert_string() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1).IsRequired(false)); + + var values = new List { "value1", null }; + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values, new MySqlTempTableBulkInsertOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }); + + var tempTable = await query.Query.ToListAsync(); + tempTable.Should().BeEquivalentTo(new[] { "value1", null }); + } + + [Fact] + public async Task Should_create_pk_by_default_on_string_column() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1).HasMaxLength(100).IsRequired()); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { "value" }, new MySqlTempTableBulkInsertOptions + { + TableNameProvider = DefaultTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced + }); + + var keys = await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name); + keys.Should().Equal(nameof(TempTable.Column1)); + } + + [Fact] + public async Task Should_throw_when_trying_to_create_pk_on_nullable_column() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + // 1171: all parts of a PRIMARY KEY must be NOT NULL + var exception = await ActDbContext.Awaiting(ctx => ctx.BulkInsertValuesIntoTempTableAsync(new List { 1 }, new MySqlTempTableBulkInsertOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced })) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1171); + } + + [Fact] + public async Task Should_throw_when_trying_to_create_pk_on_string_column_without_max_length() + { + ConfigureModel = builder => builder.ConfigureTempTable(typeBuilder => typeBuilder.Property(t => t.Column1).IsRequired()); + + // 1170: BLOB/TEXT column used in key specification without a key length + var exception = await ActDbContext.Awaiting(ctx => ctx.BulkInsertValuesIntoTempTableAsync(new List { "value" }, new MySqlTempTableBulkInsertOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced })) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1170); + } + + [Fact] + public async Task Should_create_pk_by_default() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { 1 }, new MySqlTempTableBulkInsertOptions + { + TableNameProvider = DefaultTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.AdaptiveForced + }); + + var keys = await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name); + keys.Should().Equal(nameof(TempTable.Column1)); + } + + [Fact] + public async Task Should_not_create_pk_if_entity_is_keyless_and_provider_is_AccordingToEntityTypeConfiguration() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { 1 }, new MySqlTempTableBulkInsertOptions + { + TableNameProvider = DefaultTempTableNameProvider.Instance, + PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.EntityTypeConfiguration + }); + + var keys = await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name); + keys.Should().BeEmpty(); + } + + [Fact] + public async Task Should_not_create_pk_if_specified_in_options() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { 1 }, new MySqlTempTableBulkInsertOptions { PrimaryKeyCreation = IPrimaryKeyPropertiesProvider.None }); + + var keys = await MySqlTempTableInspector.GetPrimaryKeyColumnsAsync(ActDbContext, tempTable.Name); + keys.Should().BeEmpty(); + } + + [Fact] + public async Task Should_work_with_projection_using_SplitQuery_behavior() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var parentId = new Guid("6EB48130-454B-46BC-9FEE-CDF411F69807"); + var childId = new Guid("17A2014C-F7B2-40E8-82F4-42E754DCAA2D"); + ArrangeDbContext.TestEntities.Add(new TestEntity + { + Id = parentId, + RequiredName = "RequiredName", + Children = { new() { Id = childId, RequiredName = "RequiredName" } } + }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { parentId }); + + var query = await ActDbContext.TestEntities + .AsSplitQuery() + .Where(c => tempTable.Query.Any(id => c.Id == id)) + .Select(c => new + { + c.Id, + ChildrenIds = c.Children.Select(o => o.Id).ToList() + }) + .ToListAsync(); + + query.Should().BeEquivalentTo(new[] + { + new + { + Id = parentId, + ChildrenIds = new List { childId } + } + }); + } + + [Fact] + public async Task Should_join_with_itself() + { + // The query is translated as for other databases. MySQL rejects it on execution, see Should_fail_with_error_1137_when_joining_temp_table_with_itself. + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { Guid.Empty }); + + var joinQuery = tempTable.Query.LeftJoin(tempTable.Query, e => e, e => e, (e1, e2) => new { Left = e1, Right = e2 }); + + joinQuery.ToQueryString().Should().Be(""" + SELECT `#`.`Column1` AS `Left`, `#0`.`Column1` AS `Right` + FROM `#TempTable_1` AS `#` + LEFT JOIN `#TempTable_1` AS `#0` ON `#`.`Column1` = `#0`.`Column1` + """.WithEnvironmentLineBreaks()); + } + + [Fact] + public async Task Should_fail_with_error_1137_when_joining_temp_table_with_itself() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { Guid.Empty }); + + // 1137: MySQL can't reopen a temporary table within one statement + var exception = await tempTable.Query.LeftJoin(tempTable.Query, e => e, e => e, (e1, e2) => new { Left = e1, Right = e2 }) + .Awaiting(q => q.ToListAsync()) + .Should().ThrowAsync(); + exception.Which.Number.Should().Be(1137); + } + + [Fact] + public async Task Should_join_two_temp_tables_with_same_values() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + // The workaround for error 1137: every reference needs its own temp table. + await using var first = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { 1, 2, 3 }); + await using var second = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { 2, 3, 4 }); + + first.Name.Should().NotBe(second.Name); + + var joined = await first.Query.Join(second.Query, e => e, e => e, (e1, e2) => e1).OrderBy(e => e).ToListAsync(); + joined.Should().Equal(2, 3); + } + + [Fact] + public async Task Should_filter_real_table_with_values_of_temp_table() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var firstId = new Guid("6EB48130-454B-46BC-9FEE-CDF411F69807"); + var secondId = new Guid("17A2014C-F7B2-40E8-82F4-42E754DCAA2D"); + ArrangeDbContext.TestEntities.AddRange(new TestEntity { Id = firstId, RequiredName = "First" }, + new TestEntity { Id = secondId, RequiredName = "Second" }); + await ArrangeDbContext.SaveChangesAsync(); + + await using var tempTable = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { secondId, Guid.NewGuid() }); + + var names = await ActDbContext.TestEntities + .Where(e => tempTable.Query.Contains(e.Id)) + .Select(e => e.RequiredName) + .ToListAsync(); + + names.Should().Equal("Second"); + } + + [Fact] + public async Task Should_return_number_of_inserted_rows() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var values = new List { 1, 2 }; + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values); + + query.NumberOfInsertedRows.Should().Be(2); + } + + // The tests below cover behavior specific to MySQL: write paths (multi-row INSERT and MySqlBulkCopy) and the value ranges of the MySQL column types. + + [Fact] + public async Task Should_insert_empty_values() + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List()); + + query.NumberOfInsertedRows.Should().Be(0); + (await query.Query.ToListAsync()).Should().BeEmpty(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_many_values(bool useBulkCopy) + { + var values = Enumerable.Range(0, 5001).ToList(); + + await AssertRoundTripAsync(useBulkCopy, values); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(5)] + [InlineData(100)] + public async Task Should_insert_values_with_batch_size(int batchSize) + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var values = new List { 1, 2, 3, 4, 5 }; + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values, new MySqlTempTableBulkInsertOptions { BatchSize = batchSize }); + + query.NumberOfInsertedRows.Should().Be(5); + (await query.Query.OrderBy(v => v).ToListAsync()).Should().Equal(1, 2, 3, 4, 5); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_insert_values_into_existing_temp_table(bool useBulkCopy) + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var options = CreateOptions(useBulkCopy); + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { 1, 2 }, options); + + await ActDbContext.BulkInsertValuesIntoTempTableAsync(new List { 3, 4 }, query, options); + + (await query.Query.OrderBy(v => v).ToListAsync()).Should().Equal(1, 2, 3, 4); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_int(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new[] { Int32.MinValue, -1, 0, 1, Int32.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_int(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new int?[] { Int32.MinValue, null, 0, null, Int32.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_long(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new[] { Int64.MinValue, -1L, 0L, 1L, Int64.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_long(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new long?[] { Int64.MinValue, null, Int64.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_short(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new[] { Int16.MinValue, (short)-1, (short)0, (short)1, Int16.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_short(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new short?[] { Int16.MinValue, null, Int16.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_byte(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new byte[] { Byte.MinValue, 1, 127, 128, Byte.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_byte(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new byte?[] { Byte.MinValue, null, Byte.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_bool(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new[] { true, false, true }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_bool(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new bool?[] { true, null, false }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_string(bool useBulkCopy) + { + string[] values = + [ + "", + "value", + " padded ", + "O'Brien", + "say \"hi\"", + "back\\slash", + "trailing backslash\\", + "\\N", + "NULL", + "line1\nline2", + "line1\r\nline2", + "tab\tseparated", + "null\0character", + "`backtick`", + "100%_", + "'; DROP TABLE TestEntities; --", + "umlauts \u00E4\u00F6\u00FC\u00DF, \u65E5\u672C\u8A9E, emoji \U0001F600" + ]; + + await AssertRoundTripAsync(useBulkCopy, values); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_string_longer_than_64_kilobytes(bool useBulkCopy) + { + // longtext + var value = String.Concat(Enumerable.Repeat("0123456789 \u00E4\u00F6\u00FC ", 8000)); + value.Length.Should().BeGreaterThan(64 * 1024); + + await AssertRoundTripAsync(useBulkCopy, new[] { value }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_string(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new string?[] { "value", null, "", null }, + typeBuilder => typeBuilder.Property(t => t.Column1).IsRequired(false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_string_with_max_length(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] { "", "short", new string('x', 50) }, + typeBuilder => typeBuilder.Property(t => t.Column1).HasMaxLength(50)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_throw_for_string_longer_than_max_length(bool useBulkCopy) + { + // 1406: data too long for the column (strict mode) + await AssertInsertFailsAsync(useBulkCopy, + new[] { new string('x', 51) }, + 1406, + typeBuilder => typeBuilder.Property(t => t.Column1).HasMaxLength(50)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_guid(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] + { + Guid.Empty, + new Guid("577BFD36-21BC-4F9E-97B4-367B8F29B730"), + new Guid("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF") + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_guid(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new Guid?[] { new Guid("577BFD36-21BC-4F9E-97B4-367B8F29B730"), null, Guid.Empty }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_decimal(bool useBulkCopy) + { + // decimal(38,18) + await AssertRoundTripAsync(useBulkCopy, + new[] + { + 0m, + 1.25m, + -1.25m, + 0.000000000000000001m, + 12345678901234567.89m, + 12345678901234567890.123456789012345678m, + -12345678901234567890.123456789012345678m + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_decimal(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new decimal?[] { 1.25m, null, -0.5m }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_decimal_with_explicit_precision(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] { 0m, 1.25m, -999999999999999.99999m }, + typeBuilder => typeBuilder.Property(t => t.Column1).HasPrecision(20, 5)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_throw_for_decimal_beyond_range_of_the_column(bool useBulkCopy) + { + // decimal(38,18) has 20 integer digits only, decimal.MaxValue has 29. 1264: out of range value for column + await AssertInsertFailsAsync(useBulkCopy, new[] { Decimal.MaxValue }, 1264); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_double(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] + { + 0d, + 1.5, + -1.5, + 0.1, + 1d / 3, + Math.PI, + 1.7976931348623157E+308, + -1.7976931348623157E+308, + 2.2250738585072014E-308 + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_double(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new double?[] { 1.5, null, -0.25 }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_float(bool useBulkCopy) + { + // MySQL returns a FLOAT with 6 significant digits, so these values have at most 6 digits. + await AssertRoundTripAsync(useBulkCopy, + new[] + { + 0f, + 1.5f, + -1.5f, + 0.1f, + 123456f, + 1E+38f, + -1E+38f, + 1.17549E-38f + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_return_float_with_six_significant_digits(bool useBulkCopy) + { + // MySQL sends a FLOAT as text with 6 significant digits, i.e. the value is stored precisely but read with a lower precision. + ConfigureModel = builder => builder.ConfigureTempTable(); + + var options = CreateOptions(useBulkCopy); + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { 1f / 3, MathF.PI }, options); + + (await query.Query.ToListAsync()).Should().BeEquivalentTo([0.333333f, 3.14159f]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_float(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new float?[] { 1.5f, null, -0.25f }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_throw_for_float_max_value(bool useBulkCopy) + { + // The text of float.MaxValue (3.4028235E+38) is slightly above the maximum of the MySQL type FLOAT. 1264: out of range value for column + await AssertInsertFailsAsync(useBulkCopy, new[] { Single.MaxValue }, 1264); + } + + [Theory] + [InlineData(false, Double.NaN)] + [InlineData(false, Double.PositiveInfinity)] + [InlineData(false, Double.NegativeInfinity)] + [InlineData(true, Double.NaN)] + [InlineData(true, Double.PositiveInfinity)] + [InlineData(true, Double.NegativeInfinity)] + public async Task Should_throw_for_not_a_number_and_infinity(bool useBulkCopy, double value) + { + // MySQL cannot store NaN or Infinity. The driver writes them as identifiers. 1054: unknown column + await AssertInsertFailsAsync(useBulkCopy, new[] { value }, 1054); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_double_epsilon(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new[] { Double.Epsilon, -Double.Epsilon }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_date_time(bool useBulkCopy) + { + // datetime(6): microseconds are kept, the supported range is 1000-01-01 to 9999-12-31 + await AssertRoundTripAsync(useBulkCopy, + new[] + { + new DateTime(2020, 1, 2, 3, 4, 5), + new DateTime(2020, 1, 2, 3, 4, 5).AddTicks(1234560), + new DateTime(2020, 1, 2, 3, 4, 5).AddTicks(10), + new DateTime(1000, 1, 1), + new DateTime(9999, 12, 31, 23, 59, 59) + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_date_time(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new DateTime?[] { new DateTime(2020, 1, 2, 3, 4, 5), null, new DateTime(2021, 12, 31, 23, 59, 59, 999) }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_truncate_date_time_to_microseconds(bool useBulkCopy) + { + // datetime(6) has a precision of 1 microsecond (10 ticks); the remainder is truncated, not rounded. + ConfigureModel = builder => builder.ConfigureTempTable(); + + var options = CreateOptions(useBulkCopy); + var values = new[] + { + new DateTime(2020, 1, 2, 3, 4, 5).AddTicks(1234567), + new DateTime(2020, 1, 2, 3, 4, 6).AddTicks(9), + DateTime.MaxValue + }; + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values, options); + + (await query.Query.ToListAsync()).Should().BeEquivalentTo([ + new DateTime(2020, 1, 2, 3, 4, 5).AddTicks(1234560), + new DateTime(2020, 1, 2, 3, 4, 6), + DateTime.MaxValue.AddTicks(-9) + ]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_date_time_min_value_although_it_is_outside_of_the_documented_range(bool useBulkCopy) + { + // The documented range of DATETIME starts at 1000-01-01, but MySQL accepts the earlier dates. + await AssertRoundTripAsync(useBulkCopy, new[] { DateTime.MinValue, new DateTime(1, 1, 2) }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_date_time_offset(bool useBulkCopy) + { + // datetime(6) has no offset, i.e. the value is stored as UTC. + var values = new[] + { + new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.Zero), + new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.Zero).AddTicks(1234560) + }; + + await AssertRoundTripAsync(useBulkCopy, values); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_store_date_time_offset_with_offset_as_utc(bool useBulkCopy) + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var options = CreateOptions(useBulkCopy); + var value = new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.FromHours(2)); + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { value }, options); + + var loaded = await query.Query.SingleAsync(); + loaded.UtcDateTime.Should().Be(value.UtcDateTime); + loaded.Offset.Should().Be(TimeSpan.Zero); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_date_time_offset(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new DateTimeOffset?[] { new DateTimeOffset(2020, 1, 2, 3, 4, 5, TimeSpan.Zero), null }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_time_span(bool useBulkCopy) + { + // time(6): from -838:59:59 to 838:59:59, i.e. a TimeSpan can exceed 24 hours + await AssertRoundTripAsync(useBulkCopy, + new[] + { + TimeSpan.Zero, + new TimeSpan(1, 2, 3), + new TimeSpan(1, 2, 3, 4, 5), + TimeSpan.FromHours(24), + TimeSpan.FromHours(100), + TimeSpan.FromHours(-100), + TimeSpan.FromTicks(10), + new TimeSpan(838, 59, 59), + new TimeSpan(-838, -59, -59) + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_truncate_time_span_to_microseconds(bool useBulkCopy) + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var options = CreateOptions(useBulkCopy); + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { TimeSpan.FromTicks(7), TimeSpan.FromTicks(19) }, options); + + (await query.Query.ToListAsync()).Should().BeEquivalentTo([TimeSpan.Zero, TimeSpan.FromTicks(10)]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_throw_for_time_span_beyond_range_of_the_column(bool useBulkCopy) + { + // time(6) ends at 838:59:59. 1525: incorrect TIME value + await AssertInsertFailsAsync(useBulkCopy, new[] { TimeSpan.FromHours(839) }, 1525); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_throw_for_time_span_below_range_of_the_column(bool useBulkCopy) + { + await AssertInsertFailsAsync(useBulkCopy, new[] { TimeSpan.FromHours(-839) }, 1525); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_time_span(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new TimeSpan?[] { TimeSpan.FromHours(25), null, TimeSpan.FromMinutes(-5) }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_date_only(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] + { + new DateOnly(2020, 1, 2), + new DateOnly(1000, 1, 1), + new DateOnly(9999, 12, 31) + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_date_only_min_and_max_value(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new[] { DateOnly.MinValue, DateOnly.MaxValue }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_date_only(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new DateOnly?[] { new DateOnly(2020, 1, 2), null }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_time_only(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] + { + TimeOnly.MinValue, + new TimeOnly(13, 14, 15), + new TimeOnly(1, 2, 3, 456), + new TimeOnly(23, 59, 59) + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_truncate_time_only_to_microseconds(bool useBulkCopy) + { + ConfigureModel = builder => builder.ConfigureTempTable(); + + var options = CreateOptions(useBulkCopy); + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(new[] { TimeOnly.MaxValue }, options); + + (await query.Query.ToListAsync()).Should().BeEquivalentTo([new TimeOnly(TimeOnly.MaxValue.Ticks - 9)]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_time_only(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new TimeOnly?[] { new TimeOnly(13, 14, 15), null }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_byte_array(bool useBulkCopy) + { + var largeValue = new byte[100_000]; + + for (var i = 0; i < largeValue.Length; i++) + { + largeValue[i] = (byte)(i * 31 % 256); + } + + await AssertRoundTripAsync(useBulkCopy, + new byte[][] + { + [], + [0], + [0, 1, 2, 254, 255], + Encoding.UTF8.GetBytes("O'Brien \\N \"quoted\" \r\n"), + largeValue + }, + typeBuilder => typeBuilder.Property(t => t.Column1).IsRequired()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_byte_array(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new byte[]?[] { [1, 2, 3], null, [] }, + typeBuilder => typeBuilder.Property(t => t.Column1).IsRequired(false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_enum(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new[] { PhaseMembership.None, PhaseMembership.A, PhaseMembership.A | PhaseMembership.C, PhaseMembership.A | PhaseMembership.B | PhaseMembership.C }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_enum_with_string_conversion(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] { PhaseMembership.None, PhaseMembership.A, PhaseMembership.B }, + typeBuilder => typeBuilder.Property(t => t.Column1).HasConversion().HasMaxLength(50)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_enum(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, new PhaseMembership?[] { PhaseMembership.A, null, PhaseMembership.C }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_value_with_value_converter(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] { new ConvertibleClass(1), new ConvertibleClass(Int32.MaxValue), new ConvertibleClass(-5) }, + typeBuilder => typeBuilder.Property(t => t.Column1).HasConversion(c => c.Key, k => new ConvertibleClass(k))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_nullable_value_with_value_converter(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new ConvertibleClass?[] { new ConvertibleClass(1), null }, + typeBuilder => typeBuilder.Property(t => t.Column1).HasConversion(c => c!.Key, k => new ConvertibleClass(k)).IsRequired(false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Should_round_trip_value_with_string_value_converter(bool useBulkCopy) + { + await AssertRoundTripAsync(useBulkCopy, + new[] { new ConvertibleClass(1), new ConvertibleClass(42) }, + typeBuilder => typeBuilder.Property(t => t.Column1).HasConversion(c => c.Key.ToString(), k => new ConvertibleClass(Int32.Parse(k))).HasMaxLength(20)); + } + + // MySqlBulkCopy uses LOAD DATA LOCAL INFILE, which the connection has to allow explicitly (the server is started with --local-infile=1). + // This must happen before the first use of the connection of ActDbContext. + private MySqlTempTableBulkInsertOptions CreateOptions(bool useBulkCopy) + { + if (useBulkCopy) + { + var connectionString = new MySqlConnectionStringBuilder(ConnectionString) { AllowLoadLocalInfile = true }; + ActDbContext.Database.SetConnectionString(connectionString.ConnectionString); + } + + return new MySqlTempTableBulkInsertOptions { UseBulkCopy = useBulkCopy }; + } + + private async Task AssertInsertFailsAsync( + bool useBulkCopy, + IReadOnlyList values, + int mySqlErrorNumber, + Action>>? configure = null) + { + ConfigureModel = builder => builder.ConfigureTempTable(configure); + + var options = CreateOptions(useBulkCopy); + var act = () => ActDbContext.BulkInsertValuesIntoTempTableAsync(values, options); + + if (useBulkCopy) + { + // LOAD DATA LOCAL implies IGNORE, i.e. MySQL clips or truncates the value and reports a warning, which the executor turns into an exception. + await act.Should().ThrowAsync().WithMessage("MySqlBulkCopy inserted*"); + } + else + { + var exception = await act.Should().ThrowAsync(); + exception.Which.Number.Should().Be(mySqlErrorNumber); + } + + // The temp table is dropped and the connection is released if the insert fails. + ActDbContext.Database.GetDbConnection().State.Should().Be(System.Data.ConnectionState.Closed); + } + + private async Task AssertRoundTripAsync( + bool useBulkCopy, + IReadOnlyList values, + Action>>? configure = null) + { + ConfigureModel = builder => builder.ConfigureTempTable(configure); + + var options = CreateOptions(useBulkCopy); + + await using var query = await ActDbContext.BulkInsertValuesIntoTempTableAsync(values, options); + + query.NumberOfInsertedRows.Should().Be(values.Count); + + var loadedValues = await query.Query.ToListAsync(); + loadedValues.Should().BeEquivalentTo(values); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CollectionParameterJsonSerializerOptions.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CollectionParameterJsonSerializerOptions.cs new file mode 100644 index 0000000..35dce0e --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CollectionParameterJsonSerializerOptions.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using Thinktecture.Json; + +namespace Thinktecture.Extensions.DbContextExtensionsTests; + +// EF Core creates a service provider per distinct set of options and compares JsonSerializerOptions by reference. +// The collection parameter tests therefore share these instances instead of creating their own, otherwise the test run +// exceeds the limit of 20 EF service providers (ManyServiceProvidersCreatedWarning is an error by default). +internal static class CollectionParameterJsonSerializerOptions +{ + public static readonly JsonSerializerOptions Default = Create(null); + public static readonly JsonSerializerOptions CamelCase = Create(JsonNamingPolicy.CamelCase); + public static readonly JsonSerializerOptions SnakeCaseLower = Create(JsonNamingPolicy.SnakeCaseLower); + + private static JsonSerializerOptions Create(JsonNamingPolicy? namingPolicy) + { + return new JsonSerializerOptions { Converters = { new ConvertibleClassConverter() }, PropertyNamingPolicy = namingPolicy }; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateComplexCollectionParameter.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateComplexCollectionParameter.cs new file mode 100644 index 0000000..6290783 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateComplexCollectionParameter.cs @@ -0,0 +1,314 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.DbContextExtensionsTests; + +// The objects are sent as one JSON array and read with JSON_TABLE; one JSON_TABLE column per property, addressed by its JSON property name +// (attribute [JsonPropertyName] first, then the naming policy of the JsonSerializerOptions, then the property name). +[Collection(MySqlTestCollection.NAME)] +public sealed class CreateComplexCollectionParameter : MySqlIntegrationTestsBase +{ + private static readonly Guid _TEST_ENTITY_ID = new("7F8B0E79-2C91-4682-9F61-6FC86B4E5244"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("D3E99F44-40A1-4E4E-820F-9D7C7B02AFA5"); + + public CreateComplexCollectionParameter(MySqlContainerFixture fixture) + : base(fixture) + { + ConfigureMySql = builder => builder.AddCollectionParameterSupport(CollectionParameterJsonSerializerOptions.Default); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_do_roundtrip(bool applyDistinct) + { + var result = await ActDbContext.CreateComplexCollectionParameter([new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(42))], applyDistinct) + .ToListAsync(); + + result.Should().BeEquivalentTo([new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(42))]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_work_with_joins(bool applyDistinct) + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + ConvertibleClass = new ConvertibleClass(42) + }; + await ArrangeDbContext.AddAsync(testEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var collectionParameter = ActDbContext.CreateComplexCollectionParameter([new MyParameter(testEntity.Id, new ConvertibleClass(42))], applyDistinct); + var loadedEntities = await ActDbContext.TestEntities + .Join(collectionParameter, t => new { t.Id, ConvertibleClass = t.ConvertibleClass! }, p => new { Id = p.Column1, ConvertibleClass = p.Column2 }, (t, p) => t) + .ToListAsync(); + + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + ConvertibleClass = new ConvertibleClass(42) + }); + } + + [Fact] + public async Task Should_not_find_rows_if_one_of_the_joined_columns_differs() + { + await ArrangeDbContext.BulkInsertAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(42) }]); + + var collectionParameter = ActDbContext.CreateComplexCollectionParameter([new MyParameter(_TEST_ENTITY_ID, new ConvertibleClass(43)), new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(42))]); + var loadedEntities = await ActDbContext.TestEntities + .Join(collectionParameter, t => new { t.Id, ConvertibleClass = t.ConvertibleClass! }, p => new { Id = p.Column1, ConvertibleClass = p.Column2 }, (t, p) => t) + .ToListAsync(); + + loadedEntities.Should().BeEmpty(); + } + + [Fact] + public async Task Should_filter_a_table_using_any_on_the_parameter() + { + await ArrangeDbContext.BulkInsertAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "first", ConvertibleClass = new ConvertibleClass(1) }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "second", ConvertibleClass = new ConvertibleClass(2) } + ]); + + var collectionParameter = ActDbContext.CreateComplexCollectionParameter([new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(2)), new MyParameter(Guid.NewGuid(), new ConvertibleClass(1))]); + + var result = await ActDbContext.TestEntities + .Where(t => collectionParameter.Any(p => p.Column1 == t.Id)) + .Select(t => t.RequiredName) + .ToListAsync(); + + result.Should().Equal("second"); + } + + [Fact] + public async Task Should_return_all_objects_without_distinct_and_unique_objects_with_distinct() + { + var first = new MyParameter(_TEST_ENTITY_ID, new ConvertibleClass(1)); + var second = new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(2)); + var collection = new[] { first, second, first }; + + var withDistinct = await ActDbContext.CreateComplexCollectionParameter(collection, applyDistinct: true).ToListAsync(); + var withoutDistinct = await ActDbContext.CreateComplexCollectionParameter(collection, applyDistinct: false).ToListAsync(); + + withDistinct.Should().BeEquivalentTo([first, second]); + withoutDistinct.Should().BeEquivalentTo([first, second, first]); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_return_empty_result_for_empty_collection(bool applyDistinct) + { + var result = await ActDbContext.CreateComplexCollectionParameter(Array.Empty(), applyDistinct).ToListAsync(); + + result.Should().BeEmpty(); + } + + [Fact] + public async Task Should_work_with_large_collection() + { + var objects = Enumerable.Range(0, 20_000).Select(i => new MyParameter(new Guid(i, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1), new ConvertibleClass(i))).ToList(); + + var query = ActDbContext.CreateComplexCollectionParameter(objects, applyDistinct: false); + + (await query.CountAsync()).Should().Be(20_000); + (await query.OrderByDescending(p => p.Column2).Select(p => p.Column2).FirstAsync()).Should().Be(new ConvertibleClass(19_999)); + } + + [Fact] + public async Task Should_work_with_large_collection_with_distinct() + { + var objects = Enumerable.Range(0, 20_000).Select(i => new MyParameter(new Guid(i % 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1), new ConvertibleClass(i % 100))).ToList(); + + (await ActDbContext.CreateComplexCollectionParameter(objects, applyDistinct: true).CountAsync()).Should().Be(100); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_work_with_all_column_types(bool applyDistinct) + { + ConfigureModel = builder => builder.ConfigureComplexCollectionParameter(); + + var result = await ActDbContext.CreateComplexCollectionParameter(CreateAllTypes(), applyDistinct).ToListAsync(); + + result.Should().BeEquivalentTo(CreateAllTypes()); + } + + [Fact] + public async Task Should_filter_a_table_by_columns_of_all_types() + { + ConfigureModel = builder => builder.ConfigureComplexCollectionParameter(); + + await ArrangeDbContext.BulkInsertAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "first", Name = "it's \"quoted\" {0} äöü", Count = 1 }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "second", Name = "", Count = 2 } + ]); + + var collectionParameter = ActDbContext.CreateComplexCollectionParameter(CreateAllTypes()); + + var result = await ActDbContext.TestEntities + .Join(collectionParameter, t => new { t.Name, t.Count }, p => new { Name = (string?)p.StringValue, Count = p.IntValue }, (t, p) => new { t.RequiredName, p.TimeSpanValue }) + .OrderBy(t => t.RequiredName) + .ToListAsync(); + + result.Should().BeEquivalentTo([ + new { RequiredName = "first", TimeSpanValue = TimeSpan.FromHours(36) }, + new { RequiredName = "second", TimeSpanValue = TimeSpan.FromMinutes(-90) } + ], + options => options.WithStrictOrdering()); + } + + // The EF service provider depends on the JsonSerializerOptions instance, so the naming policies are limited to a few shared instances. + [Theory] + [InlineData("None")] + [InlineData("CamelCase")] + [InlineData("SnakeCaseLower")] + public async Task Should_read_objects_using_the_json_naming_policy(string namingPolicy) + { + ConfigureMySql = builder => builder.AddCollectionParameterSupport(GetJsonSerializerOptions(namingPolicy)); + ConfigureModel = builder => builder.ConfigureComplexCollectionParameter(); + + var allTypes = await ActDbContext.CreateComplexCollectionParameter(CreateAllTypes()).ToListAsync(); + var withConverter = await ActDbContext.CreateComplexCollectionParameter([new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(42))]).ToListAsync(); + + allTypes.Should().BeEquivalentTo(CreateAllTypes()); + withConverter.Should().BeEquivalentTo([new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(42))]); + } + + [Theory] + [InlineData("None")] + [InlineData("CamelCase")] + public async Task Should_read_objects_using_json_property_name_attributes(string namingPolicy) + { + // The attribute wins over the naming policy. + ConfigureMySql = builder => builder.AddCollectionParameterSupport(GetJsonSerializerOptions(namingPolicy)); + ConfigureModel = builder => builder.ConfigureComplexCollectionParameter(); + + var objects = new[] + { + new JsonNamedComplexParameter("John", "Doe Jr.", 1, 10), + new JsonNamedComplexParameter("Jane", "O'Neil", 2, 20) + }; + + var result = await ActDbContext.CreateComplexCollectionParameter(objects).ToListAsync(); + + result.Should().BeEquivalentTo(objects); + } + + [Fact] + public async Task Should_join_objects_having_json_property_name_attributes_with_a_table() + { + ConfigureModel = builder => builder.ConfigureComplexCollectionParameter(); + + await ArrangeDbContext.BulkInsertAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "first", Count = 2 }, new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "second", Count = 3 }]); + + var collectionParameter = ActDbContext.CreateComplexCollectionParameter([ + new JsonNamedComplexParameter("John", "Doe", 1, 10), + new JsonNamedComplexParameter("Jane", "Roe", 2, 20) + ]); + + var result = await ActDbContext.TestEntities + .Join(collectionParameter, t => t.Count, p => p.Quoted, (t, p) => new { t.RequiredName, p.FirstName }) + .ToListAsync(); + + result.Should().BeEquivalentTo([new { RequiredName = "first", FirstName = "Jane" }]); + } + + [Fact] + public async Task Should_read_the_collection_with_json_table() + { + var result = await ActDbContext.CreateComplexCollectionParameter([new MyParameter(_OTHER_TEST_ENTITY_ID, new ConvertibleClass(42))]).ToListAsync(); + + result.Should().ContainSingle(); + var command = ExecutedCommands.Should().ContainSingle(c => c.Contains("JSON_TABLE", StringComparison.Ordinal)).Subject; + command.Should().Contain("PATH '$.\"Column1\"'").And.Contain("PATH '$.\"Column2\"'").And.Contain("AS `Id`"); + } + + private static JsonSerializerOptions GetJsonSerializerOptions(string namingPolicy) + { + return namingPolicy switch + { + "None" => CollectionParameterJsonSerializerOptions.Default, + "CamelCase" => CollectionParameterJsonSerializerOptions.CamelCase, + "SnakeCaseLower" => CollectionParameterJsonSerializerOptions.SnakeCaseLower, + _ => throw new ArgumentOutOfRangeException(nameof(namingPolicy), namingPolicy, null) + }; + } + + private static ComplexParameterWithAllTypes[] CreateAllTypes() + { + return + [ + new ComplexParameterWithAllTypes(1, + 1L << 40, + -5, + 200, + true, + 12345678901234567.89m, + 4.2d, + 4.3f, + "it's \"quoted\" {0} äöü", + new Guid("CE5E3D80-13D6-43C3-BB41-6499E2AD6B63"), + new DateTime(2021, 1, 15, 12, 30, 40).AddTicks(1234560), + new DateTimeOffset(new DateTime(2021, 1, 15, 12, 30, 40), TimeSpan.FromMinutes(60)), + TimeSpan.FromHours(36), + 7, + "text"), + new ComplexParameterWithAllTypes(2, + -1, + 5, + 0, + false, + -0.5m, + -4.2d, + -4.3f, + "", + Guid.Empty, + new DateTime(1000, 1, 1), + new DateTimeOffset(2020, 2, 29, 1, 2, 3, TimeSpan.Zero), + TimeSpan.FromMinutes(-90), + null, + null) + ]; + } +} + +internal sealed record ComplexParameterWithAllTypes( + int IntValue, + long LongValue, + short ShortValue, + byte ByteValue, + bool BoolValue, + decimal DecimalValue, + double DoubleValue, + float FloatValue, + string StringValue, + Guid GuidValue, + DateTime DateTimeValue, + DateTimeOffset DateTimeOffsetValue, + TimeSpan TimeSpanValue, + int? NullableIntValue, + string? NullableStringValue); + +// JSON property names containing a dot, a space, a single quote and a double quote. +internal sealed record JsonNamedComplexParameter( + [property: JsonPropertyName("first.name")] + string FirstName, + [property: JsonPropertyName("last name")] + string LastName, + [property: JsonPropertyName("it's \"quoted\"")] + int Quoted, + int PlainValue); diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateScalarCollectionParameter.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateScalarCollectionParameter.cs new file mode 100644 index 0000000..ac1e0bb --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/CreateScalarCollectionParameter.cs @@ -0,0 +1,560 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.DbContextExtensionsTests; + +// The collection is sent as one JSON parameter and read with JSON_TABLE; the column types come from the type mapping of the model. +// MySQL parses JSON numbers as DOUBLE and rejects the .NET formats of decimal, TimeSpan and DateTime, so these types are sent as strings. +[Collection(MySqlTestCollection.NAME)] +public sealed class CreateScalarCollectionParameter : MySqlIntegrationTestsBase +{ + private static readonly MethodInfo _genericRoundtripTest = typeof(CreateScalarCollectionParameter).GetMethod(nameof(AssertGenericRoundtripAsync), BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new Exception($"Method '{nameof(AssertGenericRoundtripAsync)}' not found."); + + private static readonly Guid _TEST_ENTITY_ID = new("7F8B0E79-2C91-4682-9F61-6FC86B4E5244"); + private static readonly Guid _OTHER_TEST_ENTITY_ID = new("D3E99F44-40A1-4E4E-820F-9D7C7B02AFA5"); + + // The default data types, one value each. + public static readonly IEnumerable Values = + [ + [new[] { 42 }], + [new[] { 43L }], + [new[] { new DateTime(2021, 1, 15, 12, 30, 40) }], + [new[] { new Guid("CE5E3D80-13D6-43C3-BB41-6499E2AD6B63") }], + [new[] { true }], + [new[] { (byte)4 }], + [new[] { 4.2d }], + [new[] { new DateTimeOffset(new DateTime(2021, 1, 15, 12, 30, 40), TimeSpan.FromMinutes(60)) }], + [new[] { (short)5 }], + [new[] { 4.3f }], + [new[] { 4.4m }], + [new[] { TimeSpan.FromMinutes(60) }], + [new[] { "test" }], + [new[] { new ConvertibleClass(99) }], + ]; + + // Several distinct values per type, with and without DISTINCT. + public static readonly IEnumerable MultipleValues = WithDistinctFlags( + [ + new[] { 1, 2, 3, -4 }, + new[] { 1L, 1L << 40, -5L }, + new[] { (short)-5, (short)5, (short)0 }, + new[] { (byte)0, (byte)1, (byte)255 }, + new[] { true, false }, + new[] { "a", "b", "" }, + new[] { new Guid("CE5E3D80-13D6-43C3-BB41-6499E2AD6B63"), new Guid("D3E99F44-40A1-4E4E-820F-9D7C7B02AFA5") }, + new[] { 4.4m, -1.5m, 0m, 12345678901234567.89m }, + new[] { 4.2d, -4.2d, 0.1d + 0.2d }, + new[] { 1.5f, -2.5f, 0.1f }, + new[] { new DateTime(2021, 1, 15, 12, 30, 40), new DateTime(2020, 2, 29, 0, 0, 0).AddTicks(1234560) }, + new[] { new DateTimeOffset(new DateTime(2021, 1, 15, 12, 30, 40), TimeSpan.FromMinutes(60)), new DateTimeOffset(2020, 2, 29, 1, 2, 3, TimeSpan.FromHours(-3)).AddTicks(1234560) }, + new[] { TimeSpan.FromMinutes(60), TimeSpan.FromSeconds(1.5), TimeSpan.FromMinutes(-90) }, + new[] { new ConvertibleClass(1), new ConvertibleClass(2), new ConvertibleClass(-3) }, + ]); + + // NULL elements. DISTINCT keeps a single NULL. + public static readonly IEnumerable NullableValues = WithDistinctFlags( + [ + new int?[] { 1, null, 3 }, + new long?[] { 1L, null, 3L }, + new short?[] { 1, null }, + new byte?[] { 1, null }, + new bool?[] { true, null, false }, + new decimal?[] { 1.5m, null }, + new double?[] { 1.5d, null }, + new float?[] { 1.5f, null }, + new Guid?[] { new Guid("CE5E3D80-13D6-43C3-BB41-6499E2AD6B63"), null }, + new DateTime?[] { new DateTime(2021, 1, 15, 12, 30, 40), null }, + new DateTimeOffset?[] { new DateTimeOffset(new DateTime(2021, 1, 15, 12, 30, 40), TimeSpan.FromMinutes(60)), null }, + new TimeSpan?[] { TimeSpan.FromMinutes(60), null }, + ]); + + // Limits of the MySQL column types: TIME ranges from -838:59:59 to 838:59:59, DATETIME(6) from 1000-01-01 to 9999-12-31 23:59:59.999999. + public static readonly IEnumerable BoundaryValues = WithDistinctFlags( + [ + new[] { Int32.MinValue, 0, Int32.MaxValue }, + new[] { Int64.MinValue, 0L, Int64.MaxValue }, + new[] { Int16.MinValue, Int16.MaxValue }, + new[] { Byte.MinValue, Byte.MaxValue }, + new[] { Double.MinValue, Double.MaxValue }, + new[] { Decimal.MinValue, Decimal.MaxValue, 0.0000000000000000000000000001m }, + new[] { Guid.Empty, new Guid("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF") }, + new[] { new DateTime(1000, 1, 1), new DateTime(9999, 12, 31, 23, 59, 59).AddTicks(9999990) }, + new[] { TimeSpan.FromHours(24), TimeSpan.FromHours(36) + TimeSpan.FromMilliseconds(1.5), TimeSpan.FromHours(-36) }, + new[] { new TimeSpan(838, 59, 59), new TimeSpan(-838, -59, -59) }, + ]); + + public CreateScalarCollectionParameter(MySqlContainerFixture fixture) + : base(fixture) + { + ConfigureMySql = builder => builder.AddCollectionParameterSupport(CollectionParameterJsonSerializerOptions.Default); + } + + [Theory] + [MemberData(nameof(Values))] + public async Task Should_work_with_default_data_types_with_distinct(object values) + { + await AssertRoundtripAsync((Array)values, true); + } + + [Theory] + [MemberData(nameof(Values))] + public async Task Should_work_with_default_data_types_without_distinct(object values) + { + await AssertRoundtripAsync((Array)values, false); + } + + [Theory] + [MemberData(nameof(MultipleValues))] + public async Task Should_work_with_multiple_values(object values, bool applyDistinct) + { + await AssertRoundtripAsync((Array)values, applyDistinct); + } + + [Theory] + [MemberData(nameof(NullableValues))] + public async Task Should_work_with_nullable_values(object values, bool applyDistinct) + { + await AssertRoundtripAsync((Array)values, applyDistinct); + } + + [Theory] + [MemberData(nameof(BoundaryValues))] + public async Task Should_work_with_boundary_values(object values, bool applyDistinct) + { + await AssertRoundtripAsync((Array)values, applyDistinct); + } + + [Theory] + [InlineData("it's")] + [InlineData("say \"hi\"")] + [InlineData("back\\slash")] + [InlineData("C:\\temp\\new\\table")] + [InlineData("{0}")] + [InlineData("{1}")] + [InlineData("{0} and {1} and {{2}}")] + [InlineData("100% _sure_")] + [InlineData("[1,2,3]")] + [InlineData("{\"a\":1}")] + [InlineData("$.path[*]")] + [InlineData("'; DROP TABLE TestEntities; --")] + [InlineData("äöüß 日本語 Ελληνικά")] + [InlineData("emoji 😀🚀 and surrogate pair 𝄞")] + [InlineData("line1\nline2\r\nline3\ttab")] + [InlineData("")] + [InlineData(" leading and trailing ")] + public async Task Should_work_with_special_strings(string value) + { + var result = await ActDbContext.CreateScalarCollectionParameter(new[] { value }, applyDistinct: false).ToListAsync(); + + result.Should().Equal(value); + } + + [Fact] + public async Task Should_work_with_all_special_strings_in_one_collection() + { + var values = new[] { "it's", "say \"hi\"", "back\\slash", "{0}", "{1}", "100% _sure_", "äöüß 日本語", "😀", "", "line1\nline2" }; + + var result = await ActDbContext.CreateScalarCollectionParameter(values, applyDistinct: true).ToListAsync(); + + result.Should().BeEquivalentTo(values); + } + + [Fact] + public async Task Should_work_with_strings_exceeding_the_limit_of_a_text_column() + { + var value = new string('x', 70_000); + + var result = await ActDbContext.CreateScalarCollectionParameter(new[] { value }, applyDistinct: false).ToListAsync(); + + result.Should().ContainSingle().Which.Length.Should().Be(70_000); + } + + [Fact] + public async Task Should_work_with_date_only_and_time_only() + { + ConfigureModel = builder => + { + builder.ConfigureScalarCollectionParameter(); + builder.ConfigureScalarCollectionParameter(); + }; + + var dates = new[] { new DateOnly(2021, 1, 15), new DateOnly(1000, 1, 1), new DateOnly(9999, 12, 31) }; + var times = new[] { new TimeOnly(12, 30, 40), new TimeOnly(0, 0, 0), new TimeOnly(23, 59, 59, 999, 900) }; + + (await ActDbContext.CreateScalarCollectionParameter(dates, applyDistinct: false).ToListAsync()).Should().BeEquivalentTo(dates); + (await ActDbContext.CreateScalarCollectionParameter(times, applyDistinct: true).ToListAsync()).Should().BeEquivalentTo(times); + } + + [Fact] + public async Task Should_work_with_enums() + { + ConfigureModel = builder => + { + builder.ConfigureScalarCollectionParameter(); + builder.ConfigureScalarCollectionParameter(); + }; + + var statuses = new[] { MySqlTestStatus.Published, MySqlTestStatus.Draft }; + var phases = new[] { PhaseMembership.A | PhaseMembership.C, PhaseMembership.None, PhaseMembership.B }; + + (await ActDbContext.CreateScalarCollectionParameter(statuses, applyDistinct: false).ToListAsync()).Should().BeEquivalentTo(statuses); + (await ActDbContext.CreateScalarCollectionParameter(phases, applyDistinct: true).ToListAsync()).Should().BeEquivalentTo(phases); + } + + // The factory serializes the values with the converter returned by IProperty.GetValueConverter(), i.e. an explicitly configured converter instance. + // A conversion configured with HasConversion() has no such instance (EF infers it from the provider type), so the CLR value would be sent unconverted. + [Fact] + public async Task Should_work_with_enum_converted_to_string_and_filter_a_table() + { + ConfigureModel = builder => builder.ConfigureScalarCollectionParameter(b => b.Property(e => e.Value).HasConversion(new EnumToStringConverter()).HasMaxLength(20)); + + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = Guid.NewGuid(), Status = MySqlTestStatus.Draft }, + new MySqlTypedEntity { Id = Guid.NewGuid(), Status = MySqlTestStatus.Published }); + await ArrangeDbContext.SaveChangesAsync(); + + var statuses = ActDbContext.CreateScalarCollectionParameter(new[] { MySqlTestStatus.Published }, applyDistinct: true); + + (await statuses.ToListAsync()).Should().Equal(MySqlTestStatus.Published); + (await ActDbContext.TypedEntities.Where(e => statuses.Contains(e.Status)).Select(e => e.Status).ToListAsync()).Should().Equal(MySqlTestStatus.Published); + } + + // The usual way to store an enum as string. The column of the parameter is varchar(20), so the JSON must contain "Published" and not the number 1. + // Currently the number is sent: the factory only applies a converter that is returned by IProperty.GetValueConverter() (see the test above). + // The round trip still works because MySQL converts 1 to '1' and EF parses the string '1' as enum, but a filter on the string column finds nothing. + [Fact] + public async Task Should_work_with_enum_converted_to_string_by_provider_type_and_filter_a_table() + { + ConfigureModel = builder => builder.ConfigureScalarCollectionParameter(b => b.Property(e => e.Value).HasConversion().HasMaxLength(20)); + + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = Guid.NewGuid(), Status = MySqlTestStatus.Draft }, + new MySqlTypedEntity { Id = Guid.NewGuid(), Status = MySqlTestStatus.Published }); + await ArrangeDbContext.SaveChangesAsync(); + + var statuses = ActDbContext.CreateScalarCollectionParameter(new[] { MySqlTestStatus.Published }, applyDistinct: true); + + (await statuses.ToListAsync()).Should().Equal(MySqlTestStatus.Published); + (await ActDbContext.TypedEntities.Where(e => statuses.Contains(e.Status)).Select(e => e.Status).ToListAsync()).Should().Equal(MySqlTestStatus.Published); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_return_empty_result_for_empty_collection(bool applyDistinct) + { + var intResult = await ActDbContext.CreateScalarCollectionParameter(Array.Empty(), applyDistinct).ToListAsync(); + var stringResult = await ActDbContext.CreateScalarCollectionParameter(Array.Empty(), applyDistinct).ToListAsync(); + var guidResult = await ActDbContext.CreateScalarCollectionParameter(Array.Empty(), applyDistinct).ToListAsync(); + + intResult.Should().BeEmpty(); + stringResult.Should().BeEmpty(); + guidResult.Should().BeEmpty(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_not_find_anything_with_contains_and_empty_collection(bool applyDistinct) + { + await ArrangeDbContext.BulkInsertAsync([new TestEntity { Id = _TEST_ENTITY_ID, RequiredName = "RequiredName" }]); + + var ids = ActDbContext.CreateScalarCollectionParameter(Array.Empty(), applyDistinct); + + (await ActDbContext.TestEntities.Where(e => ids.Contains(e.Id)).ToListAsync()).Should().BeEmpty(); + (await ActDbContext.TestEntities.Where(e => !ids.Contains(e.Id)).ToListAsync()).Should().ContainSingle(); + } + + [Fact] + public async Task Should_work_with_large_collection() + { + var values = Enumerable.Range(1, 20_000).ToList(); + + var query = ActDbContext.CreateScalarCollectionParameter(values, applyDistinct: false); + + (await query.CountAsync()).Should().Be(20_000); + (await query.MinAsync()).Should().Be(1); + (await query.MaxAsync()).Should().Be(20_000); + (await query.SumAsync()).Should().Be(200_010_000); + } + + [Fact] + public async Task Should_work_with_large_collection_with_distinct() + { + var values = Enumerable.Range(0, 20_000).Select(i => i % 100).ToList(); + + var result = await ActDbContext.CreateScalarCollectionParameter(values, applyDistinct: true).OrderBy(v => v).ToListAsync(); + + result.Should().Equal(Enumerable.Range(0, 100)); + } + + [Fact] + public async Task Should_work_with_large_collection_of_strings_and_guids() + { + var strings = Enumerable.Range(0, 5_000).Select(i => $"value '{i}' \"quoted\" {{{i}}}").ToList(); + var guids = Enumerable.Range(0, 5_000).Select(i => new Guid(i, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)).ToList(); + + (await ActDbContext.CreateScalarCollectionParameter(strings, applyDistinct: true).CountAsync()).Should().Be(5_000); + (await ActDbContext.CreateScalarCollectionParameter(guids, applyDistinct: false).CountAsync()).Should().Be(5_000); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Should_work_with_contains(bool applyDistinct) + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + await ArrangeDbContext.AddAsync(testEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var collectionParameter = ActDbContext.CreateScalarCollectionParameter([testEntity.Id], applyDistinct); + var loadedEntities = await ActDbContext.TestEntities + .Where(e => collectionParameter.Contains(e.Id)) + .ToListAsync(); + + loadedEntities.Should().HaveCount(1); + var loadedEntity = loadedEntities[0]; + loadedEntity.Should().BeEquivalentTo(new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }); + } + + [Fact] + public async Task Should_work_with_contains_and_not_contains_for_several_entities() + { + await SeedTestEntitiesAsync(); + + var ids = ActDbContext.CreateScalarCollectionParameter([_TEST_ENTITY_ID, Guid.NewGuid()]); + + var found = await ActDbContext.TestEntities.Where(e => ids.Contains(e.Id)).Select(e => e.RequiredName).ToListAsync(); + var notFound = await ActDbContext.TestEntities.Where(e => !ids.Contains(e.Id)).OrderBy(e => e.RequiredName).Select(e => e.RequiredName).ToListAsync(); + + found.Should().Equal("first"); + notFound.Should().Equal("second", "third"); + } + + [Fact] + public async Task Should_work_with_contains_for_strings() + { + await SeedTestEntitiesAsync(); + + var names = ActDbContext.CreateScalarCollectionParameter(["second", "third", "does not exist"]); + + var result = await ActDbContext.TestEntities.Where(e => names.Contains(e.RequiredName)).OrderBy(e => e.RequiredName).Select(e => e.RequiredName).ToListAsync(); + + result.Should().Equal("second", "third"); + } + + [Fact] + public async Task Should_work_with_contains_for_nullable_strings_and_nullable_ints() + { + await SeedTestEntitiesAsync(); + + var names = ActDbContext.CreateScalarCollectionParameter(["Name of first"]); + var counts = ActDbContext.CreateScalarCollectionParameter([2, 3]); + + (await ActDbContext.TestEntities.Where(e => names.Contains(e.Name!)).Select(e => e.RequiredName).ToListAsync()).Should().Equal("first"); + (await ActDbContext.TestEntities.Where(e => counts.Contains(e.NullableCount)).OrderBy(e => e.RequiredName).Select(e => e.RequiredName).ToListAsync()).Should().Equal("second", "third"); + } + + [Fact] + public async Task Should_work_with_contains_and_value_converter() + { + await SeedTestEntitiesAsync(); + + var convertibleClasses = ActDbContext.CreateScalarCollectionParameter([new ConvertibleClass(10), new ConvertibleClass(30)]); + + var result = await ActDbContext.TestEntities + .Where(e => convertibleClasses.Contains(e.ConvertibleClass!)) + .OrderBy(e => e.RequiredName) + .Select(e => e.RequiredName) + .ToListAsync(); + + result.Should().Equal("first", "third"); + } + + [Fact] + public async Task Should_work_with_two_collection_parameters_in_one_query() + { + await SeedTestEntitiesAsync(); + + var ids = ActDbContext.CreateScalarCollectionParameter([_TEST_ENTITY_ID, _OTHER_TEST_ENTITY_ID]); + var counts = ActDbContext.CreateScalarCollectionParameter([2, 3, 4]); + + var result = await ActDbContext.TestEntities + .Where(e => ids.Contains(e.Id) && counts.Contains(e.Count)) + .Select(e => e.RequiredName) + .ToListAsync(); + + result.Should().Equal("second"); + } + + [Fact] + public async Task Should_work_with_the_same_parameter_used_twice_in_one_query() + { + await SeedTestEntitiesAsync(); + + var counts = ActDbContext.CreateScalarCollectionParameter([1, 3]); + + var result = await ActDbContext.TestEntities + .Where(e => counts.Contains(e.Count) || counts.Contains(e.Count + 1)) + .OrderBy(e => e.RequiredName) + .Select(e => e.RequiredName) + .ToListAsync(); + + result.Should().Equal("first", "second", "third"); + } + + [Fact] + public async Task Should_work_with_joins() + { + await SeedTestEntitiesAsync(); + + var counts = ActDbContext.CreateScalarCollectionParameter([1, 3, 99]); + + var result = await ActDbContext.TestEntities + .Join(counts, e => e.Count, count => count, (e, count) => new { e.RequiredName, Count = count }) + .OrderBy(e => e.Count) + .ToListAsync(); + + result.Should().BeEquivalentTo([new { RequiredName = "first", Count = 1 }, new { RequiredName = "third", Count = 3 }], options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Should_work_with_filter_projection_and_ordering_on_the_parameter() + { + var values = ActDbContext.CreateScalarCollectionParameter([5, 1, 4, 2, 3]); + + var result = await values.Where(v => v > 1).Select(v => v * 10).OrderByDescending(v => v).Take(3).ToListAsync(); + + result.Should().Equal(50, 40, 30); + } + + [Fact] + public async Task Should_work_with_aggregates_on_the_parameter() + { + var values = ActDbContext.CreateScalarCollectionParameter([5, 1, 4, 2, 3]); + + (await values.CountAsync()).Should().Be(5); + (await values.SumAsync()).Should().Be(15); + (await values.MinAsync()).Should().Be(1); + (await values.MaxAsync()).Should().Be(5); + (await values.AnyAsync(v => v == 4)).Should().BeTrue(); + (await values.AnyAsync(v => v == 6)).Should().BeFalse(); + } + + [Fact] + public async Task Should_work_with_aggregate_on_decimals_of_the_parameter() + { + var values = ActDbContext.CreateScalarCollectionParameter([1.25m, 2.5m, 3.125m]); + + (await values.SumAsync()).Should().Be(6.875m); + (await values.MaxAsync()).Should().Be(3.125m); + } + + [Fact] + public async Task Should_work_GroupBy_and_aggregate() + { + var testEntity = new TestEntity + { + Id = _TEST_ENTITY_ID, + Name = "Name", + RequiredName = "RequiredName", + Count = 42 + }; + await ArrangeDbContext.AddAsync(testEntity); + await ArrangeDbContext.SaveChangesAsync(); + + var collectionParameter = ActDbContext.CreateScalarCollectionParameter([testEntity.Id]); + var loadedEntities = await ActDbContext.TestEntities + .Where(e => collectionParameter.Contains(e.Id)) + .GroupBy(e => e.Id) + .Select(g => new { g.Key, Aggregate = g.Count() }) + .ToListAsync(); + + loadedEntities.Should().BeEquivalentTo([ + new + { + Key = _TEST_ENTITY_ID, + Aggregate = 1 + }, + ]); + } + + [Fact] + public async Task Should_work_with_sum_count_and_group_by_on_a_table_filtered_by_the_parameter() + { + await ArrangeDbContext.BulkInsertAsync([ + new TestEntity { Id = Guid.NewGuid(), Name = "A", RequiredName = "1", Count = 1 }, + new TestEntity { Id = Guid.NewGuid(), Name = "A", RequiredName = "2", Count = 2 }, + new TestEntity { Id = Guid.NewGuid(), Name = "A", RequiredName = "3", Count = 3 }, + new TestEntity { Id = Guid.NewGuid(), Name = "B", RequiredName = "4", Count = 4 }, + new TestEntity { Id = Guid.NewGuid(), Name = "B", RequiredName = "5", Count = 5 }, + new TestEntity { Id = Guid.NewGuid(), Name = "C", RequiredName = "6", Count = 6 } + ]); + + var counts = ActDbContext.CreateScalarCollectionParameter([2, 3, 4, 5, 99]); + + var result = await ActDbContext.TestEntities + .Where(e => counts.Contains(e.Count)) + .GroupBy(e => e.Name) + .Select(g => new { Name = g.Key, Number = g.Count(), Sum = g.Sum(e => e.Count), Max = g.Max(e => e.Count) }) + .OrderBy(g => g.Name) + .ToListAsync(); + + result.Should().BeEquivalentTo([ + new { Name = "A", Number = 2, Sum = 5, Max = 3 }, + new { Name = "B", Number = 2, Sum = 9, Max = 5 }, + ], + options => options.WithStrictOrdering()); + } + + [Fact] + public async Task Should_read_the_collection_with_json_table() + { + var collectionParameter = ActDbContext.CreateScalarCollectionParameter([1, 2, 3], applyDistinct: true); + + var result = await collectionParameter.OrderBy(v => v).ToListAsync(); + + result.Should().Equal(1, 2, 3); + var command = ExecutedCommands.Should().ContainSingle(c => c.Contains("JSON_TABLE", StringComparison.Ordinal)).Subject; + command.Should().Contain("SELECT DISTINCT").And.Contain("LIMIT"); + } + + private async Task SeedTestEntitiesAsync() + { + await ArrangeDbContext.BulkInsertAsync([ + new TestEntity { Id = _TEST_ENTITY_ID, Name = "Name of first", RequiredName = "first", Count = 1, NullableCount = 1, ConvertibleClass = new ConvertibleClass(10) }, + new TestEntity { Id = _OTHER_TEST_ENTITY_ID, RequiredName = "second", Count = 2, NullableCount = 2, ConvertibleClass = new ConvertibleClass(20) }, + new TestEntity { Id = Guid.NewGuid(), RequiredName = "third", Count = 3, NullableCount = 3, ConvertibleClass = new ConvertibleClass(30) } + ]); + } + + private Task AssertRoundtripAsync(Array values, bool applyDistinct) + { + return (Task)_genericRoundtripTest.MakeGenericMethod(values.GetType().GetElementType()!).Invoke(this, [values, applyDistinct])!; + } + + private async Task AssertGenericRoundtripAsync(T[] values, bool applyDistinct) + { + var result = await ActDbContext.CreateScalarCollectionParameter(values, applyDistinct).ToListAsync(); + + result.Should().BeEquivalentTo(values); + } + + private static IEnumerable WithDistinctFlags(IEnumerable values) + { + return values.SelectMany(array => new[] { new object[] { array, true }, new object[] { array, false } }); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/TempTable_DateOnly_Repro.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/TempTable_DateOnly_Repro.cs new file mode 100644 index 0000000..0d0d47d --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/DbContextExtensionsTests/TempTable_DateOnly_Repro.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata; +using Thinktecture.EntityFrameworkCore.TempTables; +using Thinktecture.Internal; + +namespace Thinktecture.Extensions.DbContextExtensionsTests; + +/// +/// Regression tests for issue #75. The temp table entity types TempTable<DateOnly> and +/// TempTable<DateOnly?>, as well as the collection parameter entity types +/// ScalarCollectionParameter<int> and ScalarCollectionParameter<int?>, must keep distinct +/// table names even when another convention renames every entity type with convention source. +/// Without the fix the TempTableConvention and the MySqlCollectionParameterConvention configure +/// the table names with convention source, so a later convention-source rename can overwrite them +/// and collapse each pair onto a single shared table name, which makes the model build throw an +/// InvalidOperationException about table sharing. With the fix the table names are configured with +/// data-annotation source, so the convention-source rename is rejected and the distinct names remain. +/// +public class TempTable_DateOnly_Repro +{ + [Fact] + public void TempTable_DateOnly_and_nullable_should_keep_distinct_table_names() + { + using var context = new MinimalDbContext(); + var model = context.Model; + + var et1 = model.FindEntityType(EntityNameProvider.GetTempTableName(typeof(TempTable))); + var et2 = model.FindEntityType(EntityNameProvider.GetTempTableName(typeof(TempTable))); + + Assert.NotNull(et1); + Assert.NotNull(et2); + Assert.NotEqual(et1.GetTableName(), et2.GetTableName()); + } + + [Fact] + public void ScalarCollectionParameter_int_and_nullable_should_keep_distinct_table_names() + { + using var context = new MinimalDbContext(); + var model = context.Model; + + var et1 = model.FindEntityType(EntityNameProvider.GetCollectionParameterName(typeof(int), true)); + var et2 = model.FindEntityType(EntityNameProvider.GetCollectionParameterName(typeof(int?), true)); + + Assert.NotNull(et1); + Assert.NotNull(et2); + Assert.NotEqual(et1.GetTableName(), et2.GetTableName()); + } + + [Fact] + public void All_temp_tables_of_primitive_types_should_keep_distinct_table_names() + { + using var context = new MinimalDbContext(); + + var tempTableEntityTypes = context.Model.GetEntityTypes() + .Where(entityType => entityType.Name.StartsWith("Thinktecture:TempTable:", StringComparison.Ordinal)) + .ToList(); + + tempTableEntityTypes.Should().HaveCountGreaterThan(20); + tempTableEntityTypes.Select(entityType => entityType.GetTableName()).Should().OnlyHaveUniqueItems(); + } + + [Fact] + public void All_scalar_collection_parameters_should_keep_distinct_table_names() + { + using var context = new MinimalDbContext(); + + var parameterEntityTypes = context.Model.GetEntityTypes() + .Where(entityType => entityType.Name.StartsWith("Thinktecture:ScalarCollectionParameter:", StringComparison.Ordinal)) + .ToList(); + + parameterEntityTypes.Should().HaveCountGreaterThan(20); + parameterEntityTypes.Select(entityType => entityType.GetTableName()).Should().OnlyHaveUniqueItems(); + } + + private sealed class MinimalDbContext : DbContext + { + public DbSet Entities { get; set; } = null!; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning, + CoreEventId.ManyServiceProvidersCreatedWarning)); + optionsBuilder.UseMySql("Server=localhost;Database=test", + MySqlContainerFixture.ServerVersion, + o => o.AddBulkOperationSupport() + .AddCollectionParameterSupport()); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.Entity(b => + { + b.HasKey(e => e.Id); + b.Property(e => e.Date); + b.Property(e => e.NullableDate); + }); + + // Simulate a naming-convention plugin that renames every entity type with convention + // source. Without the fix this overwrites the table names set by TempTableConvention and + // MySqlCollectionParameterConvention and collapses each generic-argument pair onto the + // same table name. + foreach (var entityType in modelBuilder.Model.GetEntityTypes().ToList()) + { + ((IConventionEntityType)entityType).Builder.ToTable(entityType.ClrType.Name); + } + } + } + + private sealed class EntityWithDateOnly + { + public int Id { get; set; } + public DateOnly Date { get; set; } + public DateOnly? NullableDate { get; set; } + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/AverageWindowFunction.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/AverageWindowFunction.cs new file mode 100644 index 0000000..e9ba903 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/AverageWindowFunction.cs @@ -0,0 +1,410 @@ +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.MySqlDbFunctionsExtensionsTests; + +// MySQL specifics: +// - AVG() of integers is a DECIMAL (e.g. 2.5000), of floating point values a DOUBLE and of a DECIMAL a DECIMAL with 4 additional digits of scale. +// - The CLR return type of Average is int, i.e. a fractional average can't be represented. The tests with int use integral averages, +// Should_truncate_a_fractional_average_of_int pins what happens otherwise. Use double or decimal for fractional averages. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class AverageWindowFunction : MySqlIntegrationTestsBase +{ + public AverageWindowFunction(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_return_empty_result_set_if_table_is_empty() + { + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Average(e.Count, e.Name)) + .ToListAsync()).Should().BeEmpty(); + + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Average(e.Count, e.Name, EF.Functions.OrderBy(e.Id))) + .ToListAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Should_generate_Average_with_partition_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Average = EF.Functions.Average(e.Count * 2, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Average.Should().Be(5)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Average.Should().Be(8)); + } + + [Fact] + public async Task Should_generate_Average_with_partition_over_2_columns_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Average = EF.Functions.Average(e.Count, e.Name, e.RequiredName) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Average.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 3).Average.Should().Be(3); + result.Single(t => t.Name == "2").Average.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Average_with_partition_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Average = EF.Functions.Average(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Average.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Average.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Average_with_partition_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", NullableCount = null }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", NullableCount = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", NullableCount = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + Average = EF.Functions.Average(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Average.Should().Be(3)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Average.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Average_with_partition_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 4, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 5, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Average = EF.Functions.Average(e.Count, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Average.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 4).Average.Should().Be(3); + result.Single(t => t.Name == "2").Average.Should().Be(5); + } + + [Fact] + public async Task Should_generate_Average_with_partition_over_2_columns_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + e.Count, + Average = EF.Functions.Average(e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.RequiredName == "1").Average.Should().Be(2); + result.Single(t => t.Name == "1" && t.RequiredName == "2").Average.Should().Be(3); + result.Single(t => t.Name == "2").Average.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Average_with_partition_by_and_order_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Average = EF.Functions.Average(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Average.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Average.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Average_with_partition_by_and_order_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Average = EF.Functions.Average(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Average.Should().Be(null); + result.Single(t => t.Name == "1" && t.Count == 2).Average.Should().Be(3); + result.Single(t => t.Name == "2").Average.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Average_with_partition_by_and_order_by_for_mixed_nullability_different_order() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Average = EF.Functions.Average(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Average.Should().Be(3); + result.Single(t => t.Name == "1" && t.Count == 2).Average.Should().Be(3); + result.Single(t => t.Name == "2").Average.Should().Be(4); + } + + [Fact] + public async Task Should_generate_fractional_Average_of_double_with_partition_by() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Average = EF.Functions.Average((double)e.Count, e.Name) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Average).Should().Equal(2.5d, 2.5d, 4d); + } + + [Fact] + public async Task Should_generate_fractional_running_Average_of_double_with_partition_by_and_order_by() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", Count = 1, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "1", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Average = EF.Functions.Average((double)e.Count, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => Math.Round(e.Average, 6)).Should().Equal(1d, 1.5d, Math.Round(7d / 3, 6)); + } + + [Fact] + public async Task Should_generate_fractional_Average_of_decimal_column() + { + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Amount = 1.1234m, Flag = true }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Amount = 2.0001m, Flag = true }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Amount = -0.5m, Flag = false }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.TypedEntities + .Select(e => new + { + e.Id, + Average = EF.Functions.Average(e.Amount, e.Flag), + RunningAverage = EF.Functions.Average(e.Amount, e.Flag, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Average).Should().Equal(1.56175m, 1.56175m, -0.5m); + result.Select(e => e.RunningAverage).Should().Equal(1.1234m, 1.56175m, -0.5m); + } + + [Fact] + public async Task Should_generate_Average_with_1_to_5_partition_columns() + { + await SeedPartitionTestEntitiesAsync(); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Average1 = EF.Functions.Average((double)e.Count, e.Name), + Average2 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName), + Average3 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName, e.NullableCount), + Average4 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField), + Average5 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + // Every additional partition column splits one more row off the partition of the first rows. + result.Select(e => e.Average1).Should().Equal(10.5, 10.5, 10.5, 10.5, 10.5, 10.5, 64); + result.Select(e => e.Average2).Should().Equal(6.2, 6.2, 6.2, 6.2, 6.2, 32, 64); + result.Select(e => e.Average3).Should().Equal(3.75, 3.75, 3.75, 3.75, 16, 32, 64); + result.Select(e => Math.Round(e.Average4, 6)).Should().Equal(Math.Round(7d / 3, 6), Math.Round(7d / 3, 6), Math.Round(7d / 3, 6), 8, 16, 32, 64); + result.Select(e => e.Average5).Should().Equal(1.5, 1.5, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_Average_with_1_to_5_partition_columns_and_order_by() + { + await SeedPartitionTestEntitiesAsync(); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Average1 = EF.Functions.Average((double)e.Count, e.Name, EF.Functions.OrderBy(e.Count)), + Average2 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Count)), + Average3 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName, e.NullableCount, EF.Functions.OrderBy(e.Count)), + Average4 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, EF.Functions.OrderBy(e.Count)), + Average5 = EF.Functions.Average((double)e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass, EF.Functions.OrderBy(e.Count)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => Math.Round(e.Average1, 6)).Should().Equal(1, 1.5, Math.Round(7d / 3, 6), 3.75, 6.2, 10.5, 64); + result.Select(e => Math.Round(e.Average2, 6)).Should().Equal(1, 1.5, Math.Round(7d / 3, 6), 3.75, 6.2, 32, 64); + result.Select(e => Math.Round(e.Average3, 6)).Should().Equal(1, 1.5, Math.Round(7d / 3, 6), 3.75, 16, 32, 64); + result.Select(e => Math.Round(e.Average4, 6)).Should().Equal(1, 1.5, Math.Round(7d / 3, 6), 8, 16, 32, 64); + result.Select(e => e.Average5).Should().Equal(1, 1.5, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_be_able_to_filter_for_fractional_Average_within_subquery() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "2", Count = 2, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Average = EF.Functions.Average((double)e.Count, e.Name) + }) + .AsSubQuery() + .Where(e => e.Average > 2.25) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Average).Should().Equal(2.5, 2.5); + } + + // AVG() of int values is a DECIMAL (2.5000 here) that is read as int. This pins the real behavior: the fractional part is cut off + // (2.5 -> 2, 3.6667 -> 3), i.e. the result is truncated like the AVG() of SQL Server and not rounded. + [Fact] + public async Task Should_truncate_a_fractional_average_of_int() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "2", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "2", Count = 4, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000005"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Average = EF.Functions.Average(e.Count, e.Name) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Average).Should().Equal(2, 2, 3, 3, 3); + } + + private async Task SeedAsync(params TestEntity[] entities) + { + ArrangeDbContext.TestEntities.AddRange(entities); + await ArrangeDbContext.SaveChangesAsync(); + } + + // Count is a power of two. Each row differs from its predecessors in one more partition column. + private async Task SeedPartitionTestEntitiesAsync() + { + await SeedAsync(CreatePartitionTestEntity(1, "a", "x", 1, 1, 1, 10), + CreatePartitionTestEntity(2, "a", "x", 2, 1, 1, 10), + CreatePartitionTestEntity(3, "a", "x", 4, 1, 1, 20), + CreatePartitionTestEntity(4, "a", "x", 8, 1, 2, 20), + CreatePartitionTestEntity(5, "a", "x", 16, 2, 2, 20), + CreatePartitionTestEntity(6, "a", "y", 32, 2, 2, 20), + CreatePartitionTestEntity(7, "b", "y", 64, 2, 2, 20)); + } + + private static TestEntity CreatePartitionTestEntity(int id, string name, string requiredName, int count, int nullableCount, int propertyWithBackingField, int convertibleClass) + { + return new TestEntity + { + Id = new Guid(id, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1), + Name = name, + RequiredName = requiredName, + Count = count, + NullableCount = nullableCount, + PropertyWithBackingField = propertyWithBackingField, + ConvertibleClass = new ConvertibleClass(convertibleClass) + }; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MaxWindowFunction.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MaxWindowFunction.cs new file mode 100644 index 0000000..9c7ba12 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MaxWindowFunction.cs @@ -0,0 +1,461 @@ +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.MySqlDbFunctionsExtensionsTests; + +// MySQL specifics: +// - MAX() returns the operand's own type, the type mapping of the operand (including its value converter) is used to read the result, +// i.e. a value-converted column (enum stored as string, ConvertibleClass stored as int) comes back as the CLR type of the column. +// - MAX() of a value-converted column compares the stored values: strings alphabetically, not by the order of the enum members. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class MaxWindowFunction : MySqlIntegrationTestsBase +{ + public MaxWindowFunction(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_return_empty_result_set_if_table_is_empty() + { + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Max(e.Count, e.Name)) + .ToListAsync()).Should().BeEmpty(); + + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Max(e.Count, e.Name, EF.Functions.OrderBy(e.Id))) + .ToListAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Should_generate_Max_with_partition_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Max = EF.Functions.Max(e.Count * 2, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Max.Should().Be(6)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Max.Should().Be(8)); + } + + [Fact] + public async Task Should_generate_Max_with_partition_over_2_columns_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Max = EF.Functions.Max(e.Count, e.Name, e.RequiredName) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Max.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 3).Max.Should().Be(3); + result.Single(t => t.Name == "2").Max.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Max_with_partition_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Max = EF.Functions.Max(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Max.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Max.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Max_with_partition_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", NullableCount = null }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", NullableCount = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", NullableCount = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + Max = EF.Functions.Max(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Max.Should().Be(3)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Max.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Max_with_partition_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 4, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 5, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Max = EF.Functions.Max(e.Count, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Max.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 4).Max.Should().Be(4); + result.Single(t => t.Name == "2").Max.Should().Be(5); + } + + [Fact] + public async Task Should_generate_Max_with_partition_over_2_columns_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + e.Count, + Max = EF.Functions.Max(e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.RequiredName == "1").Max.Should().Be(2); + result.Single(t => t.Name == "1" && t.RequiredName == "2").Max.Should().Be(3); + result.Single(t => t.Name == "2").Max.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Max_with_partition_by_and_order_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Max = EF.Functions.Max(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Max.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Max.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Max_with_partition_by_and_order_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Max = EF.Functions.Max(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Max.Should().Be(null); + result.Single(t => t.Name == "1" && t.Count == 2).Max.Should().Be(3); + result.Single(t => t.Name == "2").Max.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Max_with_partition_by_and_order_by_for_mixed_nullability_different_order() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Max = EF.Functions.Max(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Max.Should().Be(3); + result.Single(t => t.Name == "1" && t.Count == 2).Max.Should().Be(3); + result.Single(t => t.Name == "2").Max.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Max_with_1_to_5_partition_columns() + { + await SeedPartitionTestEntitiesAsync(); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Max1 = EF.Functions.Max(e.Count, e.Name), + Max2 = EF.Functions.Max(e.Count, e.Name, e.RequiredName), + Max3 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, e.NullableCount), + Max4 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField), + Max5 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + // Every additional partition column splits one more row off the partition of the first rows. + result.Select(e => e.Max1).Should().Equal(32, 32, 32, 32, 32, 32, 64); + result.Select(e => e.Max2).Should().Equal(16, 16, 16, 16, 16, 32, 64); + result.Select(e => e.Max3).Should().Equal(8, 8, 8, 8, 16, 32, 64); + result.Select(e => e.Max4).Should().Equal(4, 4, 4, 8, 16, 32, 64); + result.Select(e => e.Max5).Should().Equal(2, 2, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_Max_with_1_to_5_partition_columns_and_order_by() + { + await SeedPartitionTestEntitiesAsync(); + + // Count grows with the Id. In descending order of the Id the first row of a partition has the largest Count, + // so the maximum of the rows up to the current one is the maximum of the partition. + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Max1 = EF.Functions.Max(e.Count, e.Name, EF.Functions.OrderByDescending(e.Id)), + Max2 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, EF.Functions.OrderByDescending(e.Id)), + Max3 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, e.NullableCount, EF.Functions.OrderByDescending(e.Id)), + Max4 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, EF.Functions.OrderByDescending(e.Id)), + Max5 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass, EF.Functions.OrderByDescending(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Max1).Should().Equal(32, 32, 32, 32, 32, 32, 64); + result.Select(e => e.Max2).Should().Equal(16, 16, 16, 16, 16, 32, 64); + result.Select(e => e.Max3).Should().Equal(8, 8, 8, 8, 16, 32, 64); + result.Select(e => e.Max4).Should().Equal(4, 4, 4, 8, 16, 32, 64); + result.Select(e => e.Max5).Should().Equal(2, 2, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_running_Max_with_order_by_in_ascending_order() + { + await SeedPartitionTestEntitiesAsync(); + + // Count grows with the Id, i.e. the maximum of the rows up to the current one is the Count of the current row. + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Max1 = EF.Functions.Max(e.Count, e.Name, EF.Functions.OrderBy(e.Id)), + Max5 = EF.Functions.Max(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Max1).Should().Equal(1, 2, 4, 8, 16, 32, 64); + result.Select(e => e.Max5).Should().Equal(1, 2, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_Max_of_strings() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", RequiredName = "alpha" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", RequiredName = "gamma" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "1", RequiredName = "beta" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "2", RequiredName = "delta" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Max = EF.Functions.Max(e.RequiredName, e.Name), + RunningMax = EF.Functions.Max(e.RequiredName, e.Name, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Max).Should().Equal("gamma", "gamma", "gamma", "delta"); + result.Select(e => e.RunningMax).Should().Equal("alpha", "gamma", "gamma", "delta"); + } + + [Fact] + public async Task Should_generate_Max_of_guids() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "2", RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Max = EF.Functions.Max(e.Id, e.Name) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Max).Should().Equal(new Guid("00000000-0000-0000-0000-000000000003"), + new Guid("00000000-0000-0000-0000-000000000002"), + new Guid("00000000-0000-0000-0000-000000000003")); + } + + [Fact] + public async Task Should_generate_Max_of_value_converted_column() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(5) }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(7) }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "2", RequiredName = "RequiredName", ConvertibleClass = null }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "3", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(1) }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Max = EF.Functions.Max(e.ConvertibleClass, e.Name), + RunningMax = EF.Functions.Max(e.ConvertibleClass, e.Name, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Max?.Key).Should().Equal(7, 7, null, 1); + result.Select(e => e.RunningMax?.Key).Should().Equal(5, 7, null, 1); + } + + [Fact] + public async Task Should_generate_Max_of_value_converted_enum() + { + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Status = MySqlTestStatus.Draft, Flag = true, CreatedAt = new DateTime(2020, 1, 1) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Status = MySqlTestStatus.Published, Flag = true, CreatedAt = new DateTime(2020, 1, 2) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Status = MySqlTestStatus.Draft, Flag = false, CreatedAt = new DateTime(2020, 1, 3) }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.TypedEntities + .Select(e => new + { + e.Id, + Max = EF.Functions.Max(e.Status, e.Flag), + RunningMax = EF.Functions.Max(e.Status, e.Flag, EF.Functions.OrderBy(e.CreatedAt)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Max).Should().Equal(MySqlTestStatus.Published, MySqlTestStatus.Published, MySqlTestStatus.Draft); + result.Select(e => e.RunningMax).Should().Equal(MySqlTestStatus.Draft, MySqlTestStatus.Published, MySqlTestStatus.Draft); + } + + [Fact] + public async Task Should_generate_Max_of_date_time_decimal_and_bool_columns() + { + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Status = MySqlTestStatus.Draft, Amount = 1.5m, Flag = false, CreatedAt = new DateTime(2020, 1, 1, 10, 0, 0).AddTicks(1234560) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Status = MySqlTestStatus.Draft, Amount = 2.75m, Flag = true, CreatedAt = new DateTime(2021, 2, 3, 4, 5, 6).AddTicks(7654320) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Status = MySqlTestStatus.Published, Amount = -1m, Flag = false, CreatedAt = new DateTime(2019, 1, 1) }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.TypedEntities + .Select(e => new + { + e.Id, + MaxCreatedAt = EF.Functions.Max(e.CreatedAt, e.Status), + MaxAmount = EF.Functions.Max(e.Amount, e.Status), + MaxFlag = EF.Functions.Max(e.Flag, e.Status) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.MaxCreatedAt).Should().Equal(new DateTime(2021, 2, 3, 4, 5, 6).AddTicks(7654320), new DateTime(2021, 2, 3, 4, 5, 6).AddTicks(7654320), new DateTime(2019, 1, 1)); + result.Select(e => e.MaxAmount).Should().Equal(2.75m, 2.75m, -1m); + result.Select(e => e.MaxFlag).Should().Equal(true, true, false); + } + + [Fact] + public async Task Should_be_able_to_filter_for_Max_within_subquery() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Max = EF.Functions.Max(e.Count, e.Name) + }) + .AsSubQuery() + .Where(e => e.Count == e.Max) + .OrderBy(e => e.Count) + .ToListAsync(); + + result.Select(e => e.Count).Should().Equal(3, 4); + } + + private async Task SeedAsync(params TestEntity[] entities) + { + ArrangeDbContext.TestEntities.AddRange(entities); + await ArrangeDbContext.SaveChangesAsync(); + } + + // Count is a power of two that grows with the Id. Each row differs from its predecessors in one more partition column. + private async Task SeedPartitionTestEntitiesAsync() + { + await SeedAsync(CreatePartitionTestEntity(1, "a", "x", 1, 1, 1, 10), + CreatePartitionTestEntity(2, "a", "x", 2, 1, 1, 10), + CreatePartitionTestEntity(3, "a", "x", 4, 1, 1, 20), + CreatePartitionTestEntity(4, "a", "x", 8, 1, 2, 20), + CreatePartitionTestEntity(5, "a", "x", 16, 2, 2, 20), + CreatePartitionTestEntity(6, "a", "y", 32, 2, 2, 20), + CreatePartitionTestEntity(7, "b", "y", 64, 2, 2, 20)); + } + + private static TestEntity CreatePartitionTestEntity(int id, string name, string requiredName, int count, int nullableCount, int propertyWithBackingField, int convertibleClass) + { + return new TestEntity + { + Id = new Guid(id, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1), + Name = name, + RequiredName = requiredName, + Count = count, + NullableCount = nullableCount, + PropertyWithBackingField = propertyWithBackingField, + ConvertibleClass = new ConvertibleClass(convertibleClass) + }; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MinWindowFunction.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MinWindowFunction.cs new file mode 100644 index 0000000..1888e53 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/MinWindowFunction.cs @@ -0,0 +1,461 @@ +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.MySqlDbFunctionsExtensionsTests; + +// MySQL specifics: +// - MIN() returns the operand's own type, the type mapping of the operand (including its value converter) is used to read the result, +// i.e. a value-converted column (enum stored as string, ConvertibleClass stored as int) comes back as the CLR type of the column. +// - MIN() of a value-converted column compares the stored values: strings alphabetically, not by the order of the enum members. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class MinWindowFunction : MySqlIntegrationTestsBase +{ + public MinWindowFunction(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_return_empty_result_set_if_table_is_empty() + { + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Min(e.Count, e.Name)) + .ToListAsync()).Should().BeEmpty(); + + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Min(e.Count, e.Name, EF.Functions.OrderBy(e.Id))) + .ToListAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Should_generate_Min_with_partition_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Min = EF.Functions.Min(e.Count * 2, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Min.Should().Be(4)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Min.Should().Be(8)); + } + + [Fact] + public async Task Should_generate_Min_with_partition_over_2_columns_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Min = EF.Functions.Min(e.Count, e.Name, e.RequiredName) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Min.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 3).Min.Should().Be(3); + result.Single(t => t.Name == "2").Min.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Min_with_partition_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Min = EF.Functions.Min(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Min.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Min.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Min_with_partition_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", NullableCount = null }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", NullableCount = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", NullableCount = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + Min = EF.Functions.Min(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Min.Should().Be(3)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Min.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Min_with_partition_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 4, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 5, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Min = EF.Functions.Min(e.Count, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Min.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 4).Min.Should().Be(2); + result.Single(t => t.Name == "2").Min.Should().Be(5); + } + + [Fact] + public async Task Should_generate_Min_with_partition_over_2_columns_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + e.Count, + Min = EF.Functions.Min(e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.RequiredName == "1").Min.Should().Be(2); + result.Single(t => t.Name == "1" && t.RequiredName == "2").Min.Should().Be(3); + result.Single(t => t.Name == "2").Min.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Min_with_partition_by_and_order_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Min = EF.Functions.Min(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Min.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Min.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Min_with_partition_by_and_order_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Min = EF.Functions.Min(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Min.Should().Be(null); + result.Single(t => t.Name == "1" && t.Count == 2).Min.Should().Be(3); + result.Single(t => t.Name == "2").Min.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Min_with_partition_by_and_order_by_for_mixed_nullability_different_order() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Min = EF.Functions.Min(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Min.Should().Be(3); + result.Single(t => t.Name == "1" && t.Count == 2).Min.Should().Be(3); + result.Single(t => t.Name == "2").Min.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Min_with_1_to_5_partition_columns() + { + await SeedPartitionTestEntitiesAsync(); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Min1 = EF.Functions.Min(e.Count, e.Name), + Min2 = EF.Functions.Min(e.Count, e.Name, e.RequiredName), + Min3 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, e.NullableCount), + Min4 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField), + Min5 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + // Every additional partition column splits one more row off the partition of the first rows. + result.Select(e => e.Min1).Should().Equal(1, 1, 1, 1, 1, 1, 64); + result.Select(e => e.Min2).Should().Equal(1, 1, 1, 1, 1, 32, 64); + result.Select(e => e.Min3).Should().Equal(1, 1, 1, 1, 16, 32, 64); + result.Select(e => e.Min4).Should().Equal(1, 1, 1, 8, 16, 32, 64); + result.Select(e => e.Min5).Should().Equal(1, 1, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_Min_with_1_to_5_partition_columns_and_order_by() + { + await SeedPartitionTestEntitiesAsync(); + + // Count grows with the Id. In ascending order of the Id the first row of a partition has the smallest Count, + // so the minimum of the rows up to the current one is the minimum of the partition. + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Min1 = EF.Functions.Min(e.Count, e.Name, EF.Functions.OrderBy(e.Id)), + Min2 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Id)), + Min3 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, e.NullableCount, EF.Functions.OrderBy(e.Id)), + Min4 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, EF.Functions.OrderBy(e.Id)), + Min5 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Min1).Should().Equal(1, 1, 1, 1, 1, 1, 64); + result.Select(e => e.Min2).Should().Equal(1, 1, 1, 1, 1, 32, 64); + result.Select(e => e.Min3).Should().Equal(1, 1, 1, 1, 16, 32, 64); + result.Select(e => e.Min4).Should().Equal(1, 1, 1, 8, 16, 32, 64); + result.Select(e => e.Min5).Should().Equal(1, 1, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_running_Min_with_order_by_in_descending_order() + { + await SeedPartitionTestEntitiesAsync(); + + // Count grows with the Id, i.e. the minimum of the rows up to the current one is the Count of the current row. + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Min1 = EF.Functions.Min(e.Count, e.Name, EF.Functions.OrderByDescending(e.Id)), + Min5 = EF.Functions.Min(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass, EF.Functions.OrderByDescending(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Min1).Should().Equal(1, 2, 4, 8, 16, 32, 64); + result.Select(e => e.Min5).Should().Equal(1, 2, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_Min_of_strings() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", RequiredName = "gamma" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", RequiredName = "alpha" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "1", RequiredName = "beta" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "2", RequiredName = "delta" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Min = EF.Functions.Min(e.RequiredName, e.Name), + RunningMin = EF.Functions.Min(e.RequiredName, e.Name, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Min).Should().Equal("alpha", "alpha", "alpha", "delta"); + result.Select(e => e.RunningMin).Should().Equal("gamma", "alpha", "alpha", "delta"); + } + + [Fact] + public async Task Should_generate_Min_of_guids() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "2", RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Min = EF.Functions.Min(e.Id, e.Name) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Min).Should().Equal(new Guid("00000000-0000-0000-0000-000000000001"), + new Guid("00000000-0000-0000-0000-000000000002"), + new Guid("00000000-0000-0000-0000-000000000001")); + } + + [Fact] + public async Task Should_generate_Min_of_value_converted_column() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "1", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(7) }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "1", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(5) }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "2", RequiredName = "RequiredName", ConvertibleClass = null }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "3", RequiredName = "RequiredName", ConvertibleClass = new ConvertibleClass(1) }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Min = EF.Functions.Min(e.ConvertibleClass, e.Name), + RunningMin = EF.Functions.Min(e.ConvertibleClass, e.Name, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Min?.Key).Should().Equal(5, 5, null, 1); + result.Select(e => e.RunningMin?.Key).Should().Equal(7, 5, null, 1); + } + + [Fact] + public async Task Should_generate_Min_of_value_converted_enum() + { + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Status = MySqlTestStatus.Published, Flag = true, CreatedAt = new DateTime(2020, 1, 1) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Status = MySqlTestStatus.Draft, Flag = true, CreatedAt = new DateTime(2020, 1, 2) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Status = MySqlTestStatus.Published, Flag = false, CreatedAt = new DateTime(2020, 1, 3) }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.TypedEntities + .Select(e => new + { + e.Id, + Min = EF.Functions.Min(e.Status, e.Flag), + RunningMin = EF.Functions.Min(e.Status, e.Flag, EF.Functions.OrderBy(e.CreatedAt)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Min).Should().Equal(MySqlTestStatus.Draft, MySqlTestStatus.Draft, MySqlTestStatus.Published); + result.Select(e => e.RunningMin).Should().Equal(MySqlTestStatus.Published, MySqlTestStatus.Draft, MySqlTestStatus.Published); + } + + [Fact] + public async Task Should_generate_Min_of_date_time_decimal_and_bool_columns() + { + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Status = MySqlTestStatus.Draft, Amount = 1.5m, Flag = false, CreatedAt = new DateTime(2020, 1, 1, 10, 0, 0).AddTicks(1234560) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Status = MySqlTestStatus.Draft, Amount = 2.75m, Flag = true, CreatedAt = new DateTime(2021, 2, 3, 4, 5, 6).AddTicks(7654320) }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Status = MySqlTestStatus.Published, Amount = -1m, Flag = true, CreatedAt = new DateTime(2019, 1, 1) }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.TypedEntities + .Select(e => new + { + e.Id, + MinCreatedAt = EF.Functions.Min(e.CreatedAt, e.Status), + MinAmount = EF.Functions.Min(e.Amount, e.Status), + MinFlag = EF.Functions.Min(e.Flag, e.Status) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.MinCreatedAt).Should().Equal(new DateTime(2020, 1, 1, 10, 0, 0).AddTicks(1234560), new DateTime(2020, 1, 1, 10, 0, 0).AddTicks(1234560), new DateTime(2019, 1, 1)); + result.Select(e => e.MinAmount).Should().Equal(1.5m, 1.5m, -1m); + result.Select(e => e.MinFlag).Should().Equal(false, false, true); + } + + [Fact] + public async Task Should_be_able_to_filter_for_Min_within_subquery() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Min = EF.Functions.Min(e.Count, e.Name) + }) + .AsSubQuery() + .Where(e => e.Count == e.Min) + .OrderBy(e => e.Count) + .ToListAsync(); + + result.Select(e => e.Count).Should().Equal(2, 4); + } + + private async Task SeedAsync(params TestEntity[] entities) + { + ArrangeDbContext.TestEntities.AddRange(entities); + await ArrangeDbContext.SaveChangesAsync(); + } + + // Count is a power of two that grows with the Id. Each row differs from its predecessors in one more partition column. + private async Task SeedPartitionTestEntitiesAsync() + { + await SeedAsync(CreatePartitionTestEntity(1, "a", "x", 1, 1, 1, 10), + CreatePartitionTestEntity(2, "a", "x", 2, 1, 1, 10), + CreatePartitionTestEntity(3, "a", "x", 4, 1, 1, 20), + CreatePartitionTestEntity(4, "a", "x", 8, 1, 2, 20), + CreatePartitionTestEntity(5, "a", "x", 16, 2, 2, 20), + CreatePartitionTestEntity(6, "a", "y", 32, 2, 2, 20), + CreatePartitionTestEntity(7, "b", "y", 64, 2, 2, 20)); + } + + private static TestEntity CreatePartitionTestEntity(int id, string name, string requiredName, int count, int nullableCount, int propertyWithBackingField, int convertibleClass) + { + return new TestEntity + { + Id = new Guid(id, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1), + Name = name, + RequiredName = requiredName, + Count = count, + NullableCount = nullableCount, + PropertyWithBackingField = propertyWithBackingField, + ConvertibleClass = new ConvertibleClass(convertibleClass) + }; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/RowNumber.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/RowNumber.cs new file mode 100644 index 0000000..0ec39c7 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/RowNumber.cs @@ -0,0 +1,464 @@ +using System.Linq.Expressions; +using MySqlConnector; +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.MySqlDbFunctionsExtensionsTests; + +// MySQL specifics: +// - ROW_NUMBER() is BIGINT UNSIGNED, the provider casts it to a signed value so that it can be read as long. +// - A window function can't be used in WHERE; it has to be evaluated in a sub query (AsSubQuery), error 3593. +// - NULL is the smallest value, i.e. NULLs come first in an ascending order. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class RowNumber : MySqlIntegrationTestsBase +{ + public RowNumber(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_generate_RowNumber_with_orderby_and_one_column() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "2", RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name)) + }) + .ToListAsync(); + + result.First(t => t.Name == "1").RowNumber.Should().Be(1); + result.First(t => t.Name == "2").RowNumber.Should().Be(2); + } + + [Fact] + public async Task Should_generate_RowNumber_with_orderby_and_one_column_generic_approach() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "2", RequiredName = "RequiredName" }); + + var propertyName = nameof(TestEntity.Name); + + var query = ActDbContext.TestEntities; + var result = await AppendSelect(query, propertyName, new { Name = String.Empty, RowNumber = 0L }).ToListAsync(); + + result.First(t => t.Name == "1").RowNumber.Should().Be(1); + result.First(t => t.Name == "2").RowNumber.Should().Be(2); + } + + private static IQueryable AppendSelect(IQueryable query, string propertyName, TResult anonymousTypeSample) + { + var testEntityType = typeof(T); + var propertyInfo = testEntityType.GetProperty(propertyName) ?? throw new Exception($"Property '{propertyName}' not found."); + + var returnType = anonymousTypeSample!.GetType(); + var returnTypeCtor = returnType.GetConstructor([typeof(string), typeof(long)]) + ?? throw new Exception("Constructor not found."); + + var efFunctions = Expression.Constant(EF.Functions); // EF.Functions + var extensionsType = typeof(RelationalDbFunctionsExtensions); + var orderByMethod = extensionsType.GetMethod(nameof(RelationalDbFunctionsExtensions.OrderBy)) // EF.Functions.OrderBy + ?.MakeGenericMethod(propertyInfo.PropertyType) // EF.Functions.OrderBy + ?? throw new Exception("Method 'OrderBy' not found."); + var rowNumberMethod = extensionsType.GetMethods() + .Single(m => m.Name == nameof(RelationalDbFunctionsExtensions.RowNumber) && !m.IsGenericMethod); // EF.Functions.RowNumber + + var param = Expression.Parameter(testEntityType); // e + var nameAccessor = Expression.MakeMemberAccess(param, propertyInfo); // e.Name + var orderByCall = Expression.Call(null, orderByMethod, efFunctions, nameAccessor); // EF.Functions.OrderBy(e.Name) + var rowNumberCall = Expression.Call(null, rowNumberMethod, efFunctions, orderByCall); // EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name)) + var returnTypeCtorCall = Expression.New(returnTypeCtor, nameAccessor, rowNumberCall); // new { ... } + + var projection = Expression.Lambda>(returnTypeCtorCall, param); + + return query.Select(projection); + } + + [Fact] + public async Task Should_generate_RowNumber_with_orderby_and_one_struct_column() + { + await SeedAsync(new TestEntity { Id = new Guid("18CF65B3-F53D-4F45-8DF5-DD62DCC8B2EB"), RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("28CF65B3-F53D-4F45-8DF5-DD62DCC8B2EB"), RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Id)) + }) + .ToListAsync(); + + result.First(t => t.Id == new Guid("18CF65B3-F53D-4F45-8DF5-DD62DCC8B2EB")).RowNumber.Should().Be(1); + result.First(t => t.Id == new Guid("28CF65B3-F53D-4F45-8DF5-DD62DCC8B2EB")).RowNumber.Should().Be(2); + } + + [Fact] + public async Task Should_generate_RowNumber_with_orderby_desc_and_one_column() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "2", RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderByDescending(e.Name)) + }) + .ToListAsync(); + + result.First(t => t.Name == "1").RowNumber.Should().Be(2); + result.First(t => t.Name == "2").RowNumber.Should().Be(1); + } + + [Fact] + public async Task Should_generate_RowNumber_with_orderby_and_two_columns() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", Count = 1, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", Count = 2, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Count, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name).ThenBy(e.Count)) + }) + .ToListAsync(); + + result.First(t => t.Count == 1).RowNumber.Should().Be(1); + result.First(t => t.Count == 2).RowNumber.Should().Be(2); + } + + [Fact] + public async Task Should_generate_RowNumber_with_partitionby_and_orderby_and_one_column() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "2", RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + RowNumber = EF.Functions.RowNumber(e.Name, EF.Functions.OrderBy(e.Name)) + }) + .ToListAsync(); + + result.First(t => t.Name == "1").RowNumber.Should().Be(1); + result.First(t => t.Name == "2").RowNumber.Should().Be(1); + } + + [Fact] + public async Task Should_filter_for_RowNumber_if_accessing_within_subquery() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "2", RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name)) + }) + .AsSubQuery() + .Where(i => i.RowNumber == 1) + .ToListAsync(); + + result.Should().HaveCount(1); + result[0].Name.Should().Be("1"); + } + + [Fact] + public async Task Should_throw_if_accessing_RowNumber_not_within_subquery() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }); + + var query = ActDbContext.TestEntities + .Select(e => new + { + e.Name, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name)) + }) + .Where(i => i.RowNumber == 1); + + // 3593: You cannot use the window function 'row_number' in this context + var exception = await query.Awaiting(q => q.ToListAsync()).Should().ThrowAsync(); + exception.Which.Number.Should().Be(3593); + } + + [Fact] + public async Task Should_be_able_to_fetch_whole_entity() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }); + + var query = ActDbContext.TestEntities + .Select(e => new + { + e, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name)) + }); + + var entities = await query.ToListAsync(); + entities.Should().HaveCount(1); + entities[0].Should().BeEquivalentTo(new + { + e = new TestEntity + { + Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), + Name = "1", + RequiredName = "RequiredName" + }, + RowNumber = 1 + }); + } + + [Fact] + public async Task Should_support_columns_with_converters() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", ConvertibleClass = new ConvertibleClass(1), RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", ConvertibleClass = new ConvertibleClass(2), RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.ConvertibleClass, + RowNumber = EF.Functions.RowNumber(e.Name, + EF.Functions.OrderBy(e.Name).ThenBy(e.ConvertibleClass)) + }) + .ToListAsync(); + + result.Should().HaveCount(2); + result.Select(e => e.ConvertibleClass?.Key).Should().BeEquivalentTo([1, 2]); + result.Single(e => e.ConvertibleClass!.Key == 1).RowNumber.Should().Be(1); + result.Single(e => e.ConvertibleClass!.Key == 2).RowNumber.Should().Be(2); + } + + [Fact] + public async Task Should_support_conversion_to_underlying_column_type() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", ConvertibleClass = new ConvertibleClass(1), RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", ConvertibleClass = new ConvertibleClass(2), RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + ConvertibleClass = (int)e.ConvertibleClass!, +#pragma warning disable CS8604 + RowNumber = EF.Functions.RowNumber(e.Name, (int)e.ConvertibleClass, e.Count, + EF.Functions.OrderBy(e.Name).ThenBy((int)e.ConvertibleClass).ThenBy(e.Count)) +#pragma warning restore CS8604 + }) + .ToListAsync(); + + result.Should().HaveCount(2); + result.Select(e => e.ConvertibleClass).Should().AllBeOfType().And.BeEquivalentTo([1, 2]); + result.Should().OnlyContain(e => e.RowNumber == 1); + } + + [Fact] + public async Task Should_support_conversion_if_column_type_is_convertable_on_database() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", ConvertibleClass = new ConvertibleClass(1), RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", ConvertibleClass = new ConvertibleClass(2), RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + ConvertibleClass = (long)e.ConvertibleClass!, +#pragma warning disable CS8604 + RowNumber = EF.Functions.RowNumber(e.Name, (long)e.ConvertibleClass, (long)e.Count, + EF.Functions.OrderBy(e.Name).ThenBy((long)e.ConvertibleClass).ThenBy((long)e.Count)) +#pragma warning restore CS8604 + }) + .ToListAsync(); + + result.Should().HaveCount(2); + result.Select(e => e.ConvertibleClass).Should().AllBeOfType().And.BeEquivalentTo(new long[] { 1, 2 }); + result.Should().OnlyContain(e => e.RowNumber == 1); + } + + [Fact] + public async Task Should_support_conversion_for_non_trivial_expressions() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", ConvertibleClass = new ConvertibleClass(1), RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", ConvertibleClass = new ConvertibleClass(2), RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + ConvertibleClass = (long)e.ConvertibleClass!, +#pragma warning disable CS8604 + RowNumber = EF.Functions.RowNumber(e.Name, (long)e.ConvertibleClass + 1, (long)e.Count + 1, + EF.Functions.OrderBy(e.Name).ThenBy((long)e.ConvertibleClass + 1).ThenBy((long)e.Count + 1)) +#pragma warning restore CS8604 + }) + .ToListAsync(); + + result.Should().HaveCount(2); + result.Select(e => e.ConvertibleClass).Should().AllBeOfType().And.BeEquivalentTo(new long[] { 1, 2 }); + result.Should().OnlyContain(e => e.RowNumber == 1); + } + + [Fact] + public async Task Should_generate_RowNumber_with_orderby_desc_and_two_columns() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", Count = 1, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", Count = 2, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Count, + RowNumber = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name).ThenByDescending(e.Count)) + }) + .ToListAsync(); + + result.First(t => t.Count == 1).RowNumber.Should().Be(2); + result.First(t => t.Count == 2).RowNumber.Should().Be(1); + } + + [Fact] + public async Task Should_generate_RowNumber_with_partitionby_and_orderby_and_two_columns() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", Count = 1, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", Count = 2, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Count, + RowNumber = EF.Functions.RowNumber(e.Name, e.Count, + EF.Functions.OrderBy(e.Name).ThenBy(e.Count)) + }) + .ToListAsync(); + + result.First(t => t.Count == 1).RowNumber.Should().Be(1); + result.First(t => t.Count == 2).RowNumber.Should().Be(1); + } + + [Fact] + public async Task Should_throw_if_RowNumber_contains_NewExpression() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", Count = 1, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70A"), Name = "1", Count = 2, RequiredName = "RequiredName" }); + + var query = ActDbContext.TestEntities + .Select(e => new + { + e.Count, + RowNumber = EF.Functions.RowNumber(new { e.Name, e.Count }, + EF.Functions.OrderBy(e.Name).ThenBy(e.Count)) + }); + await query.Awaiting(q => q.ToListAsync()).Should().ThrowAsync() + .WithMessage("The window function contains some expressions not supported by the Entity Framework. One of the reason is the creation of new objects like: 'new { e.MyProperty, e.MyOtherProperty }'."); + } + + [Fact] + public async Task Should_generate_RowNumber_with_three_partitions_and_orderby_in_every_partition() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "a", RequiredName = "x", Count = 1 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "a", RequiredName = "x", Count = 2 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "a", RequiredName = "y", Count = 1 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "b", RequiredName = "x", Count = 1 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000005"), Name = "b", RequiredName = "x", Count = 1 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + RowNumber = EF.Functions.RowNumber(e.Name, e.RequiredName, e.Count, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + // Only the last two rows share a partition (b, x, 1). + result.Select(e => e.RowNumber).Should().Equal(1, 1, 1, 1, 2); + } + + [Fact] + public async Task Should_generate_RowNumber_with_five_partitions() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "a", RequiredName = "x", Count = 1, NullableCount = 1, PropertyWithBackingField = 1 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "a", RequiredName = "x", Count = 1, NullableCount = 1, PropertyWithBackingField = 1 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "a", RequiredName = "x", Count = 1, NullableCount = 1, PropertyWithBackingField = 2 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "a", RequiredName = "x", Count = 1, NullableCount = null, PropertyWithBackingField = 2 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000005"), Name = "a", RequiredName = "x", Count = 1, NullableCount = null, PropertyWithBackingField = 2 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + RowNumber = EF.Functions.RowNumber(e.Name, e.RequiredName, e.Count, e.NullableCount, e.PropertyWithBackingField, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + // NULLs form one partition. + result.Select(e => e.RowNumber).Should().Equal(1, 2, 1, 1, 2); + } + + [Fact] + public async Task Should_generate_RowNumber_with_nulls_first_in_ascending_order() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "a", RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = null, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Ascending = EF.Functions.RowNumber(EF.Functions.OrderBy(e.Name)), + Descending = EF.Functions.RowNumber(EF.Functions.OrderByDescending(e.Name)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Ascending).Should().Equal(2, 1); + result.Select(e => e.Descending).Should().Equal(1, 2); + } + + [Fact] + public async Task Should_return_top_entries_per_group_using_a_subquery() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "a", RequiredName = "a1", Count = 10 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "a", RequiredName = "a2", Count = 30 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "a", RequiredName = "a3", Count = 20 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "b", RequiredName = "b1", Count = 5 }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000005"), Name = "b", RequiredName = "b2", Count = 15 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.RequiredName, + RowNumber = EF.Functions.RowNumber(e.Name, EF.Functions.OrderByDescending(e.Count)) + }) + .AsSubQuery() + .Where(i => i.RowNumber <= 2) + .OrderBy(i => i.RequiredName) + .ToListAsync(); + + result.Select(e => e.RequiredName).Should().Equal("a2", "a3", "b1", "b2"); + result.Select(e => e.RowNumber).Should().Equal(1, 2, 2, 1); + } + + [Fact] + public async Task Should_cast_RowNumber_to_signed() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BF"), Name = "1", RequiredName = "RequiredName" }); + + await ActDbContext.TestEntities + .Select(e => EF.Functions.RowNumber(e.Name, EF.Functions.OrderBy(e.Id))) + .ToListAsync(); + + ExecutedCommands.Should().Contain(c => c.Contains("CAST(ROW_NUMBER() OVER(PARTITION BY", StringComparison.Ordinal) && c.Contains("AS signed)", StringComparison.Ordinal)); + } + + private async Task SeedAsync(params TestEntity[] entities) + { + ArrangeDbContext.TestEntities.AddRange(entities); + await ArrangeDbContext.SaveChangesAsync(); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/SumWindowFunction.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/SumWindowFunction.cs new file mode 100644 index 0000000..d5452c6 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/SumWindowFunction.cs @@ -0,0 +1,458 @@ +using MySqlConnector; +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.MySqlDbFunctionsExtensionsTests; + +// MySQL specifics: +// - SUM() of integers is a DECIMAL, it is read as the CLR type of the aggregated expression. +// - A window function is rendered as NAME(...) OVER (...) without a space before the parenthesis. +// - A number is not allowed as a PARTITION BY column of a window ("legacy position indication", error 3592), use columns or expressions. +// ReSharper disable once InconsistentNaming +[Collection(MySqlTestCollection.NAME)] +public sealed class SumWindowFunction : MySqlIntegrationTestsBase +{ + public SumWindowFunction(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_return_empty_result_set_if_table_is_empty() + { + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Sum(e.Count, e.Name)) + .ToListAsync()).Should().BeEmpty(); + + (await ActDbContext.TestEntities + .Select(e => EF.Functions.Sum(e.Count, e.Name, EF.Functions.OrderBy(e.Id))) + .ToListAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Sum = EF.Functions.Sum(e.Count * 2, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Sum.Should().Be(10)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Sum.Should().Be(8)); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_over_2_columns_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Sum = EF.Functions.Sum(e.Count, e.Name, e.RequiredName) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Sum.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 3).Sum.Should().Be(3); + result.Single(t => t.Name == "2").Sum.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Sum = EF.Functions.Sum(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Sum.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Sum.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", NullableCount = null }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", NullableCount = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", NullableCount = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + Sum = EF.Functions.Sum(e.NullableCount, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Sum.Should().Be(3)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Sum.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Sum = EF.Functions.Sum(e.Count, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 2).Sum.Should().Be(2); + result.Single(t => t.Name == "1" && t.Count == 3).Sum.Should().Be(5); + result.Single(t => t.Name == "2").Sum.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_over_2_columns_by_and_order_by_for_not_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + e.Count, + Sum = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.RequiredName == "1").Sum.Should().Be(2); + result.Single(t => t.Name == "1" && t.RequiredName == "2").Sum.Should().Be(3); + result.Single(t => t.Name == "2").Sum.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_and_order_by_for_nulls() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Sum = EF.Functions.Sum(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Sum.Should().Be(null)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Sum.Should().Be(4)); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_and_order_by_for_mixed_nullability() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Sum = EF.Functions.Sum(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Sum.Should().Be(null); + result.Single(t => t.Name == "1" && t.Count == 2).Sum.Should().Be(3); + result.Single(t => t.Name == "2").Sum.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_and_order_by_for_mixed_nullability_different_order() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 1, NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 2, NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 3, NullableCount = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Sum = EF.Functions.Sum(e.NullableCount, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.Name == "1" && t.Count == 1).Sum.Should().Be(3); + result.Single(t => t.Name == "1" && t.Count == 2).Sum.Should().Be(3); + result.Single(t => t.Name == "2").Sum.Should().Be(4); + } + + [Fact] + public async Task Should_generate_Sum_with_1_to_5_partition_columns() + { + await SeedPartitionTestEntitiesAsync(); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Sum1 = EF.Functions.Sum(e.Count, e.Name), + Sum2 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName), + Sum3 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, e.NullableCount), + Sum4 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField), + Sum5 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + // Every additional partition column splits one more row off the partition of the first rows. + result.Select(e => e.Sum1).Should().Equal(63, 63, 63, 63, 63, 63, 64); + result.Select(e => e.Sum2).Should().Equal(31, 31, 31, 31, 31, 32, 64); + result.Select(e => e.Sum3).Should().Equal(15, 15, 15, 15, 16, 32, 64); + result.Select(e => e.Sum4).Should().Equal(7, 7, 7, 8, 16, 32, 64); + result.Select(e => e.Sum5).Should().Equal(3, 3, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_Sum_with_1_to_5_partition_columns_and_order_by() + { + await SeedPartitionTestEntitiesAsync(); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Sum1 = EF.Functions.Sum(e.Count, e.Name, EF.Functions.OrderBy(e.Count)), + Sum2 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Count)), + Sum3 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, e.NullableCount, EF.Functions.OrderBy(e.Count)), + Sum4 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, EF.Functions.OrderBy(e.Count)), + Sum5 = EF.Functions.Sum(e.Count, e.Name, e.RequiredName, e.NullableCount, e.PropertyWithBackingField, e.ConvertibleClass, EF.Functions.OrderBy(e.Count)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + // The running sum of the powers of two is 2^n - 1 for the first n rows of a partition. + result.Select(e => e.Sum1).Should().Equal(1, 3, 7, 15, 31, 63, 64); + result.Select(e => e.Sum2).Should().Equal(1, 3, 7, 15, 31, 32, 64); + result.Select(e => e.Sum3).Should().Equal(1, 3, 7, 15, 16, 32, 64); + result.Select(e => e.Sum4).Should().Equal(1, 3, 7, 8, 16, 32, 64); + result.Select(e => e.Sum5).Should().Equal(1, 3, 4, 8, 16, 32, 64); + } + + [Fact] + public async Task Should_generate_Sum_with_partition_by_expression() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "a", Count = 1, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "a", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "a", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000004"), Name = "a", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Sum = EF.Functions.Sum(e.Count, e.Count % 2) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Sum).Should().Equal(4, 6, 4, 6); + } + + [Fact] + public async Task Should_generate_Sum_of_a_converted_expression_without_null_values() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "a", NullableCount = null, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "a", NullableCount = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Name = "b", NullableCount = null, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Sum = EF.Functions.Sum(e.NullableCount ?? 0, e.Name) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Sum).Should().Equal(3, 3, 0); + } + + // SUM() is a DECIMAL in MySQL, so a long aggregate doesn't overflow the way an int aggregate would. + [Fact] + public async Task Should_generate_Sum_exceeding_the_range_of_int_if_declared_as_long() + { + await SeedAsync(new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Name = "a", Count = Int32.MaxValue, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Name = "a", Count = Int32.MaxValue, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => EF.Functions.Sum((long)e.Count, e.Name)) + .ToListAsync(); + + result.Should().Equal(2L * Int32.MaxValue, 2L * Int32.MaxValue); + } + + [Fact] + public async Task Should_generate_Sum_of_decimal_column() + { + ArrangeDbContext.TypedEntities.AddRange(new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000001"), Amount = 1.1234m, Flag = true }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000002"), Amount = 2.0001m, Flag = true }, + new MySqlTypedEntity { Id = new Guid("00000000-0000-0000-0000-000000000003"), Amount = -0.5m, Flag = false }); + await ArrangeDbContext.SaveChangesAsync(); + + var result = await ActDbContext.TypedEntities + .Select(e => new + { + e.Id, + Sum = EF.Functions.Sum(e.Amount, e.Flag), + RunningSum = EF.Functions.Sum(e.Amount, e.Flag, EF.Functions.OrderBy(e.Id)) + }) + .OrderBy(e => e.Id) + .ToListAsync(); + + result.Select(e => e.Sum).Should().Equal(3.1235m, 3.1235m, -0.5m); + result.Select(e => e.RunningSum).Should().Equal(1.1234m, 3.1235m, -0.5m); + } + + [Fact] + public async Task Should_be_able_to_filter_for_Sum_within_subquery() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Sum = EF.Functions.Sum(e.Count, e.Name) + }) + .AsSubQuery() + .Where(e => e.Sum > 4) + .OrderBy(e => e.Count) + .ToListAsync(); + + result.Select(e => e.Count).Should().Equal(2, 3); + } + + [Fact] + public async Task Should_generate_Sum_together_with_other_window_functions() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Count, + Sum = EF.Functions.Sum(e.Count, e.Name), + Max = EF.Functions.Max(e.Count, e.Name), + RowNumber = EF.Functions.RowNumber(e.Name, EF.Functions.OrderBy(e.Count)) + }) + .OrderBy(e => e.Count) + .ToListAsync(); + + result.Select(e => e.Sum).Should().Equal(5, 5, 4); + result.Select(e => e.Max).Should().Equal(3, 3, 4); + result.Select(e => e.RowNumber).Should().Equal(1, 2, 1); + } + + [Fact] + public async Task Should_render_Sum_without_a_space_before_the_parenthesis() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }); + + await ActDbContext.TestEntities + .Select(e => new + { + Partitioned = EF.Functions.Sum(e.Count, e.Name), + Ordered = EF.Functions.Sum(e.Count, e.Name, EF.Functions.OrderBy(e.Count)) + }) + .ToListAsync(); + + var command = ExecutedCommands.Last(c => c.Contains("SUM(", StringComparison.Ordinal)); + command.Should().Contain("SUM(`t`.`Count`) OVER (PARTITION BY `t`.`Name`)"); + command.Should().Contain("SUM(`t`.`Count`) OVER (PARTITION BY `t`.`Name` ORDER BY `t`.`Count`)"); + } + + // MySQL treats a number in PARTITION BY as the position of a column and rejects it, so a numeric constant can't be used as partition column. + [Fact] + public async Task Should_throw_if_the_partition_column_is_a_constant() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }); + + var query = ActDbContext.TestEntities.Select(e => EF.Functions.Sum(e.Count, 1)); + + var exception = await query.Awaiting(q => q.ToListAsync()).Should().ThrowAsync(); + exception.Which.Number.Should().Be(3592); + } + + private async Task SeedAsync(params TestEntity[] entities) + { + ArrangeDbContext.TestEntities.AddRange(entities); + await ArrangeDbContext.SaveChangesAsync(); + } + + // Count is a power of two, so the sum identifies the rows of a partition. Each row differs from its predecessors in one more partition column. + private async Task SeedPartitionTestEntitiesAsync() + { + await SeedAsync(CreatePartitionTestEntity(1, "a", "x", 1, 1, 1, 10), + CreatePartitionTestEntity(2, "a", "x", 2, 1, 1, 10), + CreatePartitionTestEntity(3, "a", "x", 4, 1, 1, 20), + CreatePartitionTestEntity(4, "a", "x", 8, 1, 2, 20), + CreatePartitionTestEntity(5, "a", "x", 16, 2, 2, 20), + CreatePartitionTestEntity(6, "a", "y", 32, 2, 2, 20), + CreatePartitionTestEntity(7, "b", "y", 64, 2, 2, 20)); + } + + private static TestEntity CreatePartitionTestEntity(int id, string name, string requiredName, int count, int nullableCount, int propertyWithBackingField, int convertibleClass) + { + return new TestEntity + { + Id = new Guid(id, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1), + Name = name, + RequiredName = requiredName, + Count = count, + NullableCount = nullableCount, + PropertyWithBackingField = propertyWithBackingField, + ConvertibleClass = new ConvertibleClass(convertibleClass) + }; + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/WindowFunctionTests.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/WindowFunctionTests.cs new file mode 100644 index 0000000..e2902ce --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Extensions/MySqlDbFunctionsExtensionsTests/WindowFunctionTests.cs @@ -0,0 +1,289 @@ +using Thinktecture.EntityFrameworkCore; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Extensions.MySqlDbFunctionsExtensionsTests; + +// MySQL specifics: AVG() of integers is a DECIMAL, RANK() is a BIGINT UNSIGNED and COUNT() is a BIGINT. +// The window function SQL has no space between the function name and the opening parenthesis: NAME(...) OVER (...). +[Collection(MySqlTestCollection.NAME)] +public sealed class WindowFunctionTests : MySqlIntegrationTestsBase +{ + private static readonly WindowFunction _averageInt = new("AVG"); + private static readonly WindowFunction _averageDecimal = new("AVG"); + private static readonly WindowFunction _rank = new("RANK"); + private static readonly WindowFunction _count = new("COUNT", true); + private static readonly WindowFunction _lag = new("LAG"); + + public WindowFunctionTests(MySqlContainerFixture fixture) + : base(fixture) + { + } + + [Fact] + public async Task Should_translate_function_with_1_argument_and_1_partition_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 4, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + Average = EF.Functions.Average(e.Count * 2, e.Name), + OtherAverage = EF.Functions.WindowFunction(_averageInt, e.Count * 2, EF.Functions.PartitionBy(e.Name)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Should().AllSatisfy(t => t.Average.Should().Be(t.OtherAverage)); + result.Where(t => t.Name == "1").Should().AllSatisfy(t => t.Average.Should().Be(5)); + result.Where(t => t.Name == "2").Should().AllSatisfy(t => t.Average.Should().Be(8)); + } + + [Fact] + public async Task Should_translate_function_with_1_argument_and_2_partition_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Average = EF.Functions.Average(e.Count, e.Name, e.RequiredName), + OtherAverage = EF.Functions.WindowFunction(_averageInt, e.Count, EF.Functions.PartitionBy(e.Name, e.RequiredName)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Should().AllSatisfy(t => t.Average.Should().Be(t.OtherAverage)); + result.Should().AllSatisfy(t => t.Average.Should().Be(t.Count)); + } + + [Fact] + public async Task Should_translate_function_with_1_argument_and_1_partition_by_and_1_order_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 4, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", Count = 5, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.Count, + Average = EF.Functions.Average(e.Count, e.Name, EF.Functions.OrderBy(e.Count)), + OtherAverage = EF.Functions.WindowFunction(_averageInt, e.Count, EF.Functions.PartitionBy(e.Name), EF.Functions.OrderBy(e.Count)) + }) + .OrderBy(t => t.Count) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Should().AllSatisfy(t => t.Average.Should().Be(t.OtherAverage)); + result.Select(t => t.Average).Should().Equal(2, 3, 5); + } + + [Fact] + public async Task Should_translate_function_with_1_argument_and_2_partition_by_and_1_order_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + e.Count, + Average = EF.Functions.Average(e.Count, e.Name, e.RequiredName, EF.Functions.OrderBy(e.Count)), + OtherAverage = EF.Functions.WindowFunction(_averageInt, e.Count, EF.Functions.PartitionBy(e.Name, e.RequiredName), EF.Functions.OrderBy(e.Count)) + }) + .OrderBy(t => t.Count) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Should().AllSatisfy(t => t.Average.Should().Be(t.OtherAverage)); + result.Select(t => t.Average).Should().Equal(2, 3, 4); + } + + [Fact] + public async Task Should_translate_function_without_arguments_and_1_order_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + e.Count, + Rank = EF.Functions.WindowFunction(_rank, EF.Functions.OrderBy(e.RequiredName)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.RequiredName == "1").Rank.Should().Be(1); + result.Single(t => t.RequiredName == "2").Rank.Should().Be(2); + result.Single(t => t.RequiredName == "3").Rank.Should().Be(3); + } + + [Fact] + public async Task Should_translate_function_without_arguments_and_1_partition_by_and_1_order_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + e.Count, + Rank = EF.Functions.WindowFunction(_rank, EF.Functions.PartitionBy(e.Name), EF.Functions.OrderBy(e.RequiredName)) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.RequiredName == "1").Rank.Should().Be(1); + result.Single(t => t.RequiredName == "2").Rank.Should().Be(2); + result.Single(t => t.RequiredName == "3").Rank.Should().Be(1); + } + + [Fact] + public async Task Should_translate_function_without_parameters() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + Count = EF.Functions.WindowFunction(_count) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.RequiredName == "1").Count.Should().Be(3); + result.Single(t => t.RequiredName == "2").Count.Should().Be(3); + result.Single(t => t.RequiredName == "3").Count.Should().Be(3); + ExecutedCommands.Should().Contain(c => c.Contains("COUNT(*) OVER (", StringComparison.Ordinal)); + } + + [Fact] + public async Task Should_translate_function_with_1_parameter() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Name, + e.RequiredName, + Count = EF.Functions.WindowFunction(_count, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Single(t => t.RequiredName == "1").Count.Should().Be(3); + result.Single(t => t.RequiredName == "2").Count.Should().Be(3); + result.Single(t => t.RequiredName == "3").Count.Should().Be(3); + ExecutedCommands.Should().Contain(c => c.Contains("COUNT(", StringComparison.Ordinal) && !c.Contains("COUNT (", StringComparison.Ordinal)); + } + + [Fact] + public async Task Should_translate_function_with_1_parameter_and_ignore_nulls_in_count() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = null, RequiredName = "2" }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.RequiredName, + Count = EF.Functions.WindowFunction(_count, e.Name) + }) + .ToListAsync(); + + result.Should().HaveCount(3); + result.Should().AllSatisfy(t => t.Count.Should().Be(2)); + } + + [Fact] + public async Task Should_translate_function_with_3_partition_by_and_2_order_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2, NullableCount = 1 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "1", Count = 3, NullableCount = 1 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "1", RequiredName = "1", Count = 3, NullableCount = 2 }, + new TestEntity { Id = new Guid("28C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "1", Count = 3, NullableCount = 1 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.Id, + Rank = EF.Functions.WindowFunction(_rank, EF.Functions.PartitionBy(e.Name, e.RequiredName, e.PropertyWithBackingField % 10), EF.Functions.OrderBy(e.Count).ThenByDescending(e.NullableCount)) + }) + .OrderBy(t => t.Id) + .ToListAsync(); + + // Ordered by Id: the partition (1, 1, 0) has the rows Count 3/NullableCount 2 (rank 2), Count 2 (rank 1) and Count 3/NullableCount 1 (rank 3); the partition (2, 1, 0) has a single row (rank 1). + result.Select(t => t.Rank).Should().Equal(2, 1, 1, 3); + } + + [Fact] + public async Task Should_translate_function_with_arguments_partition_by_and_order_by() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", RequiredName = "1", Count = 2 }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", RequiredName = "2", Count = 3 }, + new TestEntity { Id = new Guid("18C13F68-0981-4853-92FC-FB7B2551F70B"), Name = "2", RequiredName = "3", Count = 4 }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + e.RequiredName, + Previous = EF.Functions.WindowFunction(_lag, e.Count, EF.Functions.PartitionBy(e.Name), EF.Functions.OrderBy(e.RequiredName)) + }) + .OrderBy(t => t.RequiredName) + .ToListAsync(); + + result.Select(t => t.Previous).Should().Equal(null, 2, null); + } + + // AVG() of integers is a DECIMAL (e.g. 2.5000). A custom window function returning int is read through the DECIMAL value. + // Pins the behavior: the fractional part is cut off when the result is declared as int. + [Fact] + public async Task Should_truncate_a_fractional_average_if_the_custom_window_function_is_declared_as_int() + { + await SeedAsync(new TestEntity { Id = new Guid("4883F7E0-FC8C-45FF-A579-DF351A3E79BA"), Name = "1", Count = 2, RequiredName = "RequiredName" }, + new TestEntity { Id = new Guid("A9A6E809-1002-493A-BA5D-1483F704CBA9"), Name = "1", Count = 3, RequiredName = "RequiredName" }); + + var result = await ActDbContext.TestEntities + .Select(e => new + { + AsInt = EF.Functions.WindowFunction(_averageInt, e.Count, EF.Functions.PartitionBy(e.Name)), + AsDecimal = EF.Functions.WindowFunction(_averageDecimal, e.Count, EF.Functions.PartitionBy(e.Name)) + }) + .ToListAsync(); + + result.Should().AllSatisfy(t => t.AsDecimal.Should().Be(2.5m)); + result.Should().AllSatisfy(t => t.AsInt.Should().Be(2)); + } + + private async Task SeedAsync(params TestEntity[] entities) + { + ArrangeDbContext.TestEntities.AddRange(entities); + await ArrangeDbContext.SaveChangesAsync(); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Json/ConvertibleClassConverter.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Json/ConvertibleClassConverter.cs new file mode 100644 index 0000000..fadca79 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Json/ConvertibleClassConverter.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture.Json; + +public class ConvertibleClassConverter : JsonConverter +{ + public override ConvertibleClass Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, ConvertibleClass value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.Key); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlContainerFixture.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlContainerFixture.cs new file mode 100644 index 0000000..31fdc28 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlContainerFixture.cs @@ -0,0 +1,55 @@ +using Testcontainers.MySql; + +namespace Thinktecture; + +public class MySqlContainerFixture : IDisposable, IAsyncDisposable, IAsyncLifetime +{ + private readonly MySqlContainer _mySqlContainer; + private bool _isDisposed; + + // Pinned LTS image; the server version used by the EF options must match it. + public const string Image = "mysql:8.4"; + + public static readonly MySqlServerVersion ServerVersion = new(new Version(8, 4, 0)); + + public string ConnectionString => _mySqlContainer.GetConnectionString(); + + public MySqlContainerFixture() + { + _mySqlContainer = BuildContainer(); + } + + private static MySqlContainer BuildContainer() + { + return new MySqlBuilder(Image) + .WithCommand("--local-infile=1") + .WithCleanUp(true) + .Build(); + } + + public async Task InitializeAsync() + { + await _mySqlContainer.StartAsync(); + } + + public void Dispose() + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await DisposeAsync(); + } + + public async ValueTask DisposeAsync() + { + if (_isDisposed) + return; + + _isDisposed = true; + + await _mySqlContainer.StopAsync(); + await _mySqlContainer.DisposeAsync(); + } +} diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlTestDbContext.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlTestDbContext.cs new file mode 100644 index 0000000..956e0a0 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/MySqlTestDbContext.cs @@ -0,0 +1,251 @@ +using System.Text.Json.Serialization; +using Thinktecture.TestDatabaseContext; + +namespace Thinktecture; + +public sealed class MySqlTestDbContext(DbContextOptions options) : DbContext(options) +{ + public Action? ConfigureModel { get; set; } + public Action? Configure { get; set; } + + public DbSet Entities => Set(); + public DbSet CompositeEntities => Set(); + public DbSet QuerySources => Set(); + public DbSet QueryTargets => Set(); + public DbSet Children => Set(); + public DbSet TypedEntities => Set(); + public DbSet Owners => Set(); + public DbSet CollatedEntities => Set(); + public DbSet AutoIncrementEntities => Set(); + + // Shared entities of Thinktecture.EntityFrameworkCore.TestHelpers (same names as in the other provider test projects). + public DbSet TestEntities => Set(); + public DbSet TestEntitiesWithBaseClass => Set(); + public DbSet KeylessEntities => Set(); + public DbSet TestEntitiesWithAutoIncrement => Set(); + public DbSet TestEntitiesWithShadowProperties => Set(); + public DbSet TestEntitiesWithDefaultValues => Set(); + public DbSet TestEntitiesWithDotnetDefaultValues => Set(); + public DbSet TestEntities_Own_Inline => Set(); + public DbSet TestEntities_Own_Inline_Inline => Set(); + public DbSet TestEntities_Own_Inline_SeparateOne => Set(); + public DbSet TestEntities_Own_Inline_SeparateMany => Set(); + public DbSet TestEntities_Own_SeparateOne => Set(); + public DbSet TestEntities_Own_SeparateOne_Inline => Set(); + public DbSet TestEntities_Own_SeparateOne_SeparateOne => Set(); + public DbSet TestEntities_Own_SeparateOne_SeparateMany => Set(); + public DbSet TestEntities_Own_SeparateMany => Set(); + public DbSet TestEntities_Own_SeparateMany_Inline => Set(); + public DbSet TestEntities_Own_SeparateMany_SeparateOne => Set(); + public DbSet TestEntities_Own_SeparateMany_SeparateMany => Set(); + public DbSet TestEntities_with_ComplexType => Set(); + public DbSet TestEntitiesWithJsonColumns => Set(); + public DbSet TestEntitiesWithFlags => Set(); + public DbSet TestEntitiesWithDifferentColumnNames => Set(); + public DbSet TestEntitiesWithCollation => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + Configure?.Invoke(optionsBuilder); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(entity => entity.Name).HasMaxLength(100); + modelBuilder.Entity(builder => + { + builder.Property(entity => entity.Id).ValueGeneratedNever(); + builder.Property(entity => entity.Amount).HasPrecision(18, 4); + builder.Property(entity => entity.Payload).HasColumnType("json"); + builder.Property(entity => entity.CreatedAt).HasColumnType("datetime(6)"); + builder.Property(entity => entity.Status).HasConversion().HasMaxLength(20); + }); + modelBuilder.Entity(builder => + { + builder.Property(entity => entity.Id).ValueGeneratedNever(); + builder.OwnsOne(entity => entity.Address, owned => owned.ToTable("owner_addresses")); + builder.OwnsMany(entity => entity.Phones, owned => + { + owned.ToTable("owner_phones"); + owned.Property("PhoneId"); + owned.HasKey("PhoneId"); + }); + }); + modelBuilder.Entity(builder => + { + builder.Property(entity => entity.Id).ValueGeneratedNever(); + builder.Property(entity => entity.Name).HasMaxLength(100).HasCharSet("utf8mb4").UseCollation("utf8mb4_bin"); + builder.HasIndex(entity => entity.Name); + }); + modelBuilder.Entity().HasOne().WithMany().HasForeignKey(child => child.ParentId); + modelBuilder.Entity().Property(entity => entity.Name).HasMaxLength(255); + modelBuilder.Entity().HasIndex(entity => entity.Name).IsUnique(); + modelBuilder.Entity().HasKey(entity => new { entity.RegionId, entity.ItemId }); + modelBuilder.Entity().Property(entity => entity.Name).HasMaxLength(255); + modelBuilder.Entity().Property(entity => entity.Name).HasMaxLength(255); + modelBuilder.Entity().Property(entity => entity.Name).HasMaxLength(255); + modelBuilder.ConfigureComplexCollectionParameter(); + modelBuilder.ConfigureComplexCollectionParameter(); + modelBuilder.ConfigureTempTableEntity(isKeyless: false, builder => + { + builder.HasKey(entity => entity.Id); + builder.Property(entity => entity.Id).ValueGeneratedNever(); + builder.Property(entity => entity.Name).HasMaxLength(255); + }); + ConfigureSharedEntities(modelBuilder); + ConfigureModel?.Invoke(modelBuilder); + } + + // The shared entities are written for the other providers; where they use provider-specific features, the MySQL equivalent is configured here. + private static void ConfigureSharedEntities(ModelBuilder modelBuilder) + { + modelBuilder.ConfigureScalarCollectionParameter(builder => builder.Property(e => e.Value).HasConversion(c => c.Key, k => new ConvertibleClass(k))); + modelBuilder.ConfigureComplexCollectionParameter(builder => + { + builder.Property(e => e.Column1).HasColumnName("Id"); + builder.Property(e => e.Column2).HasConversion(c => c.Key, k => new ConvertibleClass(k)); + }); + + TestEntity.Configure(modelBuilder); + TestEntityWithBaseClass.Configure(modelBuilder); + KeylessTestEntity.Configure(modelBuilder); + TestEntityWithShadowProperties.Configure(modelBuilder); + TestEntityWithSqlDefaultValues.Configure(modelBuilder); + TestEntityWithDotnetDefaultValues.Configure(modelBuilder); + TestEntity_Owns_Inline.Configure(modelBuilder); + TestEntity_Owns_SeparateOne.Configure(modelBuilder); + TestEntity_Owns_SeparateMany.Configure(modelBuilder); + TestEntity_Owns_SeparateOne_SeparateOne.Configure(modelBuilder); + TestEntity_Owns_SeparateOne_Inline.Configure(modelBuilder); + TestEntity_Owns_SeparateOne_SeparateMany.Configure(modelBuilder); + TestEntity_Owns_Inline_SeparateOne.Configure(modelBuilder); + TestEntity_Owns_Inline_Inline.Configure(modelBuilder); + TestEntity_Owns_Inline_SeparateMany.Configure(modelBuilder); + TestEntity_Owns_SeparateMany_SeparateOne.Configure(modelBuilder); + TestEntity_Owns_SeparateMany_Inline.Configure(modelBuilder); + TestEntity_Owns_SeparateMany_SeparateMany.Configure(modelBuilder); + TestEntityWithComplexType.Configure(modelBuilder); + TestEntityWithDifferentColumnNames.Configure(modelBuilder); + TestEntityWithFlags.Configure(modelBuilder); + + // MySQL: UUID() is an expression default and must be enclosed in parentheses. + modelBuilder.Entity(builder => + { + builder.Property(e => e.Id).HasDefaultValueSql("(UUID())"); + // TEXT columns can't have a literal default (error 1101), varchar can. + builder.Property(e => e.String).HasMaxLength(100); + builder.Property(e => e.NullableString).HasMaxLength(100); + }); + modelBuilder.Entity(builder => + { + builder.Property(e => e.String).HasMaxLength(100); + builder.Property(e => e.NullableString).HasMaxLength(100); + }); + + // MySQL has no jsonb. + modelBuilder.Entity(builder => + { + builder.Ignore(e => e.JsonbColumn); + builder.Property(e => e.JsonColumn).HasColumnType("json"); + }); + + // The shared entity configures a SQL Server collation. + modelBuilder.Entity(builder => + { + builder.Property(e => e.ColumnWithoutCollation).HasMaxLength(100); + builder.Property(e => e.ColumnWithCollation).HasMaxLength(100).HasCharSet("utf8mb4").UseCollation("utf8mb4_bin"); + }); + } +} + +public sealed class MySqlTestEntity +{ + public int Id { get; set; } + public string? Name { get; set; } +} + +public enum MySqlTestStatus +{ + Draft, + Published +} + +public sealed class MySqlAutoIncrementEntity +{ + public int Id { get; set; } + public string? Name { get; set; } +} + +public sealed class MySqlTypedEntity +{ + public Guid Id { get; set; } + public decimal Amount { get; set; } + public string? Payload { get; set; } + public DateTime CreatedAt { get; set; } + public MySqlTestStatus Status { get; set; } + public byte[]? Data { get; set; } + public bool Flag { get; set; } +} + +public sealed class MySqlOwnerEntity +{ + public int Id { get; set; } + public MySqlAddress? Address { get; set; } + public List Phones { get; set; } = []; +} + +public sealed class MySqlAddress +{ + public string? City { get; set; } +} + +public sealed class MySqlPhone +{ + public string? Number { get; set; } +} + +public sealed class MySqlCollatedEntity +{ + public int Id { get; set; } + public string? Name { get; set; } + public int Count { get; set; } +} + +public sealed class MySqlTestChildEntity +{ + public int Id { get; set; } + public int ParentId { get; set; } +} + +public sealed class MySqlCompositeEntity +{ + public int RegionId { get; set; } + public int ItemId { get; set; } + public string? Name { get; set; } +} + +internal sealed class MySqlTestTempEntity +{ + public int Id { get; set; } + public string? Name { get; set; } +} + +public sealed class MySqlQuerySource +{ + public int Id { get; set; } + public string? Name { get; set; } + public int Count { get; set; } +} + +public sealed class MySqlQueryTarget +{ + public int Id { get; set; } + public string? Name { get; set; } + public int Count { get; set; } +} + +internal sealed record MySqlTestComplexParameter(int Id); + +internal sealed record MySqlTestJsonNamedComplexParameter([property: JsonPropertyName("parameter_value")] int Value); diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/TestDatabaseContext/MyParameter.cs b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/TestDatabaseContext/MyParameter.cs new file mode 100644 index 0000000..d054ddd --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/TestDatabaseContext/MyParameter.cs @@ -0,0 +1,3 @@ +namespace Thinktecture.TestDatabaseContext; + +public record MyParameter(Guid Column1, ConvertibleClass Column2); diff --git a/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Thinktecture.EntityFrameworkCore.MySql.Tests.csproj b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Thinktecture.EntityFrameworkCore.MySql.Tests.csproj new file mode 100644 index 0000000..ac2bea5 --- /dev/null +++ b/tests/Thinktecture.EntityFrameworkCore.MySql.Tests/Thinktecture.EntityFrameworkCore.MySql.Tests.csproj @@ -0,0 +1,17 @@ + + + + $(NoWarn);CS1591 + + + + + + + + + + + + + \ No newline at end of file