diff --git a/.editorconfig b/.editorconfig index 133850c..d8982c2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -50,6 +50,12 @@ dotnet_diagnostic.RCS1124.severity = suggestion # IDE0290: Use primary constructor dotnet_diagnostic.IDE0290.severity = none +# CA2100: Review SQL queries for security vulnerabilities +dotnet_diagnostic.CA2100.severity = none + +# RCS1222: Merge preprocessor directives +dotnet_diagnostic.RCS1222.severity = none + [*.{cs,vb}] #### Naming styles #### diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 954fb3a..724859a 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -25,20 +25,31 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 - - name: Dotnet Setup - uses: actions/setup-dotnet@v3 + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.x + dotnet-version: 8.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release - - run: dotnet tool update -g docfx - - run: docfx docs/docfx.json + - name: Install docfx + run: dotnet tool update -g docfx + + - name: Run docfx + run: docfx docs/docfx.json - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: # Upload entire repository path: 'docs/_site' + - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index fea0f5a..e056b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/). - + +## [1.2.0] - 2026-02-14 + +### Added +- Optimistic Concurrency Support via Concurrency Tokens (Fixes [issue #5](https://github.com/rent-a-developer/DbConnectionPlus/issues/5)) + +### Changed +- Switched benchmarks to SQLite for more stable results. + ## [1.1.0] - 2026-02-01 ### Added diff --git a/docs/DESIGN-DECISIONS.md b/DESIGN-DECISIONS.md similarity index 99% rename from docs/DESIGN-DECISIONS.md rename to DESIGN-DECISIONS.md index e678f2c..9ced144 100644 --- a/docs/DESIGN-DECISIONS.md +++ b/DESIGN-DECISIONS.md @@ -1,6 +1,6 @@ # DbConnectionPlus - Design Decisions Document -**Version:** 1.1.0 +**Version:** 1.2.0 **Last Updated:** February 2026 **Author:** David Liebeherr diff --git a/DbConnectionPlus.slnx b/DbConnectionPlus.slnx index bffd51f..8327e94 100644 --- a/DbConnectionPlus.slnx +++ b/DbConnectionPlus.slnx @@ -4,7 +4,7 @@ - + diff --git a/README.md b/README.md index 5a7df58..01d051c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![NuGet Version](https://img.shields.io/nuget/v/RentADeveloper.DbConnectionPlus)](https://www.nuget.org/packages/RentADeveloper.DbConnectionPlus/) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=rent-a-developer_DbConnectionPlus&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=rent-a-developer_DbConnectionPlus) [![license](https://img.shields.io/badge/License-MIT-purple.svg)](LICENSE.md) -![semver](https://img.shields.io/badge/semver-1.1.0-blue) +![semver](https://img.shields.io/badge/semver-1.2.0-blue) # ![image icon](https://raw.githubusercontent.com/rent-a-developer/DbConnectionPlus/main/icon.png) DbConnectionPlus A lightweight .NET ORM and extension library for the type @@ -9,11 +9,14 @@ A lightweight .NET ORM and extension library for the type that adds high-performance, type-safe helpers to reduce boilerplate code, boost productivity, and make working with SQL databases in C# more enjoyable. +If you frequently write SQL queries in your C# code and want to avoid boilerplate code, you will love DbConnectionPlus! + Highlights: -- Parameterized interpolated-string support -- On-the-fly temporary tables from in-memory collections +- [Parameterized interpolated-string support](#parameters-via-interpolated-strings) +- [On-the-fly temporary tables](#on-the-fly-temporary-tables-via-interpolated-strings) from in-memory collections - Entity mapping helpers (insert, update, delete, query) - Designed to be used in synchronous and asynchronous code paths +- Minimal performance and allocation overhead The following database systems are supported out of the box: - MySQL (via [MySqlConnector](https://www.nuget.org/packages/MySqlConnector/)) @@ -182,7 +185,7 @@ This prevents SQL injection and keeps the SQL readable. > `RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle.OracleDatabaseAdapter.AllowTemporaryTables` to `true`. > [!NOTE] -> **Note for MySQL users** +> **Note for MySQL users** > The temporary tables feature of DbConnectionPlus uses `MySqlBulkCopy` to populate temporary tables. > Therefore, the option `AllowLoadLocalInfile=true` must be set in the connection string and the server side > option `local_infile` must be enabled (e.g. via the statement `SET GLOBAL local_infile=1`). @@ -378,7 +381,7 @@ DbConnectionExtensions.Configure(config => ``` > [!NOTE] -> `DbConnectionExtensions.Configure` can only be called once. +> To prevent multi-threading issues `DbConnectionExtensions.Configure` can only be called once during the application lifetime. > After it has been called the configuration of DbConnectionPlus is frozen and cannot be changed anymore. #### EnumSerializationMode @@ -488,9 +491,47 @@ DbConnectionExtensions.Configure(config => config.Entity() .Property(a => a.IsOnSale) .IsIgnored(); + + config.Entity() + .Property(a => a.Version) + .IsRowVersion(); + + config.Entity() + .Property(a => a.ConcurrencyToken) + .IsConcurrencyToken(); }); ``` +###### `Entity()` +Use this method to start configuring the mapping for the entity type `TEntity`. + +###### `ToTable(tableName)` +Use this method to specify the name of the table where entities of the entity type are stored in the database. + +###### `Property(propertyExpression)` +Use this method to start configuring the mapping for a property of the entity type. + +###### `HasColumnName(columnName)` +Use this method to specify the name of the column where the property is stored in the database. + +###### `IsKey()` +Use this method to specify that the property is part of the key by which entities of the entity type are identified. + +###### `IsIdentity()` +Use this method to specify that the property is generated by the database on insert. + +###### `IsComputed()` +Use this method to specify that the property is generated by the database on insert and update. + +###### `IsRowVersion()` +Use this method to specify that the property is a native database-generated concurrency token. + +###### `IsConcurrencyToken()` +Use this method to specify that the property is an application-managed concurrency token. + +###### `IsIgnored()` +Use this method to specify that the property should be ignored and not mapped to a column. + ##### Data annotation attributes You can use the following attributes to configure how entity types are mapped to database tables and columns: @@ -538,6 +579,31 @@ class Product Properties marked with this attribute are ignored (unless `DatabaseGeneratedOption.None` is used) when inserting new entities into the database or updating existing entities. When an entity is inserted or updated, the value of the property is read back from the database and set on the entity. + +###### `System.ComponentModel.DataAnnotations.TimestampAttribute` +Use this attribute to specify that a property of an entity type is a native database-generated concurrency token: +```csharp +class Product +{ + [Timestamp] + public Byte[] Version { get; set; } +} +``` +Properties marked with this attribute will be checked during delete and update operations. +When their values in the database do not match the original values, the delete or update will fail. +When an entity is inserted or updated, the value of the property is read back from the database and set on the entity. + +###### `System.ComponentModel.DataAnnotations.ConcurrencyCheckAttribute` +Use this attribute to specify that a property of an entity type is a application-managed concurrency token: +```csharp +class Product +{ + [ConcurrencyCheck] + public Byte[] ConcurrencyToken { get; set; } +} +``` +Properties marked with this attribute will be checked during delete and update operations. +When their values in the database do not match the original values, the delete or update will fail. ###### `System.ComponentModel.DataAnnotations.Schema.NotMappedAttribute` Use this attribute to specify that a property of an entity type should be ignored and not mapped to a column: @@ -1141,88 +1207,96 @@ See [SqlServerDatabaseAdapter](https://github.com/rent-a-developer/DbConnectionP for an example implementation of a database adapter. ## Benchmarks -DbConnectionPlus is designed to have a minimal performance and allocation overhead compared to using -`DbCommand` manually. +DbConnectionPlus is designed to have a minimal performance and allocation overhead compared to using `DbCommand` +manually. + +All benchmarks are performed using SQLite in-memory databases, which is a worst-case scenario for DbConnectionPlus +because the overhead of using DbConnectionPlus is more noticeable when the executed SQL statements are very fast. ``` + BenchmarkDotNet v0.15.8, Windows 11 (10.0.26100.7623/24H2/2024Update/HudsonValley) 12th Gen Intel Core i9-12900K 3.19GHz, 1 CPU, 24 logical and 16 physical cores .NET SDK 10.0.102 [Host] : .NET 8.0.23 (8.0.23, 8.0.2325.60607), X64 RyuJIT x86-64-v3 - Job-ADQEJE : .NET 8.0.23 (8.0.23, 8.0.2325.60607), X64 RyuJIT x86-64-v3 - -MinIterationTime=100ms OutlierMode=DontRemove Server=True -InvocationCount=1 MaxIterationCount=20 UnrollFactor=1 -WarmupCount=10 -``` + Job-HTGOBL : .NET 8.0.23 (8.0.23, 8.0.2325.60607), X64 RyuJIT x86-64-v3 + Job-CYDVOR : .NET 8.0.23 (8.0.23, 8.0.2325.60607), X64 RyuJIT x86-64-v3 -| Method | Mean | Error | StdDev | Median | P90 | P95 | Ratio | RatioSD | Allocated | Alloc Ratio | -|----------------------------------------------- |-------------:|-------------:|-------------:|-------------:|-------------:|-------------:|-------------:|--------:|----------:|------------:| -| **DeleteEntities_Manually** | **14,672.73 μs** | **3,387.316 μs** | **3,900.839 μs** | **14,243.07 μs** | **19,825.26 μs** | **20,144.38 μs** | **baseline** | **** | **101.62 KB** | **** | -| DeleteEntities_DbConnectionPlus | 6,717.47 μs | 721.336 μs | 830.692 μs | 6,372.96 μs | 7,698.83 μs | 8,539.65 μs | 2.21x faster | 0.62x | 17.17 KB | 5.92x less | -| | | | | | | | | | | | -| **DeleteEntity_Manually** | **188.68 μs** | **24.244 μs** | **27.920 μs** | **198.98 μs** | **212.97 μs** | **217.73 μs** | **baseline** | **** | **2.1 KB** | **** | -| DeleteEntity_DbConnectionPlus | 191.09 μs | 27.642 μs | 31.833 μs | 197.78 μs | 230.76 μs | 235.60 μs | 1.04x slower | 0.24x | 2.1 KB | 1.00x more | -| | | | | | | | | | | | -| **ExecuteNonQuery_Manually** | **158.13 μs** | **24.189 μs** | **27.856 μs** | **157.52 μs** | **169.30 μs** | **178.27 μs** | **baseline** | **** | **2.1 KB** | **** | -| ExecuteNonQuery_DbConnectionPlus | 165.12 μs | 13.165 μs | 15.161 μs | 166.52 μs | 177.50 μs | 180.82 μs | 1.07x slower | 0.19x | 2.81 KB | 1.33x more | -| | | | | | | | | | | | -| **ExecuteReader_Manually** | **183.91 μs** | **9.815 μs** | **11.303 μs** | **179.93 μs** | **203.46 μs** | **211.49 μs** | **baseline** | **** | **50.54 KB** | **** | -| ExecuteReader_DbConnectionPlus | 173.84 μs | 4.810 μs | 5.539 μs | 173.00 μs | 180.74 μs | 186.21 μs | 1.06x faster | 0.07x | 50.83 KB | 1.01x more | -| | | | | | | | | | | | -| **ExecuteScalar_Manually** | **73.79 μs** | **2.411 μs** | **2.777 μs** | **73.54 μs** | **78.35 μs** | **78.58 μs** | **baseline** | **** | **3.04 KB** | **** | -| ExecuteScalar_DbConnectionPlus | 77.81 μs | 5.661 μs | 6.519 μs | 76.63 μs | 81.00 μs | 87.09 μs | 1.06x slower | 0.09x | 3.77 KB | 1.24x more | -| | | | | | | | | | | | -| **Exists_Manually** | **56.36 μs** | **13.725 μs** | **15.806 μs** | **48.61 μs** | **78.16 μs** | **86.30 μs** | **baseline** | **** | **2.63 KB** | **** | -| Exists_DbConnectionPlus | 51.36 μs | 2.946 μs | 3.392 μs | 50.43 μs | 53.15 μs | 55.69 μs | 1.10x faster | 0.31x | 3.34 KB | 1.27x more | -| | | | | | | | | | | | -| **InsertEntities_Manually** | **17,619.46 μs** | **2,472.686 μs** | **2,847.548 μs** | **18,691.91 μs** | **20,290.38 μs** | **20,702.41 μs** | **baseline** | **** | **517.03 KB** | **** | -| InsertEntities_DbConnectionPlus | 21,575.08 μs | 2,280.957 μs | 2,626.754 μs | 23,062.28 μs | 23,656.92 μs | 24,692.07 μs | 1.25x slower | 0.24x | 437.87 KB | 1.18x less | -| | | | | | | | | | | | -| **InsertEntity_Manually** | **256.13 μs** | **16.084 μs** | **18.522 μs** | **257.27 μs** | **264.82 μs** | **285.02 μs** | **baseline** | **** | **8.57 KB** | **** | -| InsertEntity_DbConnectionPlus | 280.06 μs | 37.113 μs | 42.740 μs | 259.51 μs | 341.86 μs | 355.55 μs | 1.10x slower | 0.18x | 8.72 KB | 1.02x more | -| | | | | | | | | | | | -| **Parameter_Manually** | **57.55 μs** | **10.088 μs** | **11.618 μs** | **56.99 μs** | **65.92 μs** | **67.72 μs** | **baseline** | **** | **5.43 KB** | **** | -| Parameter_DbConnectionPlus | 52.35 μs | 5.561 μs | 6.404 μs | 50.31 μs | 55.65 μs | 57.76 μs | 1.11x faster | 0.24x | 7.34 KB | 1.35x more | -| | | | | | | | | | | | -| **Query_Dynamic_Manually** | **315.14 μs** | **12.468 μs** | **14.358 μs** | **312.52 μs** | **327.40 μs** | **333.20 μs** | **baseline** | **** | **195.41 KB** | **** | -| Query_Dynamic_DbConnectionPlus | 203.51 μs | 16.883 μs | 19.442 μs | 197.45 μs | 215.26 μs | 224.23 μs | 1.56x faster | 0.13x | 136.38 KB | 1.43x less | -| | | | | | | | | | | | -| **Query_Scalars_Manually** | **74.03 μs** | **2.179 μs** | **2.510 μs** | **73.53 μs** | **77.74 μs** | **77.97 μs** | **baseline** | **** | **2.11 KB** | **** | -| Query_Scalars_DbConnectionPlus | 90.07 μs | 11.385 μs | 13.111 μs | 89.36 μs | 102.01 μs | 104.18 μs | 1.22x slower | 0.18x | 7.26 KB | 3.44x more | -| | | | | | | | | | | | -| **Query_Entities_Manually** | **251.81 μs** | **6.020 μs** | **6.933 μs** | **250.85 μs** | **260.06 μs** | **262.91 μs** | **baseline** | **** | **51.3 KB** | **** | -| Query_Entities_DbConnectionPlus | 263.71 μs | 6.792 μs | 7.822 μs | 260.52 μs | 271.74 μs | 274.68 μs | 1.05x slower | 0.04x | 54.37 KB | 1.06x more | -| | | | | | | | | | | | -| **Query_ValueTuples_Manually** | **180.00 μs** | **8.115 μs** | **9.345 μs** | **177.02 μs** | **185.67 μs** | **194.46 μs** | **baseline** | **** | **18.07 KB** | **** | -| Query_ValueTuples_DbConnectionPlus | 190.84 μs | 9.986 μs | 11.499 μs | 188.72 μs | 200.44 μs | 217.74 μs | 1.06x slower | 0.08x | 29.45 KB | 1.63x more | -| | | | | | | | | | | | -| **TemporaryTable_ComplexObjects_Manually** | **8,267.76 μs** | **2,480.979 μs** | **2,857.099 μs** | **7,983.17 μs** | **11,502.49 μs** | **11,944.48 μs** | **baseline** | **** | **132.52 KB** | **** | -| TemporaryTable_ComplexObjects_DbConnectionPlus | 6,636.36 μs | 614.018 μs | 707.104 μs | 6,582.66 μs | 7,309.96 μs | 7,595.85 μs | 1.26x faster | 0.44x | 137.92 KB | 1.04x more | -| | | | | | | | | | | | -| **TemporaryTable_ScalarValues_Manually** | **4,784.75 μs** | **566.815 μs** | **652.745 μs** | **4,620.07 μs** | **4,950.02 μs** | **5,609.07 μs** | **baseline** | **** | **177.18 KB** | **** | -| TemporaryTable_ScalarValues_DbConnectionPlus | 4,897.28 μs | 393.307 μs | 452.933 μs | 4,735.95 μs | 5,696.50 μs | 5,701.09 μs | 1.04x slower | 0.14x | 304.21 KB | 1.72x more | -| | | | | | | | | | | | -| **UpdateEntities_Manually** | **23,744.24 μs** | **3,367.021 μs** | **3,877.466 μs** | **22,203.37 μs** | **30,059.37 μs** | **32,188.80 μs** | **baseline** | **** | **530.26 KB** | **** | -| UpdateEntities_DbConnectionPlus | 34,624.61 μs | 3,734.617 μs | 4,300.790 μs | 34,084.29 μs | 35,478.88 μs | 39,301.47 μs | 1.49x slower | 0.28x | 450.27 KB | 1.18x less | -| | | | | | | | | | | | -| **UpdateEntity_Manually** | **300.87 μs** | **28.337 μs** | **32.633 μs** | **291.67 μs** | **350.76 μs** | **366.50 μs** | **baseline** | **** | **9.5 KB** | **** | -| UpdateEntity_DbConnectionPlus | 344.98 μs | 49.278 μs | 56.749 μs | 356.24 μs | 393.93 μs | 408.69 μs | 1.16x slower | 0.22x | 9.67 KB | 1.02x more | - -Please keep in mind that benchmarking is tricky when SQL Server is involved. -So take these benchmark results with a grain of salt. +Server=True MaxIterationCount=20 -### Running the benchmarks -To run the benchmarks, ensure you have an SQL Server instance available. -The benchmarks will create a database named `DbConnectionPlusTests`, so make sure your SQL user has the necessary -rights. - -Set the environment variable `ConnectionString_SqlServer` to the connection string to the SQL Server instance: -```shell -set ConnectionString_SqlServer="Data Source=.\SqlServer;Integrated Security=True;Encrypt=False;MultipleActiveResultSets=True" ``` +| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | +|----------------------------------------------- |---------------:|--------------:|--------------:|-------------:|--------:|--------:|-----------:|------------:| +| **DeleteEntities_DbCommand** | **223,628.340 μs** | **3,095.0138 μs** | **2,895.0778 μs** | **baseline** | **** | **-** | **1412000 B** | **** | +| DeleteEntities_Dapper | 218,830.227 μs | 3,503.0632 μs | 3,276.7675 μs | 1.02x faster | 0.02x | - | 2710584 B | 1.92x more | +| DeleteEntities_DbConnectionPlus | 225,066.323 μs | 3,714.7493 μs | 3,474.7788 μs | 1.01x slower | 0.02x | - | 2560160 B | 1.81x more | +| | | | | | | | | | +| **DeleteEntity_DbCommand** | **73.778 μs** | **0.6793 μs** | **0.6022 μs** | **baseline** | **** | **-** | **768 B** | **** | +| DeleteEntity_Dapper | 75.820 μs | 1.1366 μs | 1.0632 μs | 1.03x slower | 0.02x | - | 1704 B | 2.22x more | +| DeleteEntity_DbConnectionPlus | 75.308 μs | 0.9598 μs | 0.8014 μs | 1.02x slower | 0.01x | - | 1312 B | 1.71x more | +| | | | | | | | | | +| **ExecuteNonQuery_DbCommand** | **88.582 μs** | **19.6701 μs** | **22.6521 μs** | **baseline** | **** | **-** | **768 B** | **** | +| ExecuteNonQuery_Dapper | 72.316 μs | 0.9734 μs | 0.9106 μs | 1.23x faster | 0.31x | - | 1072 B | 1.40x more | +| ExecuteNonQuery_DbConnectionPlus | 71.852 μs | 0.9360 μs | 0.8755 μs | 1.23x faster | 0.31x | - | 1704 B | 2.22x more | +| | | | | | | | | | +| **ExecuteReader_DbCommand** | **650.394 μs** | **5.8340 μs** | **5.4571 μs** | **baseline** | **** | **0.9766** | **857945 B** | **** | +| ExecuteReader_Dapper | 649.543 μs | 6.7084 μs | 6.2750 μs | 1.00x faster | 0.01x | 0.9766 | 857417 B | 1.00x less | +| ExecuteReader_DbConnectionPlus | 657.459 μs | 6.0157 μs | 5.6271 μs | 1.01x slower | 0.01x | - | 859169 B | 1.00x more | +| | | | | | | | | | +| **ExecuteScalar_DbCommand** | **2.090 μs** | **0.0199 μs** | **0.0186 μs** | **baseline** | **** | **-** | **1072 B** | **** | +| ExecuteScalar_Dapper | 2.410 μs | 0.0239 μs | 0.0199 μs | 1.15x slower | 0.01x | - | 1464 B | 1.37x more | +| ExecuteScalar_DbConnectionPlus | 2.779 μs | 0.0356 μs | 0.0333 μs | 1.33x slower | 0.02x | 0.0038 | 2128 B | 1.99x more | +| | | | | | | | | | +| **Exists_DbCommand** | **1.824 μs** | **0.0183 μs** | **0.0172 μs** | **baseline** | **** | **-** | **1000 B** | **** | +| Exists_Dapper | 2.102 μs | 0.0165 μs | 0.0138 μs | 1.15x slower | 0.01x | - | 1336 B | 1.34x more | +| Exists_DbConnectionPlus | 2.457 μs | 0.0416 μs | 0.0389 μs | 1.35x slower | 0.02x | 0.0038 | 1944 B | 1.94x more | +| | | | | | | | | | +| **InsertEntities_DbCommand** | **5,739.618 μs** | **29.8202 μs** | **26.4348 μs** | **baseline** | **** | **15.6250** | **8586537 B** | **** | +| InsertEntities_Dapper | 6,495.939 μs | 58.2679 μs | 54.5038 μs | 1.13x slower | 0.01x | 15.6250 | 9602018 B | 1.12x more | +| InsertEntities_DbConnectionPlus | 6,518.117 μs | 56.0311 μs | 52.4115 μs | 1.14x slower | 0.01x | 15.6250 | 9468391 B | 1.10x more | +| | | | | | | | | | +| **InsertEntity_DbCommand** | **32.660 μs** | **0.4831 μs** | **0.4282 μs** | **baseline** | **** | **-** | **45267 B** | **** | +| InsertEntity_Dapper | 43.533 μs | 0.6281 μs | 0.5875 μs | 1.33x slower | 0.02x | - | 60446 B | 1.34x more | +| InsertEntity_DbConnectionPlus | 37.122 μs | 0.4297 μs | 0.3809 μs | 1.14x slower | 0.02x | - | 50039 B | 1.11x more | +| | | | | | | | | | +| **Parameter_DbCommand** | **2.672 μs** | **0.0394 μs** | **0.0350 μs** | **baseline** | **** | **0.0038** | **1864 B** | **** | +| Parameter_Dapper | 3.395 μs | 0.0375 μs | 0.0333 μs | 1.27x slower | 0.02x | - | 2984 B | 1.60x more | +| Parameter_DbConnectionPlus | 4.102 μs | 0.0802 μs | 0.0711 μs | 1.54x slower | 0.03x | 0.0076 | 4464 B | 2.39x more | +| | | | | | | | | | +| **Query_Dynamic_DbCommand** | **793.944 μs** | **6.6057 μs** | **6.1790 μs** | **baseline** | **** | **1.9531** | **975378 B** | **** | +| Query_Dynamic_Dapper | 246.576 μs | 3.5537 μs | 3.3241 μs | 3.22x faster | 0.05x | - | 91560 B | 10.65x less | +| Query_Dynamic_DbConnectionPlus | 323.036 μs | 3.4959 μs | 3.2701 μs | 2.46x faster | 0.03x | - | 149368 B | 6.53x less | +| | | | | | | | | | +| **Query_Entities_DbCommand** | **652.021 μs** | **5.8298 μs** | **5.1680 μs** | **baseline** | **** | **0.9766** | **858825 B** | **** | +| Query_Entities_Dapper | 287.951 μs | 4.6809 μs | 4.3785 μs | 2.26x faster | 0.04x | - | 98056 B | 8.76x less | +| Query_Entities_DbConnectionPlus | 307.558 μs | 3.6913 μs | 3.4528 μs | 2.12x faster | 0.03x | - | 99560 B | 8.63x less | +| | | | | | | | | | +| **Query_Scalars_DbCommand** | **80.531 μs** | **0.6016 μs** | **0.5333 μs** | **baseline** | **** | **-** | **17384 B** | **** | +| Query_Scalars_Dapper | 113.098 μs | 1.2660 μs | 1.1842 μs | 1.40x slower | 0.02x | - | 37072 B | 2.13x more | +| Query_Scalars_DbConnectionPlus | 111.844 μs | 1.3095 μs | 1.2249 μs | 1.39x slower | 0.02x | - | 32464 B | 1.87x more | +| | | | | | | | | | +| **Query_ValueTuples_DbCommand** | **120.574 μs** | **1.3583 μs** | **1.2041 μs** | **baseline** | **** | **-** | **49200 B** | **** | +| Query_ValueTuples_Dapper | 142.430 μs | 1.7777 μs | 1.6628 μs | 1.18x slower | 0.02x | - | 73584 B | 1.50x more | +| Query_ValueTuples_DbConnectionPlus | 145.854 μs | 2.0993 μs | 1.9637 μs | 1.21x slower | 0.02x | - | 58752 B | 1.19x more | +| | | | | | | | | | +| **TemporaryTable_ComplexObjects_DbCommand** | **9,029.496 μs** | **109.0053 μs** | **101.9636 μs** | **baseline** | **** | **-** | **12899278 B** | **** | +| TemporaryTable_ComplexObjects_Dapper | 8,120.420 μs | 106.3880 μs | 99.5154 μs | 1.11x faster | 0.02x | 15.6250 | 10968565 B | 1.18x less | +| TemporaryTable_ComplexObjects_DbConnectionPlus | 9,348.308 μs | 104.4113 μs | 97.6664 μs | 1.04x slower | 0.02x | 15.6250 | 12074972 B | 1.07x less | +| | | | | | | | | | +| **TemporaryTable_ScalarValues_DbCommand** | **5,080.907 μs** | **101.4218 μs** | **99.6098 μs** | **baseline** | **** | **-** | **1723968 B** | **** | +| TemporaryTable_ScalarValues_Dapper | 5,165.566 μs | 48.4921 μs | 45.3595 μs | 1.02x slower | 0.02x | - | 1764568 B | 1.02x more | +| TemporaryTable_ScalarValues_DbConnectionPlus | 6,644.777 μs | 125.8456 μs | 123.5973 μs | 1.31x slower | 0.03x | - | 2806416 B | 1.63x more | +| | | | | | | | | | +| **UpdateEntities_DbCommand** | **3,113.671 μs** | **29.0143 μs** | **27.1400 μs** | **baseline** | **** | **-** | **4325925 B** | **** | +| UpdateEntities_Dapper | 3,467.305 μs | 39.2788 μs | 36.7414 μs | 1.11x slower | 0.01x | 7.8125 | 4872590 B | 1.13x more | +| UpdateEntities_DbConnectionPlus | 3,602.616 μs | 46.1440 μs | 43.1631 μs | 1.16x slower | 0.02x | - | 4766990 B | 1.10x more | +| | | | | | | | | | +| **UpdateEntity_DbCommand** | **35.296 μs** | **0.2973 μs** | **0.2636 μs** | **baseline** | **** | **-** | **45469 B** | **** | +| UpdateEntity_Dapper | 40.460 μs | 0.8024 μs | 0.7113 μs | 1.15x slower | 0.02x | - | 54299 B | 1.19x more | +| UpdateEntity_DbConnectionPlus | 38.464 μs | 0.3082 μs | 0.2883 μs | 1.09x slower | 0.01x | - | 50254 B | 1.11x more | -Then run the following command: +### Running the benchmarks +To run the benchmarks, run the following command: ```shell dotnet run --configuration Release --project benchmarks\DbConnectionPlus.Benchmarks\DbConnectionPlus.Benchmarks.csproj ``` diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.DeleteEntities.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.DeleteEntities.cs new file mode 100644 index 0000000..437accf --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.DeleteEntities.cs @@ -0,0 +1,89 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [IterationCleanup( + Targets = + [ + nameof(DeleteEntities_Command), + nameof(DeleteEntities_Dapper), + nameof(DeleteEntities_DbConnectionPlus) + ] + )] + public void DeleteEntities__Cleanup() => + this.connection.Dispose(); + + [IterationSetup( + Targets = + [ + nameof(DeleteEntities_Command), + nameof(DeleteEntities_Dapper), + nameof(DeleteEntities_DbConnectionPlus) + ] + )] + public void DeleteEntities__Setup() => + this.SetupDatabase(DeleteEntities_EntitiesPerOperation * DeleteEntities_OperationsPerInvoke); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(DeleteEntities_Category)] + public void DeleteEntities_Command() + { + for (var i = 0; i < DeleteEntities_OperationsPerInvoke; i++) + { + using var command = this.connection.CreateCommand(); + command.CommandText = "DELETE FROM Entity WHERE Id = @Id"; + + var idParameter = command.CreateParameter(); + idParameter.ParameterName = "@Id"; + command.Parameters.Add(idParameter); + + var entities = this.entitiesInDb.Take(DeleteEntities_EntitiesPerOperation).ToList(); + + foreach (var entity in entities) + { + idParameter.Value = entity.Id; + + command.ExecuteNonQuery(); + } + + this.entitiesInDb.RemoveRange(0, DeleteEntities_EntitiesPerOperation); + } + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(DeleteEntities_Category)] + public void DeleteEntities_Dapper() + { + for (var i = 0; i < DeleteEntities_OperationsPerInvoke; i++) + { + var entities = this.entitiesInDb.Take(DeleteEntities_EntitiesPerOperation).ToList(); + + SqlMapperExtensions.Delete(this.connection, entities); + + this.entitiesInDb.RemoveRange(0, DeleteEntities_EntitiesPerOperation); + } + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(DeleteEntities_Category)] + public void DeleteEntities_DbConnectionPlus() + { + for (var i = 0; i < DeleteEntities_OperationsPerInvoke; i++) + { + var entities = this.entitiesInDb.Take(DeleteEntities_EntitiesPerOperation).ToList(); + + this.connection.DeleteEntities(entities); + + this.entitiesInDb.RemoveRange(0, DeleteEntities_EntitiesPerOperation); + } + } + + private const String DeleteEntities_Category = "DeleteEntities"; + private const Int32 DeleteEntities_EntitiesPerOperation = 250; + private const Int32 DeleteEntities_OperationsPerInvoke = 20; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.DeleteEntity.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.DeleteEntity.cs new file mode 100644 index 0000000..1907490 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.DeleteEntity.cs @@ -0,0 +1,87 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [IterationCleanup( + Targets = + [ + nameof(DeleteEntity_Command), + nameof(DeleteEntity_Dapper), + nameof(DeleteEntity_DbConnectionPlus) + ] + )] + public void DeleteEntity__Cleanup() => + this.SetupDatabase(DeleteEntity_OperationsPerInvoke); + + [IterationSetup( + Targets = + [ + nameof(DeleteEntity_Command), + nameof(DeleteEntity_Dapper), + nameof(DeleteEntity_DbConnectionPlus) + ] + )] + public void DeleteEntity__Setup() => + this.SetupDatabase(DeleteEntity_OperationsPerInvoke); + + [Benchmark(Baseline = true, OperationsPerInvoke = DeleteEntity_OperationsPerInvoke)] + [BenchmarkCategory(DeleteEntity_Category)] + public void DeleteEntity_Command() + { + for (var i = 0; i < DeleteEntity_OperationsPerInvoke; i++) + { + var entityToDelete = this.entitiesInDb[0]; + + using var command = this.connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entity WHERE Id = @Id"; + + var idParameter = command.CreateParameter(); + + idParameter.ParameterName = "@Id"; + idParameter.Value = entityToDelete.Id; + + command.Parameters.Add(idParameter); + + command.ExecuteNonQuery(); + + this.entitiesInDb.Remove(entityToDelete); + } + } + + [Benchmark(Baseline = false, OperationsPerInvoke = DeleteEntity_OperationsPerInvoke)] + [BenchmarkCategory(DeleteEntity_Category)] + public void DeleteEntity_Dapper() + { + for (var i = 0; i < DeleteEntity_OperationsPerInvoke; i++) + { + var entityToDelete = this.entitiesInDb[0]; + + SqlMapperExtensions.Delete(this.connection, entityToDelete); + + this.entitiesInDb.Remove(entityToDelete); + } + } + + [Benchmark(Baseline = false, OperationsPerInvoke = DeleteEntity_OperationsPerInvoke)] + [BenchmarkCategory(DeleteEntity_Category)] + public void DeleteEntity_DbConnectionPlus() + { + for (var i = 0; i < DeleteEntity_OperationsPerInvoke; i++) + { + var entityToDelete = this.entitiesInDb[0]; + + this.connection.DeleteEntity(entityToDelete); + + this.entitiesInDb.Remove(entityToDelete); + } + } + + private const String DeleteEntity_Category = "DeleteEntity"; + private const Int32 DeleteEntity_OperationsPerInvoke = 8000; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteNonQuery.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteNonQuery.cs new file mode 100644 index 0000000..9dfba35 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteNonQuery.cs @@ -0,0 +1,61 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(ExecuteNonQuery_Command), + nameof(ExecuteNonQuery_Dapper), + nameof(ExecuteNonQuery_DbConnectionPlus) + ] + )] + public void ExecuteNonQuery__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(ExecuteNonQuery_Command), + nameof(ExecuteNonQuery_Dapper), + nameof(ExecuteNonQuery_DbConnectionPlus) + ] + )] + public void ExecuteNonQuery__Setup() => + this.SetupDatabase(0); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(ExecuteNonQuery_Category)] + public void ExecuteNonQuery_Command() + { + using var command = this.connection.CreateCommand(); + + command.CommandText = "DELETE FROM Entity WHERE Id = @Id"; + + var idParameter = command.CreateParameter(); + + idParameter.ParameterName = "@Id"; + idParameter.Value = -1; + + command.Parameters.Add(idParameter); + + command.ExecuteNonQuery(); + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(ExecuteNonQuery_Category)] + public void ExecuteNonQuery_Dapper() => + SqlMapper.Execute(this.connection, "DELETE FROM Entity WHERE Id = @Id", new { Id = -1 }); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(ExecuteNonQuery_Category)] + public void ExecuteNonQuery_DbConnectionPlus() => + this.connection.ExecuteNonQuery($"DELETE FROM Entity WHERE Id = {Parameter(-1)}"); + + private const String ExecuteNonQuery_Category = "ExecuteNonQuery"; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteReader.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteReader.cs new file mode 100644 index 0000000..2d1db3c --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteReader.cs @@ -0,0 +1,85 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(ExecuteReader_Command), + nameof(ExecuteReader_Dapper), + nameof(ExecuteReader_DbConnectionPlus) + ] + )] + public void ExecuteReader__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(ExecuteReader_Command), + nameof(ExecuteReader_Dapper), + nameof(ExecuteReader_DbConnectionPlus) + ] + )] + public void ExecuteReader__Setup() => + this.SetupDatabase(100); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(ExecuteReader_Category)] + public List ExecuteReader_Command() + { + var result = new List(); + + using var command = this.connection.CreateCommand(); + + command.CommandText = "SELECT * FROM Entity"; + + using var dataReader = command.ExecuteReader(); + + while (dataReader.Read()) + { + result.Add(ReadEntity(dataReader)); + } + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(ExecuteReader_Category)] + public List ExecuteReader_Dapper() + { + var result = new List(); + + using var dataReader = SqlMapper.ExecuteReader(this.connection, "SELECT * FROM Entity"); + + while (dataReader.Read()) + { + result.Add(ReadEntity(dataReader)); + } + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(ExecuteReader_Category)] + public List ExecuteReader_DbConnectionPlus() + { + var result = new List(); + + using var dataReader = this.connection.ExecuteReader("SELECT * FROM Entity"); + + while (dataReader.Read()) + { + result.Add(ReadEntity(dataReader)); + } + + return result; + } + + private const String ExecuteReader_Category = "ExecuteReader"; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteScalar.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteScalar.cs new file mode 100644 index 0000000..b56cc05 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.ExecuteScalar.cs @@ -0,0 +1,76 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(ExecuteScalar_Command), + nameof(ExecuteScalar_Dapper), + nameof(ExecuteScalar_DbConnectionPlus) + ] + )] + public void ExecuteScalar__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(ExecuteScalar_Command), + nameof(ExecuteScalar_Dapper), + nameof(ExecuteScalar_DbConnectionPlus) + ] + )] + public void ExecuteScalar__Setup() => + this.SetupDatabase(1); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(ExecuteScalar_Category)] + public String ExecuteScalar_Command() + { + var entity = this.entitiesInDb[0]; + + using var command = this.connection.CreateCommand(); + + command.CommandText = "SELECT StringValue FROM Entity WHERE Id = @Id"; + + var idParameter = command.CreateParameter(); + idParameter.ParameterName = "@Id"; + idParameter.Value = entity.Id; + + command.Parameters.Add(idParameter); + + return (String)command.ExecuteScalar()!; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(ExecuteScalar_Category)] + public String ExecuteScalar_Dapper() + { + var entity = this.entitiesInDb[0]; + + return SqlMapper.ExecuteScalar( + this.connection, + "SELECT StringValue FROM Entity WHERE Id = @Id", + new { entity.Id } + )!; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(ExecuteScalar_Category)] + public String ExecuteScalar_DbConnectionPlus() + { + var entity = this.entitiesInDb[0]; + + return this.connection.ExecuteScalar( + $"SELECT StringValue FROM Entity WHERE Id = {Parameter(entity.Id)}" + ); + } + + private const String ExecuteScalar_Category = "ExecuteScalar"; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Exists.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Exists.cs new file mode 100644 index 0000000..65e6233 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Exists.cs @@ -0,0 +1,77 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(Exists_Command), + nameof(Exists_Dapper), + nameof(Exists_DbConnectionPlus) + ] + )] + public void Exists__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(Exists_Command), + nameof(Exists_Dapper), + nameof(Exists_DbConnectionPlus) + ] + )] + public void Exists__Setup() => + this.SetupDatabase(1); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(Exists_Category)] + public Boolean Exists_Command() + { + var entityId = this.entitiesInDb[0].Id; + + using var command = this.connection.CreateCommand(); + command.CommandText = "SELECT 1 FROM Entity WHERE Id = @Id"; + + var idParameter = command.CreateParameter(); + idParameter.ParameterName = "@Id"; + idParameter.Value = entityId; + + command.Parameters.Add(idParameter); + + using var dataReader = command.ExecuteReader(CommandBehavior.SingleResult | CommandBehavior.SingleRow); + + return dataReader.Read(); + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Exists_Category)] + public Boolean Exists_Dapper() + { + var entityId = this.entitiesInDb[0].Id; + + using var dataReader = SqlMapper.ExecuteReader( + this.connection, + "SELECT 1 FROM Entity WHERE Id = @Id", + new { Id = entityId } + ); + + return dataReader.Read(); + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Exists_Category)] + public Boolean Exists_DbConnectionPlus() + { + var entityId = this.entitiesInDb[0].Id; + + return this.connection.Exists($"SELECT 1 FROM Entity WHERE Id = {Parameter(entityId)}"); + } + + private const String Exists_Category = "Exists"; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.InsertEntities.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.InsertEntities.cs new file mode 100644 index 0000000..86d4e9b --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.InsertEntities.cs @@ -0,0 +1,126 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(InsertEntities_Command), + nameof(InsertEntities_Dapper), + nameof(InsertEntities_DbConnectionPlus) + ] + )] + public void InsertEntities__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(InsertEntities_Command), + nameof(InsertEntities_Dapper), + nameof(InsertEntities_DbConnectionPlus) + ] + )] + public void InsertEntities__Setup() => + this.SetupDatabase(0); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(InsertEntities_Category)] + public void InsertEntities_Command() + { + using var command = this.connection.CreateCommand(); + + command.CommandText = InsertEntitySql; + + var parameters = new Dictionary + { + { "Id", new("Id", null) }, + { "BooleanValue", new("BooleanValue", null) }, + { "BytesValue", new("BytesValue", null) }, + { "ByteValue", new("ByteValue", null) }, + { "CharValue", new("CharValue", null) }, + { "DateTimeValue", new("DateTimeValue", null) }, + { "DecimalValue", new("DecimalValue", null) }, + { "DoubleValue", new("DoubleValue", null) }, + { "EnumValue", new("EnumValue", null) }, + { "GuidValue", new("GuidValue", null) }, + { "Int16Value", new("Int16Value", null) }, + { "Int32Value", new("Int32Value", null) }, + { "Int64Value", new("Int64Value", null) }, + { "SingleValue", new("SingleValue", null) }, + { "StringValue", new("StringValue", null) }, + { "TimeSpanValue", new("TimeSpanValue", null) } + }; + + command.Parameters.AddRange(parameters.Values); + + foreach (var entity in this.insertEntities_entitiesToInsert) + { + PopulateEntityParameters(entity, parameters); + + command.ExecuteNonQuery(); + } + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(InsertEntities_Category)] + public void InsertEntities_Dapper() => + SqlMapperExtensions.Insert(this.connection, this.insertEntities_entitiesToInsert); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(InsertEntities_Category)] + public void InsertEntities_DbConnectionPlus() => + this.connection.InsertEntities(this.insertEntities_entitiesToInsert); + + private readonly List insertEntities_entitiesToInsert = + Generate.Multiple(InsertEntities_EntitiesPerOperation); + + private const String InsertEntities_Category = "InsertEntities"; + private const Int32 InsertEntities_EntitiesPerOperation = 200; + + private const String InsertEntitySql = """ + INSERT INTO Entity + ( + Id, + BooleanValue, + BytesValue, + ByteValue, + CharValue, + DateTimeValue, + DecimalValue, + DoubleValue, + EnumValue, + GuidValue, + Int16Value, + Int32Value, + Int64Value, + SingleValue, + StringValue, + TimeSpanValue + ) + VALUES + ( + @Id, + @BooleanValue, + @BytesValue, + @ByteValue, + @CharValue, + @DateTimeValue, + @DecimalValue, + @DoubleValue, + @EnumValue, + @GuidValue, + @Int16Value, + @Int32Value, + @Int64Value, + @SingleValue, + @StringValue, + @TimeSpanValue + ) + """; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.InsertEntity.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.InsertEntity.cs new file mode 100644 index 0000000..3eaee1b --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.InsertEntity.cs @@ -0,0 +1,79 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(InsertEntity_Command), + nameof(InsertEntity_Dapper), + nameof(InsertEntity_DbConnectionPlus) + ] + )] + public void InsertEntity__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(InsertEntity_Command), + nameof(InsertEntity_Dapper), + nameof(InsertEntity_DbConnectionPlus) + ] + )] + public void InsertEntity__Setup() => + this.SetupDatabase(0); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(InsertEntity_Category)] + public void InsertEntity_Command() + { + using var command = this.connection.CreateCommand(); + + command.CommandText = InsertEntitySql; + + var parameters = new Dictionary + { + { "Id", new("Id", null) }, + { "BooleanValue", new("BooleanValue", null) }, + { "BytesValue", new("BytesValue", null) }, + { "ByteValue", new("ByteValue", null) }, + { "CharValue", new("CharValue", null) }, + { "DateTimeValue", new("DateTimeValue", null) }, + { "DecimalValue", new("DecimalValue", null) }, + { "DoubleValue", new("DoubleValue", null) }, + { "EnumValue", new("EnumValue", null) }, + { "GuidValue", new("GuidValue", null) }, + { "Int16Value", new("Int16Value", null) }, + { "Int32Value", new("Int32Value", null) }, + { "Int64Value", new("Int64Value", null) }, + { "SingleValue", new("SingleValue", null) }, + { "StringValue", new("StringValue", null) }, + { "TimeSpanValue", new("TimeSpanValue", null) } + }; + + command.Parameters.AddRange(parameters.Values); + + PopulateEntityParameters(this.insertEntity_entityToInsert, parameters); + + command.ExecuteNonQuery(); + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(InsertEntity_Category)] + public void InsertEntity_Dapper() => + SqlMapperExtensions.Insert(this.connection, this.insertEntity_entityToInsert); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(InsertEntity_Category)] + public void InsertEntity_DbConnectionPlus() => + this.connection.InsertEntity(this.insertEntity_entityToInsert); + + private readonly BenchmarkEntity insertEntity_entityToInsert = Generate.Single(); + private const String InsertEntity_Category = "InsertEntity"; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Parameter.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Parameter.cs new file mode 100644 index 0000000..8daba2e --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Parameter.cs @@ -0,0 +1,74 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(Parameter_Command), + nameof(Parameter_Dapper), + nameof(Parameter_DbConnectionPlus) + ] + )] + public void Parameter__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(Parameter_Command), + nameof(Parameter_Dapper), + nameof(Parameter_DbConnectionPlus) + ] + )] + public void Parameter__Setup() => + this.SetupDatabase(0); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(Parameter_Category)] + public Int64 Parameter_Command() + { + using var command = this.connection.CreateCommand(); + + command.CommandText = "SELECT @P1 + @P2 + @P3 + @P4 + @P5 + @P6 + @P7 + @P8 + @P9 + @P10"; + + command.Parameters.Add(new("@P1", 1)); + command.Parameters.Add(new("@P2", 2)); + command.Parameters.Add(new("@P3", 3)); + command.Parameters.Add(new("@P4", 4)); + command.Parameters.Add(new("@P5", 5)); + command.Parameters.Add(new("@P6", 6)); + command.Parameters.Add(new("@P7", 7)); + command.Parameters.Add(new("@P8", 8)); + command.Parameters.Add(new("@P9", 9)); + command.Parameters.Add(new("@P10", 10)); + + return (Int64)command.ExecuteScalar()!; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Parameter_Category)] + public Int64 Parameter_Dapper() => + SqlMapper.ExecuteScalar( + this.connection, + "SELECT @P1 + @P2 + @P3 + @P4 + @P5 + @P6 + @P7 + @P8 + @P9 + @P10", + new { P1 = 1, P2 = 2, P3 = 3, P4 = 4, P5 = 5, P6 = 6, P7 = 7, P8 = 8, P9 = 9, P10 = 10 } + ); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Parameter_Category)] + public Int64 Parameter_DbConnectionPlus() => + this.connection.ExecuteScalar( + $""" + SELECT {Parameter(1)} + {Parameter(2)} + {Parameter(3)} + {Parameter(4)} + {Parameter(5)} + + {Parameter(6)} + {Parameter(7)} + {Parameter(8)} + {Parameter(9)} + {Parameter(10)} + """ + ); + + private const String Parameter_Category = "Parameter"; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Dynamic.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Dynamic.cs new file mode 100644 index 0000000..d0c17e6 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Dynamic.cs @@ -0,0 +1,88 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +using DataRow = RentADeveloper.DbConnectionPlus.Dynamic.DataRow; + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(Query_Dynamic_Command), + nameof(Query_Dynamic_Dapper), + nameof(Query_Dynamic_DbConnectionPlus) + ] + )] + public void Query_Dynamic__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(Query_Dynamic_Command), + nameof(Query_Dynamic_Dapper), + nameof(Query_Dynamic_DbConnectionPlus) + ] + )] + public void Query_Dynamic__Setup() => + this.SetupDatabase(Query_Dynamic_EntitiesPerOperation); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(Query_Dynamic_Category)] + public List Query_Dynamic_Command() + { + var entities = new List(); + + using var dataReader = this.connection.ExecuteReader("SELECT * FROM Entity"); + + while (dataReader.Read()) + { + var charBuffer = new Char[1]; + + var ordinal = 0; + + var dictionary = new Dictionary + { + ["Id"] = dataReader.GetInt64(ordinal++), + ["BooleanValue"] = dataReader.GetInt64(ordinal++) == 1, + ["BytesValue"] = (Byte[])dataReader.GetValue(ordinal++), + ["ByteValue"] = dataReader.GetByte(ordinal++), + ["CharValue"] = dataReader.GetChars(ordinal++, 0, charBuffer, 0, 1) == 1 + ? charBuffer[0] + : throw new(), + ["DateTimeValue"] = DateTime.Parse(dataReader.GetString(ordinal++), CultureInfo.InvariantCulture), + ["DecimalValue"] = Decimal.Parse(dataReader.GetString(ordinal++), CultureInfo.InvariantCulture), + ["DoubleValue"] = dataReader.GetDouble(ordinal++), + ["EnumValue"] = Enum.Parse(dataReader.GetString(ordinal++)), + ["GuidValue"] = Guid.Parse(dataReader.GetString(ordinal++)), + ["Int16Value"] = (Int16)dataReader.GetInt64(ordinal++), + ["Int32Value"] = (Int32)dataReader.GetInt64(ordinal++), + ["Int64Value"] = dataReader.GetInt64(ordinal++), + ["SingleValue"] = dataReader.GetFloat(ordinal++), + ["StringValue"] = dataReader.GetString(ordinal++), + ["TimeSpanValue"] = TimeSpan.Parse(dataReader.GetString(ordinal), CultureInfo.InvariantCulture) + }; + + entities.Add(new DataRow(dictionary)); + } + + return entities; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_Dynamic_Category)] + public List Query_Dynamic_Dapper() => + SqlMapper.Query(this.connection, "SELECT * FROM Entity").ToList(); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_Dynamic_Category)] + public List Query_Dynamic_DbConnectionPlus() => + this.connection.Query("SELECT * FROM Entity").ToList(); + + private const String Query_Dynamic_Category = "Query_Dynamic"; + private const Int32 Query_Dynamic_EntitiesPerOperation = 100; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Entities.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Entities.cs new file mode 100644 index 0000000..3909b29 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Entities.cs @@ -0,0 +1,64 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(Query_Entities_Command), + nameof(Query_Entities_Dapper), + nameof(Query_Entities_DbConnectionPlus) + ] + )] + public void Query_Entities__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(Query_Entities_Command), + nameof(Query_Entities_Dapper), + nameof(Query_Entities_DbConnectionPlus) + ] + )] + public void Query_Entities__Setup() => + this.SetupDatabase(Query_Entities_EntitiesPerOperation); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(Query_Entities_Category)] + public List Query_Entities_Command() + { + var result = new List(); + + using var command = this.connection.CreateCommand(); + + command.CommandText = "SELECT * FROM Entity"; + + using var dataReader = command.ExecuteReader(); + + while (dataReader.Read()) + { + result.Add(ReadEntity(dataReader)); + } + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_Entities_Category)] + public List Query_Entities_Dapper() => + SqlMapper.Query(this.connection, "SELECT * FROM Entity").ToList(); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_Entities_Category)] + public List Query_Entities_DbConnectionPlus() => + this.connection.Query("SELECT * FROM Entity").ToList(); + + private const String Query_Entities_Category = "Query_Entities"; + private const Int32 Query_Entities_EntitiesPerOperation = 100; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Scalars.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Scalars.cs new file mode 100644 index 0000000..1a880cf --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_Scalars.cs @@ -0,0 +1,64 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(Query_Scalars_Command), + nameof(Query_Scalars_Dapper), + nameof(Query_Scalars_DbConnectionPlus) + ] + )] + public void Query_Scalars__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(Query_Scalars_Command), + nameof(Query_Scalars_Dapper), + nameof(Query_Scalars_DbConnectionPlus) + ] + )] + public void Query_Scalars__Setup() => + this.SetupDatabase(Query_Scalars_EntitiesPerOperation); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(Query_Scalars_Category)] + public List Query_Scalars_Command() + { + var result = new List(); + + using var command = this.connection.CreateCommand(); + + command.CommandText = "SELECT Id FROM Entity"; + + using var dataReader = command.ExecuteReader(); + + while (dataReader.Read()) + { + result.Add(dataReader.GetInt64(0)); + } + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_Scalars_Category)] + public List Query_Scalars_Dapper() => + SqlMapper.Query(this.connection, "SELECT Id FROM Entity").ToList(); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_Scalars_Category)] + public List Query_Scalars_DbConnectionPlus() => + this.connection.Query("SELECT Id FROM Entity").ToList(); + + private const String Query_Scalars_Category = "Query_Scalars"; + private const Int32 Query_Scalars_EntitiesPerOperation = 600; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_ValueTuples.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_ValueTuples.cs new file mode 100644 index 0000000..d6187b8 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.Query_ValueTuples.cs @@ -0,0 +1,83 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(Query_ValueTuples_Command), + nameof(Query_ValueTuples_Dapper), + nameof(Query_ValueTuples_DbConnectionPlus) + ] + )] + public void Query_ValueTuples__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(Query_ValueTuples_Command), + nameof(Query_ValueTuples_Dapper), + nameof(Query_ValueTuples_DbConnectionPlus) + ] + )] + public void Query_ValueTuples__Setup() => + this.SetupDatabase(Query_ValueTuples_EntitiesPerOperation); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(Query_ValueTuples_Category)] + public List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)> + Query_ValueTuples_Command() + { + var result = new List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)>(); + + using var command = this.connection.CreateCommand(); + + command.CommandText = "SELECT Id, DateTimeValue, EnumValue, StringValue FROM Entity"; + + using var dataReader = command.ExecuteReader(); + + while (dataReader.Read()) + { + result.Add( + ( + dataReader.GetInt64(0), + DateTime.Parse(dataReader.GetString(1), CultureInfo.InvariantCulture), + Enum.Parse(dataReader.GetString(2)), + dataReader.GetString(3) + ) + ); + } + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_ValueTuples_Category)] + public List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)> + Query_ValueTuples_Dapper() => + SqlMapper + .Query<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)>( + this.connection, + "SELECT Id, DateTimeValue, EnumValue, StringValue FROM Entity" + ) + .ToList(); + + [Benchmark(Baseline = false)] + [BenchmarkCategory(Query_ValueTuples_Category)] + public List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)> + Query_ValueTuples_DbConnectionPlus() => + this.connection + .Query<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)>( + "SELECT Id, DateTimeValue, EnumValue, StringValue FROM Entity" + ) + .ToList(); + + private const String Query_ValueTuples_Category = "Query_ValueTuples"; + private const Int32 Query_ValueTuples_EntitiesPerOperation = 150; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.TemporaryTable_ComplexObjects.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.TemporaryTable_ComplexObjects.cs new file mode 100644 index 0000000..1d30eb2 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.TemporaryTable_ComplexObjects.cs @@ -0,0 +1,182 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(TemporaryTable_ComplexObjects_Command), + nameof(TemporaryTable_ComplexObjects_Dapper), + nameof(TemporaryTable_ComplexObjects_DbConnectionPlus) + ] + )] + public void TemporaryTable_ComplexObjects__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(TemporaryTable_ComplexObjects_Command), + nameof(TemporaryTable_ComplexObjects_Dapper), + nameof(TemporaryTable_ComplexObjects_DbConnectionPlus) + ] + )] + public void TemporaryTable_ComplexObjects__Setup() => + this.SetupDatabase(TemporaryTable_ComplexObjects_EntitiesPerOperation); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(TemporaryTable_ComplexObjects_Category)] + public List TemporaryTable_ComplexObjects_Command() + { + var result = new List(); + + using var createTableCommand = this.connection.CreateCommand(); + createTableCommand.CommandText = CreateTempEntitiesTableSql; + createTableCommand.ExecuteNonQuery(); + + using var insertCommand = this.connection.CreateCommand(); + + insertCommand.CommandText = InsertIntoTempEntities; + + var parameters = new Dictionary + { + { "Id", new("Id", null) }, + { "BooleanValue", new("BooleanValue", null) }, + { "BytesValue", new("BytesValue", null) }, + { "ByteValue", new("ByteValue", null) }, + { "CharValue", new("CharValue", null) }, + { "DateTimeValue", new("DateTimeValue", null) }, + { "DecimalValue", new("DecimalValue", null) }, + { "DoubleValue", new("DoubleValue", null) }, + { "EnumValue", new("EnumValue", null) }, + { "GuidValue", new("GuidValue", null) }, + { "Int16Value", new("Int16Value", null) }, + { "Int32Value", new("Int32Value", null) }, + { "Int64Value", new("Int64Value", null) }, + { "SingleValue", new("SingleValue", null) }, + { "StringValue", new("StringValue", null) }, + { "TimeSpanValue", new("TimeSpanValue", null) } + }; + + insertCommand.Parameters.AddRange(parameters.Values); + + foreach (var entity in this.temporaryTable_ComplexObjects_Entities) + { + PopulateEntityParameters(entity, parameters); + + insertCommand.ExecuteNonQuery(); + } + + using var selectCommand = this.connection.CreateCommand(); + + selectCommand.CommandText = "SELECT * FROM temp.Entities"; + + using var dataReader = selectCommand.ExecuteReader(); + + while (dataReader.Read()) + { + result.Add(ReadEntity(dataReader)); + } + + using var dropTableCommand = this.connection.CreateCommand(); + dropTableCommand.CommandText = "DROP TABLE temp.Entities"; + dropTableCommand.ExecuteNonQuery(); + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(TemporaryTable_ComplexObjects_Category)] + public List TemporaryTable_ComplexObjects_Dapper() + { + SqlMapper.Execute(this.connection, CreateTempEntitiesTableSql); + + SqlMapperExtensions.TableNameMapper = _ => "temp.Entities"; + + SqlMapperExtensions.Insert(this.connection, this.temporaryTable_ComplexObjects_Entities); + + var result = SqlMapper.Query(this.connection, "SELECT * FROM temp.Entities").ToList(); + + SqlMapper.Execute(this.connection, "DROP TABLE temp.Entities"); + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(TemporaryTable_ComplexObjects_Category)] + public List TemporaryTable_ComplexObjects_DbConnectionPlus() => + this.connection + .Query($"SELECT * FROM {TemporaryTable(this.temporaryTable_ComplexObjects_Entities)}") + .ToList(); + + private readonly List temporaryTable_ComplexObjects_Entities = + Generate.Multiple(TemporaryTable_ComplexObjects_EntitiesPerOperation); + + private const String CreateTempEntitiesTableSql = """ + CREATE TEMP TABLE Entities ( + Id INTEGER, + BooleanValue INTEGER, + BytesValue BLOB, + ByteValue INTEGER, + CharValue TEXT, + DateTimeValue TEXT, + DecimalValue TEXT, + DoubleValue REAL, + EnumValue TEXT, + GuidValue TEXT, + Int16Value INTEGER, + Int32Value INTEGER, + Int64Value INTEGER, + SingleValue REAL, + StringValue TEXT, + TimeSpanValue TEXT + ) + """; + + private const String InsertIntoTempEntities = """ + INSERT INTO temp.Entities ( + Id, + BooleanValue, + BytesValue, + ByteValue, + CharValue, + DateTimeValue, + DecimalValue, + DoubleValue, + EnumValue, + GuidValue, + Int16Value, + Int32Value, + Int64Value, + SingleValue, + StringValue, + TimeSpanValue + ) + VALUES ( + @Id, + @BooleanValue, + @BytesValue, + @ByteValue, + @CharValue, + @DateTimeValue, + @DecimalValue, + @DoubleValue, + @EnumValue, + @GuidValue, + @Int16Value, + @Int32Value, + @Int64Value, + @SingleValue, + @StringValue, + @TimeSpanValue + ) + """; + + private const String TemporaryTable_ComplexObjects_Category = "TemporaryTable_ComplexObjects"; + private const Int32 TemporaryTable_ComplexObjects_EntitiesPerOperation = 250; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.TemporaryTable_ScalarValues.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.TemporaryTable_ScalarValues.cs new file mode 100644 index 0000000..7f10528 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.TemporaryTable_ScalarValues.cs @@ -0,0 +1,110 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(TemporaryTable_ScalarValues_Command), + nameof(TemporaryTable_ScalarValues_Dapper), + nameof(TemporaryTable_ScalarValues_DbConnectionPlus) + ] + )] + public void TemporaryTable_ScalarValues__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(TemporaryTable_ScalarValues_Command), + nameof(TemporaryTable_ScalarValues_Dapper), + nameof(TemporaryTable_ScalarValues_DbConnectionPlus) + ] + )] + public void TemporaryTable_ScalarValues__Setup() => + this.SetupDatabase(0); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(TemporaryTable_ScalarValues_Category)] + public List TemporaryTable_ScalarValues_Command() + { + using var createTableCommand = this.connection.CreateCommand(); + createTableCommand.CommandText = "CREATE TEMP TABLE \"Values\" (Value INTEGER)"; + createTableCommand.ExecuteNonQuery(); + + using var insertCommand = this.connection.CreateCommand(); + insertCommand.CommandText = "INSERT INTO temp.\"Values\" (Value) VALUES (@Value)"; + + var valueParameter = new SqliteParameter + { + ParameterName = "@Value" + }; + + insertCommand.Parameters.Add(valueParameter); + + foreach (var value in this.temporaryTable_ScalarValues_Values) + { + valueParameter.Value = value; + + insertCommand.ExecuteNonQuery(); + } + + using var selectCommand = this.connection.CreateCommand(); + + selectCommand.CommandText = "SELECT Value FROM temp.\"Values\""; + + using var dataReader = selectCommand.ExecuteReader(); + + var result = new List(); + + while (dataReader.Read()) + { + result.Add(dataReader.GetInt64(0)); + } + + using var dropTableCommand = this.connection.CreateCommand(); + dropTableCommand.CommandText = "DROP TABLE temp.\"Values\""; + dropTableCommand.ExecuteNonQuery(); + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(TemporaryTable_ScalarValues_Category)] + public List TemporaryTable_ScalarValues_Dapper() + { + SqlMapper.Execute(this.connection, "CREATE TEMP TABLE \"Values\" (Value INTEGER)"); + + SqlMapperExtensions.TableNameMapper = _ => "temp.\"Values\""; + + SqlMapperExtensions.Insert( + this.connection, + this.temporaryTable_ScalarValues_Values.Select(a => new { Value = a }) + ); + + var result = SqlMapper.Query(this.connection, "SELECT Value FROM temp.\"Values\"").ToList(); + + SqlMapper.Execute(this.connection, "DROP TABLE temp.\"Values\""); + + return result; + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(TemporaryTable_ScalarValues_Category)] + public List TemporaryTable_ScalarValues_DbConnectionPlus() => + this.connection.Query($"SELECT Value FROM {TemporaryTable(this.temporaryTable_ScalarValues_Values)}") + .ToList(); + + private readonly List temporaryTable_ScalarValues_Values = Enumerable + .Range(0, TemporaryTable_ScalarValues_ValuesPerOperation) + .Select(a => (Int64)a) + .ToList(); + + private const String TemporaryTable_ScalarValues_Category = "TemporaryTable_ScalarValues"; + private const Int32 TemporaryTable_ScalarValues_ValuesPerOperation = 5000; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.UpdateEntities.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.UpdateEntities.cs new file mode 100644 index 0000000..a261885 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.UpdateEntities.cs @@ -0,0 +1,110 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(UpdateEntities_Command), + nameof(UpdateEntities_Dapper), + nameof(UpdateEntities_DbConnectionPlus) + ] + )] + public void UpdateEntities__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(UpdateEntities_Command), + nameof(UpdateEntities_Dapper), + nameof(UpdateEntities_DbConnectionPlus) + ] + )] + public void UpdateEntities__Setup() => + this.SetupDatabase(UpdateEntities_EntitiesPerOperation); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(UpdateEntities_Category)] + public void UpdateEntities_Command() + { + var updatedEntities = Generate.UpdateFor(this.entitiesInDb); + + using var command = this.connection.CreateCommand(); + + command.CommandText = """ + UPDATE Entity + SET BooleanValue = @BooleanValue, + BytesValue = @BytesValue, + ByteValue = @ByteValue, + CharValue = @CharValue, + DateTimeValue = @DateTimeValue, + DecimalValue = @DecimalValue, + DoubleValue = @DoubleValue, + EnumValue = @EnumValue, + GuidValue = @GuidValue, + Int16Value = @Int16Value, + Int32Value = @Int32Value, + Int64Value = @Int64Value, + SingleValue = @SingleValue, + StringValue = @StringValue, + TimeSpanValue = @TimeSpanValue + WHERE Id = @Id + """; + + var parameters = new Dictionary + { + { "Id", new("Id", null) }, + { "BooleanValue", new("BooleanValue", null) }, + { "BytesValue", new("BytesValue", null) }, + { "ByteValue", new("ByteValue", null) }, + { "CharValue", new("CharValue", null) }, + { "DateTimeValue", new("DateTimeValue", null) }, + { "DecimalValue", new("DecimalValue", null) }, + { "DoubleValue", new("DoubleValue", null) }, + { "EnumValue", new("EnumValue", null) }, + { "GuidValue", new("GuidValue", null) }, + { "Int16Value", new("Int16Value", null) }, + { "Int32Value", new("Int32Value", null) }, + { "Int64Value", new("Int64Value", null) }, + { "SingleValue", new("SingleValue", null) }, + { "StringValue", new("StringValue", null) }, + { "TimeSpanValue", new("TimeSpanValue", null) } + }; + + command.Parameters.AddRange(parameters.Values); + + foreach (var updatedEntity in updatedEntities) + { + PopulateEntityParameters(updatedEntity, parameters); + + command.ExecuteNonQuery(); + } + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(UpdateEntities_Category)] + public void UpdateEntities_Dapper() + { + var updatesEntities = Generate.UpdateFor(this.entitiesInDb); + + SqlMapperExtensions.Update(this.connection, updatesEntities); + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(UpdateEntities_Category)] + public void UpdateEntities_DbConnectionPlus() + { + var updatesEntities = Generate.UpdateFor(this.entitiesInDb); + + this.connection.UpdateEntities(updatesEntities); + } + + private const String UpdateEntities_Category = "UpdateEntities"; + private const Int32 UpdateEntities_EntitiesPerOperation = 100; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.UpdateEntity.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.UpdateEntity.cs new file mode 100644 index 0000000..d84c98d --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.UpdateEntity.cs @@ -0,0 +1,112 @@ +// ReSharper disable InvokeAsExtensionMethod +// ReSharper disable InconsistentNaming + +#pragma warning disable RCS1196 + +namespace RentADeveloper.DbConnectionPlus.Benchmarks; + +public partial class Benchmarks +{ + [GlobalCleanup( + Targets = + [ + nameof(UpdateEntity_Command), + nameof(UpdateEntity_Dapper), + nameof(UpdateEntity_DbConnectionPlus) + ] + )] + public void UpdateEntity__Cleanup() => + this.connection.Dispose(); + + [GlobalSetup( + Targets = + [ + nameof(UpdateEntity_Command), + nameof(UpdateEntity_Dapper), + nameof(UpdateEntity_DbConnectionPlus) + ] + )] + public void UpdateEntity__Setup() => + this.SetupDatabase(1); + + [Benchmark(Baseline = true)] + [BenchmarkCategory(UpdateEntity_Category)] + public void UpdateEntity_Command() + { + var entity = this.entitiesInDb[0]; + + var updatedEntity = Generate.UpdateFor(entity); + + using var command = this.connection.CreateCommand(); + + command.CommandText = """ + UPDATE Entity + SET BooleanValue = @BooleanValue, + BytesValue = @BytesValue, + ByteValue = @ByteValue, + CharValue = @CharValue, + DateTimeValue = @DateTimeValue, + DecimalValue = @DecimalValue, + DoubleValue = @DoubleValue, + EnumValue = @EnumValue, + GuidValue = @GuidValue, + Int16Value = @Int16Value, + Int32Value = @Int32Value, + Int64Value = @Int64Value, + SingleValue = @SingleValue, + StringValue = @StringValue, + TimeSpanValue = @TimeSpanValue + WHERE Id = @Id + """; + + var parameters = new Dictionary + { + { "Id", new("Id", null) }, + { "BooleanValue", new("BooleanValue", null) }, + { "BytesValue", new("BytesValue", null) }, + { "ByteValue", new("ByteValue", null) }, + { "CharValue", new("CharValue", null) }, + { "DateTimeValue", new("DateTimeValue", null) }, + { "DecimalValue", new("DecimalValue", null) }, + { "DoubleValue", new("DoubleValue", null) }, + { "EnumValue", new("EnumValue", null) }, + { "GuidValue", new("GuidValue", null) }, + { "Int16Value", new("Int16Value", null) }, + { "Int32Value", new("Int32Value", null) }, + { "Int64Value", new("Int64Value", null) }, + { "SingleValue", new("SingleValue", null) }, + { "StringValue", new("StringValue", null) }, + { "TimeSpanValue", new("TimeSpanValue", null) } + }; + + command.Parameters.AddRange(parameters.Values); + + PopulateEntityParameters(updatedEntity, parameters); + + command.ExecuteNonQuery(); + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(UpdateEntity_Category)] + public void UpdateEntity_Dapper() + { + var entity = this.entitiesInDb[0]; + + var updatedEntity = Generate.UpdateFor(entity); + + SqlMapperExtensions.Update(this.connection, updatedEntity); + } + + [Benchmark(Baseline = false)] + [BenchmarkCategory(UpdateEntity_Category)] + public void UpdateEntity_DbConnectionPlus() + { + var entity = this.entitiesInDb[0]; + + var updatedEntity = Generate.UpdateFor(entity); + + this.connection.UpdateEntity(updatedEntity); + } + + private const String UpdateEntity_Category = "UpdateEntity"; +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.cs b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.cs index 06797d5..b135666 100644 --- a/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.cs +++ b/benchmarks/DbConnectionPlus.Benchmarks/Benchmarks.cs @@ -1,1644 +1,108 @@ -// @formatter:off -// ReSharper disable InconsistentNaming -#pragma warning disable IDE0017, IDE0305 +namespace RentADeveloper.DbConnectionPlus.Benchmarks; -using System.Dynamic; -using BenchmarkDotNet.Attributes; -using FastMember; -using Microsoft.Data.SqlClient; -using RentADeveloper.DbConnectionPlus.Entities; -using RentADeveloper.DbConnectionPlus.IntegrationTests.TestDatabase; -using RentADeveloper.DbConnectionPlus.Readers; -using RentADeveloper.DbConnectionPlus.UnitTests.TestData; -using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; - -namespace RentADeveloper.DbConnectionPlus.Benchmarks; - -// Note: All settings (i.e. *_EntitiesPerOperation and *_OperationsPerInvoke) are chosen so that each invoke +// Note: All benchmark settings (i.e. *_EntitiesPerOperation and *_OperationsPerInvoke) are chosen so that each invoke // takes at least 100 milliseconds to complete on a reasonably fast machine. [MemoryDiagnoser] [Config(typeof(BenchmarksConfig))] -public class Benchmarks +public partial class Benchmarks { - [GlobalSetup] - public void Setup_Global() + static Benchmarks() { - this.testDatabaseProvider.ResetDatabase(); - - // Warm up connection pool. - for (var i = 0; i < 20; i++) - { - using var warmUpConnection = this.CreateConnection(); - warmUpConnection.ExecuteScalar("SELECT 1"); - } - - using var connection = this.CreateConnection(); - connection.ExecuteNonQuery("CHECKPOINT"); - connection.ExecuteNonQuery("DBCC DROPCLEANBUFFERS"); - connection.ExecuteNonQuery("DBCC FREEPROCCACHE"); + SqlMapper.AddTypeHandler(new GuidTypeHandler()); + SqlMapper.AddTypeHandler(new TimeSpanTypeHandler()); } - private SqlConnection CreateConnection() => - (SqlConnection)this.testDatabaseProvider.CreateConnection(); + public Benchmarks() => + SqlMapperExtensions.TableNameMapper = null; - private void PrepareEntitiesInDb(Int32 numberOfEntities) + private void SetupDatabase(Int32 numberOfEntities) { - using var connection = this.CreateConnection(); + this.connection = new("Data Source=:memory:"); + this.connection.Open(); - using var transaction = connection.BeginTransaction(); + using var createEntityTableCommand = this.connection.CreateCommand(); + createEntityTableCommand.CommandText = CreateEntityTableSql; + createEntityTableCommand.ExecuteNonQuery(); - connection.ExecuteNonQuery("DELETE FROM Entity", transaction); + using var transaction = this.connection.BeginTransaction(); - this.entitiesInDb = Generate.Multiple(numberOfEntities); - connection.InsertEntities(this.entitiesInDb, transaction); + this.entitiesInDb = Generate.Multiple(numberOfEntities); + this.connection.InsertEntities(this.entitiesInDb, transaction); transaction.Commit(); } - private List entitiesInDb = []; - - #region DeleteEntities - private const String DeleteEntities_Category = "DeleteEntities"; - private const Int32 DeleteEntities_EntitiesPerOperation = 100; - private const Int32 DeleteEntities_OperationsPerInvoke = 20; - - [IterationSetup(Targets = [nameof(DeleteEntities_Manually), nameof(DeleteEntities_DbConnectionPlus)])] - public void DeleteEntities_Setup() => - this.PrepareEntitiesInDb(DeleteEntities_OperationsPerInvoke * DeleteEntities_EntitiesPerOperation); - - [Benchmark(Baseline = true, OperationsPerInvoke = DeleteEntities_OperationsPerInvoke)] - [BenchmarkCategory(DeleteEntities_Category)] - public void DeleteEntities_Manually() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < DeleteEntities_OperationsPerInvoke; i++) - { - using var command = connection.CreateCommand(); - command.CommandText = "DELETE FROM Entity WHERE Id = @Id"; - - var idParameter = new SqlParameter(); - idParameter.ParameterName = "@Id"; - command.Parameters.Add(idParameter); - - foreach (var entity in this.entitiesInDb.Take(DeleteEntities_EntitiesPerOperation).ToList()) - { - idParameter.Value = entity.Id; - - command.ExecuteNonQuery(); - - this.entitiesInDb.Remove(entity); - } - } - } - - [Benchmark(Baseline = false, OperationsPerInvoke = DeleteEntities_OperationsPerInvoke)] - [BenchmarkCategory(DeleteEntities_Category)] - public void DeleteEntities_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < DeleteEntities_OperationsPerInvoke; i++) - { - var entities = this.entitiesInDb.Take(DeleteEntities_EntitiesPerOperation).ToList(); - - connection.DeleteEntities(entities); - - foreach (var entity in entities) - { - this.entitiesInDb.Remove(entity); - } - } - } - #endregion DeleteEntities - - #region DeleteEntity - private const String DeleteEntity_Category = "DeleteEntity"; - private const Int32 DeleteEntity_OperationsPerInvoke = 1200; - - [IterationSetup(Targets = [nameof(DeleteEntity_Manually), nameof(DeleteEntity_DbConnectionPlus)])] - public void DeleteEntity_Setup() => - this.PrepareEntitiesInDb(DeleteEntity_OperationsPerInvoke); - - [Benchmark(Baseline = true, OperationsPerInvoke = DeleteEntity_OperationsPerInvoke)] - [BenchmarkCategory(DeleteEntity_Category)] - public void DeleteEntity_Manually() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < DeleteEntity_OperationsPerInvoke; i++) - { - var entityToDelete = this.entitiesInDb[0]; - - using var command = connection.CreateCommand(); - - command.CommandText = "DELETE FROM Entity WHERE Id = @Id"; - command.Parameters.Add(new("@Id", entityToDelete.Id)); - - command.ExecuteNonQuery(); - - this.entitiesInDb.Remove(entityToDelete); - } - } - - [Benchmark(Baseline = false, OperationsPerInvoke = DeleteEntity_OperationsPerInvoke)] - [BenchmarkCategory(DeleteEntity_Category)] - public void DeleteEntity_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < DeleteEntity_OperationsPerInvoke; i++) - { - var entityToDelete = this.entitiesInDb[0]; - - connection.DeleteEntity(entityToDelete); - - this.entitiesInDb.Remove(entityToDelete); - } - } - #endregion DeleteEntity - - #region ExecuteNonQuery - private const String ExecuteNonQuery_Category = "ExecuteNonQuery"; - private const Int32 ExecuteNonQuery_OperationsPerInvoke = 1100; - - [IterationSetup(Targets = [nameof(ExecuteNonQuery_Manually), nameof(ExecuteNonQuery_DbConnectionPlus)])] - public void ExecuteNonQuery_Setup() => - this.PrepareEntitiesInDb(ExecuteNonQuery_OperationsPerInvoke); - - [Benchmark(Baseline = true, OperationsPerInvoke = ExecuteNonQuery_OperationsPerInvoke)] - [BenchmarkCategory(ExecuteNonQuery_Category)] - public void ExecuteNonQuery_Manually() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < ExecuteNonQuery_OperationsPerInvoke; i++) - { - var entity = this.entitiesInDb[0]; - - using var command = connection.CreateCommand(); - - command.CommandText = "DELETE FROM Entity WHERE Id = @Id"; - command.Parameters.Add(new("@Id", entity.Id)); - - command.ExecuteNonQuery(); - - this.entitiesInDb.Remove(entity); - } - } - - [Benchmark(Baseline = false, OperationsPerInvoke = ExecuteNonQuery_OperationsPerInvoke)] - [BenchmarkCategory(ExecuteNonQuery_Category)] - public void ExecuteNonQuery_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < ExecuteNonQuery_OperationsPerInvoke; i++) - { - var entity = this.entitiesInDb[0]; - - connection.ExecuteNonQuery($"DELETE FROM Entity WHERE Id = {Parameter(entity.Id)}"); - - this.entitiesInDb.Remove(entity); - } - } - #endregion ExecuteNonQuery - - #region ExecuteReader - private const String ExecuteReader_Category = "ExecuteReader"; - private const Int32 ExecuteReader_OperationsPerInvoke = 700; - private const Int32 ExecuteReader_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [nameof(ExecuteReader_Manually), nameof(ExecuteReader_DbConnectionPlus)])] - public void ExecuteReader_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(ExecuteReader_EntitiesPerOperation); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = ExecuteReader_OperationsPerInvoke)] - [BenchmarkCategory(ExecuteReader_Category)] - public List ExecuteReader_Manually() - { - using var connection = this.CreateConnection(); - - var entities = new List(); - - for (var i = 0; i < ExecuteReader_OperationsPerInvoke; i++) - { - entities.Clear(); - - using var command = connection.CreateCommand(); - command.CommandText = $""" - SELECT - TOP ({ExecuteReader_EntitiesPerOperation}) - [Id], - [BooleanValue], - [ByteValue], - [CharValue], - [DateOnlyValue], - [DateTimeValue], - [DecimalValue], - [DoubleValue], - [EnumValue], - [GuidValue], - [Int16Value], - [Int32Value], - [Int64Value], - [SingleValue], - [StringValue], - [TimeOnlyValue], - [TimeSpanValue] - FROM - Entity - """; - - using var dataReader = command.ExecuteReader(); - - while (dataReader.Read()) - { - var charBuffer = new Char[1]; - - var ordinal = 0; - entities.Add(new() - { - Id = dataReader.GetInt64(ordinal++), - BooleanValue = dataReader.GetBoolean(ordinal++), - ByteValue = dataReader.GetByte(ordinal++), - CharValue = dataReader.GetChars(ordinal++, 0, charBuffer, 0, 1) == 1 ? charBuffer[0] : throw new(), - DateOnlyValue = DateOnly.FromDateTime((DateTime) dataReader.GetValue(ordinal++)), - DateTimeValue = dataReader.GetDateTime(ordinal++), - DecimalValue = dataReader.GetDecimal(ordinal++), - DoubleValue = dataReader.GetDouble(ordinal++), - EnumValue = Enum.Parse(dataReader.GetString(ordinal++)), - GuidValue = dataReader.GetGuid(ordinal++), - Int16Value = dataReader.GetInt16(ordinal++), - Int32Value = dataReader.GetInt32(ordinal++), - Int64Value = dataReader.GetInt64(ordinal++), - SingleValue = dataReader.GetFloat(ordinal++), - StringValue = dataReader.GetString(ordinal++), - TimeOnlyValue = TimeOnly.FromTimeSpan((TimeSpan)dataReader.GetValue(ordinal++)), - TimeSpanValue = (TimeSpan)dataReader.GetValue(ordinal) - }); - } - } - - return entities; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = ExecuteReader_OperationsPerInvoke)] - [BenchmarkCategory(ExecuteReader_Category)] - public List ExecuteReader_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - var entities = new List(); - - for (var i = 0; i < ExecuteReader_OperationsPerInvoke; i++) - { - entities.Clear(); - - using var dataReader = connection.ExecuteReader( - $""" - SELECT - TOP ({ExecuteReader_EntitiesPerOperation}) - [Id], - [BooleanValue], - [ByteValue], - [CharValue], - [DateOnlyValue], - [DateTimeValue], - [DecimalValue], - [DoubleValue], - [EnumValue], - [GuidValue], - [Int16Value], - [Int32Value], - [Int64Value], - [SingleValue], - [StringValue], - [TimeOnlyValue], - [TimeSpanValue] - FROM - Entity - """ - ); - - while (dataReader.Read()) - { - var charBuffer = new Char[1]; - - var ordinal = 0; - entities.Add(new() - { - Id = dataReader.GetInt64(ordinal++), - BooleanValue = dataReader.GetBoolean(ordinal++), - ByteValue = dataReader.GetByte(ordinal++), - CharValue = dataReader.GetChars(ordinal++, 0, charBuffer, 0, 1) == 1 ? charBuffer[0] : throw new(), - DateOnlyValue = DateOnly.FromDateTime((DateTime) dataReader.GetValue(ordinal++)), - DateTimeValue = dataReader.GetDateTime(ordinal++), - DecimalValue = dataReader.GetDecimal(ordinal++), - DoubleValue = dataReader.GetDouble(ordinal++), - EnumValue = Enum.Parse(dataReader.GetString(ordinal++)), - GuidValue = dataReader.GetGuid(ordinal++), - Int16Value = dataReader.GetInt16(ordinal++), - Int32Value = dataReader.GetInt32(ordinal++), - Int64Value = dataReader.GetInt64(ordinal++), - SingleValue = dataReader.GetFloat(ordinal++), - StringValue = dataReader.GetString(ordinal++), - TimeOnlyValue = TimeOnly.FromTimeSpan((TimeSpan)dataReader.GetValue(ordinal++)), - TimeSpanValue = (TimeSpan)dataReader.GetValue(ordinal) - }); - } - } - - return entities; - } - #endregion ExecuteReader - - #region ExecuteScalar - private const String ExecuteScalar_Category = "ExecuteScalar"; - private const Int32 ExecuteScalar_OperationsPerInvoke = 5000; - - [GlobalSetup(Targets = [nameof(ExecuteScalar_Manually), nameof(ExecuteScalar_DbConnectionPlus)])] - public void ExecuteScalar_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(ExecuteScalar_OperationsPerInvoke); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = ExecuteScalar_OperationsPerInvoke)] - [BenchmarkCategory(ExecuteScalar_Category)] - public String ExecuteScalar_Manually() - { - using var connection = this.CreateConnection(); - - String result = null!; - - for (var i = 0; i < ExecuteScalar_OperationsPerInvoke; i++) - { - var entity = this.entitiesInDb[i]; - - using var command = connection.CreateCommand(); - - command.CommandText = "SELECT StringValue FROM Entity WHERE Id = @Id"; - command.Parameters.Add(new("@Id", entity.Id)); - - result = (String)command.ExecuteScalar()!; - } - - return result; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = ExecuteScalar_OperationsPerInvoke)] - [BenchmarkCategory(ExecuteScalar_Category)] - public String ExecuteScalar_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - String result = null!; - - for (var i = 0; i < ExecuteScalar_OperationsPerInvoke; i++) - { - var entity = this.entitiesInDb[i]; - - result = connection.ExecuteScalar( - $"SELECT StringValue FROM Entity WHERE Id = {Parameter(entity.Id)}" - ); - } - - return result; - } - #endregion ExecuteScalar - - #region Exists - private const String Exists_Category = "Exists"; - private const Int32 Exists_OperationsPerInvoke = 5000; - - [GlobalSetup(Targets = [nameof(Exists_Manually), nameof(Exists_DbConnectionPlus)])] - public void Exists_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(Exists_OperationsPerInvoke); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = Exists_OperationsPerInvoke)] - [BenchmarkCategory(Exists_Category)] - public Boolean Exists_Manually() - { - using var connection = this.CreateConnection(); - - var result = false; - - for (var i = 0; i < Exists_OperationsPerInvoke; i++) - { - var entityId = this.entitiesInDb[i].Id; - - using var command = connection.CreateCommand(); - command.CommandText = "SELECT 1 FROM Entity WHERE Id = @Id"; - command.Parameters.Add(new("@Id", entityId)); - - using var dataReader = command.ExecuteReader(); - - result = dataReader.HasRows; - } - - return result; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = Exists_OperationsPerInvoke)] - [BenchmarkCategory(Exists_Category)] - public Boolean Exists_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - var result = false; - - for (var i = 0; i < Exists_OperationsPerInvoke; i++) - { - var entityId = this.entitiesInDb[i].Id; - - result = connection.Exists($"SELECT 1 FROM Entity WHERE Id = {Parameter(entityId)}"); - } - - return result; - } - #endregion Exists - - #region InsertEntities - private const String InsertEntities_Category = "InsertEntities"; - private const Int32 InsertEntities_OperationsPerInvoke = 20; - private const Int32 InsertEntities_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [nameof(InsertEntities_Manually), nameof(InsertEntities_DbConnectionPlus)])] - public void InsertEntities_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(0); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = InsertEntities_OperationsPerInvoke)] - [BenchmarkCategory(InsertEntities_Category)] - public void InsertEntities_Manually() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < InsertEntities_OperationsPerInvoke; i++) - { - var entities = Generate.Multiple(InsertEntities_EntitiesPerOperation); - - using var command = connection.CreateCommand(); - command.CommandText = """ - INSERT INTO [Entity] - ( - [Id], - [BooleanValue], - [ByteValue], - [CharValue], - [DateOnlyValue], - [DateTimeValue], - [DecimalValue], - [DoubleValue], - [EnumValue], - [GuidValue], - [Int16Value], - [Int32Value], - [Int64Value], - [SingleValue], - [StringValue], - [TimeOnlyValue], - [TimeSpanValue] - ) - VALUES - ( - @Id, - @BooleanValue, - @ByteValue, - @CharValue, - @DateOnlyValue, - @DateTimeValue, - @DecimalValue, - @DoubleValue, - @EnumValue, - @GuidValue, - @Int16Value, - @Int32Value, - @Int64Value, - @SingleValue, - @StringValue, - @TimeOnlyValue, - @TimeSpanValue - ) - """; - - var idParameter = new SqlParameter(); - idParameter.ParameterName = "@Id"; - - var booleanValueParameter = new SqlParameter(); - booleanValueParameter.ParameterName = "@BooleanValue"; - - var byteValueParameter = new SqlParameter(); - byteValueParameter.ParameterName = "@ByteValue"; - - var charValueParameter = new SqlParameter(); - charValueParameter.ParameterName = "@CharValue"; - - var dateOnlyParameter = new SqlParameter(); - dateOnlyParameter.ParameterName = "@DateOnlyValue"; - - var dateTimeValueParameter = new SqlParameter(); - dateTimeValueParameter.ParameterName = "@DateTimeValue"; - - var decimalValueParameter = new SqlParameter(); - decimalValueParameter.ParameterName = "@DecimalValue"; - - var doubleValueParameter = new SqlParameter(); - doubleValueParameter.ParameterName = "@DoubleValue"; - - var enumValueParameter = new SqlParameter(); - enumValueParameter.ParameterName = "@EnumValue"; - - var guidValueParameter = new SqlParameter(); - guidValueParameter.ParameterName = "@GuidValue"; - - var int16ValueParameter = new SqlParameter(); - int16ValueParameter.ParameterName = "@Int16Value"; - - var int32ValueParameter = new SqlParameter(); - int32ValueParameter.ParameterName = "@Int32Value"; - - var int64ValueParameter = new SqlParameter(); - int64ValueParameter.ParameterName = "@Int64Value"; - - var singleValueParameter = new SqlParameter(); - singleValueParameter.ParameterName = "@SingleValue"; - - var stringValueParameter = new SqlParameter(); - stringValueParameter.ParameterName = "@StringValue"; - - var timeOnlyValueParameter = new SqlParameter(); - timeOnlyValueParameter.ParameterName = "@TimeOnlyValue"; - - var timeSpanValueParameter = new SqlParameter(); - timeSpanValueParameter.ParameterName = "@TimeSpanValue"; - - command.Parameters.Add(idParameter); - command.Parameters.Add(booleanValueParameter); - command.Parameters.Add(byteValueParameter); - command.Parameters.Add(charValueParameter); - command.Parameters.Add(dateOnlyParameter); - command.Parameters.Add(dateTimeValueParameter); - command.Parameters.Add(decimalValueParameter); - command.Parameters.Add(doubleValueParameter); - command.Parameters.Add(enumValueParameter); - command.Parameters.Add(guidValueParameter); - command.Parameters.Add(int16ValueParameter); - command.Parameters.Add(int32ValueParameter); - command.Parameters.Add(int64ValueParameter); - command.Parameters.Add(singleValueParameter); - command.Parameters.Add(stringValueParameter); - command.Parameters.Add(timeOnlyValueParameter); - command.Parameters.Add(timeSpanValueParameter); - - foreach (var entity in entities) - { - idParameter.Value = entity.Id; - booleanValueParameter.Value = entity.BooleanValue; - byteValueParameter.Value = entity.ByteValue; - charValueParameter.Value = entity.CharValue; - dateOnlyParameter.Value = entity.DateOnlyValue; - dateTimeValueParameter.Value = entity.DateTimeValue; - decimalValueParameter.Value = entity.DecimalValue; - doubleValueParameter.Value = entity.DoubleValue; - enumValueParameter.Value = entity.EnumValue.ToString(); - guidValueParameter.Value = entity.GuidValue; - int16ValueParameter.Value = entity.Int16Value; - int32ValueParameter.Value = entity.Int32Value; - int64ValueParameter.Value = entity.Int64Value; - singleValueParameter.Value = entity.SingleValue; - stringValueParameter.Value = entity.StringValue; - timeOnlyValueParameter.Value = entity.TimeOnlyValue; - timeSpanValueParameter.Value = entity.TimeSpanValue; - - command.ExecuteNonQuery(); - } - } - } - - [Benchmark(Baseline = false, OperationsPerInvoke = InsertEntities_OperationsPerInvoke)] - [BenchmarkCategory(InsertEntities_Category)] - public void InsertEntities_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < InsertEntities_OperationsPerInvoke; i++) - { - var entitiesToInsert = Generate.Multiple(InsertEntities_EntitiesPerOperation); - - connection.InsertEntities(entitiesToInsert); - } - } - #endregion InsertEntities - - #region InsertEntity - private const String InsertEntity_Category = "InsertEntity"; - private const Int32 InsertEntity_OperationsPerInvoke = 700; - - [GlobalSetup(Targets = [nameof(InsertEntity_Manually), nameof(InsertEntity_DbConnectionPlus)])] - public void InsertEntity_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(0); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = InsertEntity_OperationsPerInvoke)] - [BenchmarkCategory(InsertEntity_Category)] - public void InsertEntity_Manually() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < InsertEntity_OperationsPerInvoke; i++) - { - var entity = Generate.Single(); - - using var command = connection.CreateCommand(); - command.CommandText = """ - INSERT INTO [Entity] - ( - [Id], - [BooleanValue], - [ByteValue], - [CharValue], - [DateOnlyValue], - [DateTimeValue], - [DecimalValue], - [DoubleValue], - [EnumValue], - [GuidValue], - [Int16Value], - [Int32Value], - [Int64Value], - [SingleValue], - [StringValue], - [TimeOnlyValue], - [TimeSpanValue] - ) - VALUES - ( - @Id, - @BooleanValue, - @ByteValue, - @CharValue, - @DateOnlyValue, - @DateTimeValue, - @DecimalValue, - @DoubleValue, - @EnumValue, - @GuidValue, - @Int16Value, - @Int32Value, - @Int64Value, - @SingleValue, - @StringValue, - @TimeOnlyValue, - @TimeSpanValue - ) - """; - command.Parameters.Add(new("@Id", entity.Id)); - command.Parameters.Add(new("@BooleanValue", entity.BooleanValue)); - command.Parameters.Add(new("@ByteValue", entity.ByteValue)); - command.Parameters.Add(new("@CharValue", entity.CharValue)); - command.Parameters.Add(new("@DateOnlyValue", entity.DateOnlyValue)); - command.Parameters.Add(new("@DateTimeValue", entity.DateTimeValue)); - command.Parameters.Add(new("@DecimalValue", entity.DecimalValue)); - command.Parameters.Add(new("@DoubleValue", entity.DoubleValue)); - command.Parameters.Add(new("@EnumValue", entity.EnumValue.ToString())); - command.Parameters.Add(new("@GuidValue", entity.GuidValue)); - command.Parameters.Add(new("@Int16Value", entity.Int16Value)); - command.Parameters.Add(new("@Int32Value", entity.Int32Value)); - command.Parameters.Add(new("@Int64Value", entity.Int64Value)); - command.Parameters.Add(new("@SingleValue", entity.SingleValue)); - command.Parameters.Add(new("@StringValue", entity.StringValue)); - command.Parameters.Add(new("@TimeOnlyValue", entity.TimeOnlyValue)); - command.Parameters.Add(new("@TimeSpanValue", entity.TimeSpanValue)); - - command.ExecuteNonQuery(); - } - } - - [Benchmark(Baseline = false, OperationsPerInvoke = InsertEntity_OperationsPerInvoke)] - [BenchmarkCategory(InsertEntity_Category)] - public void InsertEntity_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < InsertEntity_OperationsPerInvoke; i++) - { - var entity = Generate.Single(); - - connection.InsertEntity(entity); - } - } - #endregion InsertEntity - - #region Parameter - private const String Parameter_Category = "Parameter"; - private const Int32 Parameter_OperationsPerInvoke = 2500; - - [GlobalSetup(Targets = [nameof(Parameter_Manually), nameof(Parameter_DbConnectionPlus)])] - public void Parameter_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(0); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = Parameter_OperationsPerInvoke)] - [BenchmarkCategory(Parameter_Category)] - public Object Parameter_Manually() - { - using var connection = this.CreateConnection(); - - var result = new List(); - - for (var i = 0; i < Parameter_OperationsPerInvoke; i++) - { - result.Clear(); - - using var command = connection.CreateCommand(); - command.CommandText = "SELECT @P1, @P2, @P3, @P4, @P5"; - command.Parameters.Add(new("@P1", 1)); - command.Parameters.Add(new("@P2", "Test")); - command.Parameters.Add(new("@P3", DateTime.UtcNow)); - command.Parameters.Add(new("@P4", Guid.NewGuid())); - command.Parameters.Add(new("@P5", true)); - - using var dataReader = command.ExecuteReader(); - - dataReader.Read(); - result.Add(dataReader.GetInt32(0)); - result.Add(dataReader.GetString(1)); - result.Add(dataReader.GetDateTime(2)); - result.Add(dataReader.GetGuid(3)); - result.Add(dataReader.GetBoolean(4)); - } - - return result; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = Parameter_OperationsPerInvoke)] - [BenchmarkCategory(Parameter_Category)] - public Object Parameter_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - var result = new List(); - - for (var i = 0; i < Parameter_OperationsPerInvoke; i++) - { - result.Clear(); - - using var dataReader = connection.ExecuteReader( - $""" - SELECT {Parameter(1)}, - {Parameter("Test")}, - {Parameter(DateTime.UtcNow)}, - {Parameter(Guid.NewGuid())}, - {Parameter(true)} - """); - - dataReader.Read(); - result.Add(dataReader.GetInt32(0)); - result.Add(dataReader.GetString(1)); - result.Add(dataReader.GetDateTime(2)); - result.Add(dataReader.GetGuid(3)); - result.Add(dataReader.GetBoolean(4)); - } - - return result; - } - #endregion Parameter - - #region Query_Dynamic - private const String Query_Dynamic_Category = "Query_Dynamic"; - private const Int32 Query_Dynamic_OperationsPerInvoke = 600; - private const Int32 Query_Dynamic_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [nameof(Query_Dynamic_Manually), nameof(Query_Dynamic_DbConnectionPlus)])] - public void Query_Dynamic_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(Query_Dynamic_EntitiesPerOperation); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = Query_Dynamic_OperationsPerInvoke)] - [BenchmarkCategory(Query_Dynamic_Category)] - public List Query_Dynamic_Manually() - { - using var connection = this.CreateConnection(); - - var entities = new List(); - - for (var i = 0; i < Query_Dynamic_OperationsPerInvoke; i++) - { - entities.Clear(); - - using var dataReader = connection.ExecuteReader( - $""" - SELECT - TOP ({Query_Dynamic_EntitiesPerOperation}) - [Id], - [BooleanValue], - [ByteValue], - [CharValue], - [DateOnlyValue], - [DateTimeValue], - [DecimalValue], - [DoubleValue], - [EnumValue], - [GuidValue], - [Int16Value], - [Int32Value], - [Int64Value], - [SingleValue], - [StringValue], - [TimeOnlyValue], - [TimeSpanValue] - FROM - Entity - """ - ); - - while (dataReader.Read()) - { - var charBuffer = new Char[1]; - - var ordinal = 0; - dynamic entity = new ExpandoObject(); - - entity.Id = dataReader.GetInt64(ordinal++); - entity.BooleanValue = dataReader.GetBoolean(ordinal++); - entity.ByteValue = dataReader.GetByte(ordinal++); - entity.CharValue = dataReader.GetChars(ordinal++, 0, charBuffer, 0, 1) == 1 - ? charBuffer[0] - : throw new(); - entity.DateOnlyValue = DateOnly.FromDateTime((DateTime)dataReader.GetValue(ordinal++)); - entity.DateTimeValue = dataReader.GetDateTime(ordinal++); - entity.DecimalValue = dataReader.GetDecimal(ordinal++); - entity.DoubleValue = dataReader.GetDouble(ordinal++); - entity.EnumValue = Enum.Parse(dataReader.GetString(ordinal++)); - entity.GuidValue = dataReader.GetGuid(ordinal++); - entity.Int16Value = dataReader.GetInt16(ordinal++); - entity.Int32Value = dataReader.GetInt32(ordinal++); - entity.Int64Value = dataReader.GetInt64(ordinal++); - entity.SingleValue = dataReader.GetFloat(ordinal++); - entity.StringValue = dataReader.GetString(ordinal++); - entity.TimeOnlyValue = TimeOnly.FromTimeSpan((TimeSpan)dataReader.GetValue(ordinal++)); - entity.TimeSpanValue = (TimeSpan)dataReader.GetValue(ordinal); - - entities.Add(entity); - } - } - - return entities; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = Query_Dynamic_OperationsPerInvoke)] - [BenchmarkCategory(Query_Dynamic_Category)] - public List Query_Dynamic_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - List entities = []; - - for (var i = 0; i < Query_Dynamic_OperationsPerInvoke; i++) - { - entities = connection - .Query($"SELECT TOP ({Query_Dynamic_EntitiesPerOperation}) * FROM Entity") - .ToList(); - } - - return entities; - } - #endregion Query_Dynamic - - #region Query_Scalars - private const String Query_Scalars_Category = "Query_Scalars"; - private const Int32 Query_Scalars_OperationsPerInvoke = 1500; - private const Int32 Query_Scalars_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [nameof(Query_Scalars_Manually), nameof(Query_Scalars_DbConnectionPlus)])] - public void Query_Scalars_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(Query_Scalars_EntitiesPerOperation); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = Query_Scalars_OperationsPerInvoke)] - [BenchmarkCategory(Query_Scalars_Category)] - public List Query_Scalars_Manually() - { - using var connection = this.CreateConnection(); - - var data = new List(); - - for (var i = 0; i < Query_Scalars_OperationsPerInvoke; i++) - { - data.Clear(); - - using var command = connection.CreateCommand(); - command.CommandText = $"SELECT TOP ({Query_Scalars_EntitiesPerOperation}) Id FROM Entity"; - - using var dataReader = command.ExecuteReader(); - - while (dataReader.Read()) - { - var id = dataReader.GetInt64(0); - - data.Add(id); - } - } - - return data; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = Query_Scalars_OperationsPerInvoke)] - [BenchmarkCategory(Query_Scalars_Category)] - public List Query_Scalars_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - List data = []; - - for (var i = 0; i < Query_Scalars_OperationsPerInvoke; i++) - { - data = connection - .Query($"SELECT TOP ({Query_Scalars_EntitiesPerOperation}) Id FROM Entity") - .ToList(); - } - - return data; - } - #endregion Query_Scalars - - #region Query_Entities - private const String Query_Entities_Category = "Query_Entities"; - private const Int32 Query_Entities_OperationsPerInvoke = 600; - private const Int32 Query_Entities_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [nameof(Query_Entities_Manually), nameof(Query_Entities_DbConnectionPlus)])] - public void Query_Entities_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(Query_Entities_EntitiesPerOperation); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = Query_Entities_OperationsPerInvoke)] - [BenchmarkCategory(Query_Entities_Category)] - public List Query_Entities_Manually() - { - using var connection = this.CreateConnection(); - - var entities = new List(); - - for (var i = 0; i < Query_Entities_OperationsPerInvoke; i++) - { - entities.Clear(); - - using var dataReader = connection.ExecuteReader( - $""" - SELECT - TOP ({Query_Entities_EntitiesPerOperation}) - [Id], - [BooleanValue], - [ByteValue], - [CharValue], - [DateOnlyValue], - [DateTimeValue], - [DecimalValue], - [DoubleValue], - [EnumValue], - [GuidValue], - [Int16Value], - [Int32Value], - [Int64Value], - [SingleValue], - [StringValue], - [TimeOnlyValue], - [TimeSpanValue] - FROM - Entity - """ - ); - - while (dataReader.Read()) - { - var charBuffer = new Char[1]; - - var ordinal = 0; - entities.Add(new() - { - Id = dataReader.GetInt64(ordinal++), - BooleanValue = dataReader.GetBoolean(ordinal++), - ByteValue = dataReader.GetByte(ordinal++), - CharValue = dataReader.GetChars(ordinal++, 0, charBuffer, 0, 1) == 1 ? charBuffer[0] : throw new(), - DateOnlyValue = DateOnly.FromDateTime((DateTime) dataReader.GetValue(ordinal++)), - DateTimeValue = dataReader.GetDateTime(ordinal++), - DecimalValue = dataReader.GetDecimal(ordinal++), - DoubleValue = dataReader.GetDouble(ordinal++), - EnumValue = Enum.Parse(dataReader.GetString(ordinal++)), - GuidValue = dataReader.GetGuid(ordinal++), - Int16Value = dataReader.GetInt16(ordinal++), - Int32Value = dataReader.GetInt32(ordinal++), - Int64Value = dataReader.GetInt64(ordinal++), - SingleValue = dataReader.GetFloat(ordinal++), - StringValue = dataReader.GetString(ordinal++), - TimeOnlyValue = TimeOnly.FromTimeSpan((TimeSpan)dataReader.GetValue(ordinal++)), - TimeSpanValue = (TimeSpan)dataReader.GetValue(ordinal) - }); - } - } - - return entities; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = Query_Entities_OperationsPerInvoke)] - [BenchmarkCategory(Query_Entities_Category)] - public List Query_Entities_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - List entities = []; - - for (var i = 0; i < Query_Entities_OperationsPerInvoke; i++) - { - entities = connection - .Query($"SELECT TOP ({Query_Entities_EntitiesPerOperation}) * FROM Entity") - .ToList(); - } - - return entities; - } - #endregion Query_Entities - - #region Query_ValueTuples - private const String Query_ValueTuples_Category = "Query_ValueTuples"; - private const Int32 Query_ValueTuples_OperationsPerInvoke = 900; - private const Int32 Query_ValueTuples_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [nameof(Query_ValueTuples_Manually), nameof(Query_ValueTuples_DbConnectionPlus)])] - public void Query_ValueTuples_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(Query_ValueTuples_EntitiesPerOperation); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = Query_ValueTuples_OperationsPerInvoke)] - [BenchmarkCategory(Query_ValueTuples_Category)] - public List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)> Query_ValueTuples_Manually() - { - using var connection = this.CreateConnection(); - - var tuples = new List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)>(); - - for (var i = 0; i < Query_ValueTuples_OperationsPerInvoke; i++) - { - tuples.Clear(); - - using var command = connection.CreateCommand(); - command.CommandText = $""" - SELECT TOP ({Query_ValueTuples_EntitiesPerOperation}) - Id, DateTimeValue, EnumValue, StringValue - FROM Entity - """; - - using var dataReader = command.ExecuteReader(); - - while (dataReader.Read()) - { - tuples.Add( - ( - dataReader.GetInt64(0), - dataReader.GetDateTime(1), - Enum.Parse(dataReader.GetString(2)), - dataReader.GetString(3) - ) - ); - } - } - - return tuples; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = Query_ValueTuples_OperationsPerInvoke)] - [BenchmarkCategory(Query_ValueTuples_Category)] - public List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)> - Query_ValueTuples_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - List<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)> tuples = []; - - for (var i = 0; i < Query_ValueTuples_OperationsPerInvoke; i++) - { - tuples = connection - .Query<(Int64 Id, DateTime DateTimeValue, TestEnum EnumValue, String StringValue)>( - $""" - SELECT TOP ({Query_ValueTuples_EntitiesPerOperation}) - Id, DateTimeValue, EnumValue, StringValue - FROM Entity - """ - ) - .ToList(); - } - - return tuples; - } - #endregion Query_ValueTuples - - #region TemporaryTable_ComplexObjects - private const String TemporaryTable_ComplexObjects_Category = "TemporaryTable_ComplexObjects"; - private const Int32 TemporaryTable_ComplexObjects_OperationsPerInvoke = 25; - private const Int32 TemporaryTable_ComplexObjects_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [ - nameof(TemporaryTable_ComplexObjects_Manually), - nameof(TemporaryTable_ComplexObjects_DbConnectionPlus) - ])] - public void TemporaryTable_ComplexObjects_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(0); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = TemporaryTable_ComplexObjects_OperationsPerInvoke)] - [BenchmarkCategory(TemporaryTable_ComplexObjects_Category)] - public List TemporaryTable_ComplexObjects_Manually() - { - using var connection = this.CreateConnection(); - - var entities = Generate.Multiple(TemporaryTable_ComplexObjects_EntitiesPerOperation); - - var result = new List(); - - for (var i = 0; i < TemporaryTable_ComplexObjects_OperationsPerInvoke; i++) - { - result.Clear(); - - using var entitiesReader = new ObjectReader( - typeof(Entity), - entities, - EntityHelper.GetEntityTypeMetadata(typeof(Entity)). - MappedProperties.Where(a => a.CanRead).Select(a => a.PropertyName).ToArray() - ); - - using var getCollationCommand = connection.CreateCommand(); - getCollationCommand.CommandText = - "SELECT CONVERT (VARCHAR(256), DATABASEPROPERTYEX(DB_NAME(), 'collation'))"; - var databaseCollation = (String)getCollationCommand.ExecuteScalar()!; - - using var createTableCommand = connection.CreateCommand(); - createTableCommand.CommandText = - $""" - CREATE TABLE [#Entities] ( - [BooleanValue] BIT, - [ByteValue] TINYINT, - [CharValue] CHAR(1), - [DateOnlyValue] DATE, - [DateTimeValue] DATETIME2, - [DecimalValue] DECIMAL(28, 10), - [DoubleValue] FLOAT, - [EnumValue] NVARCHAR(200) COLLATE {databaseCollation}, - [GuidValue] UNIQUEIDENTIFIER, - [Id] BIGINT, - [Int16Value] SMALLINT, - [Int32Value] INT, - [Int64Value] BIGINT, - [SingleValue] REAL, - [StringValue] NVARCHAR(MAX) COLLATE {databaseCollation}, - [TimeOnlyValue] TIME, - [TimeSpanValue] TIME - ) - """; - createTableCommand.ExecuteNonQuery(); - - using (var bulkCopy = new SqlBulkCopy(connection)) - { - bulkCopy.DestinationTableName = "#Entities"; - bulkCopy.WriteToServer(entitiesReader); - } - - using var selectCommand = connection.CreateCommand(); - selectCommand.CommandText = - """ - SELECT - [Id], - [BooleanValue], - [ByteValue], - [CharValue], - [DateOnlyValue], - [DateTimeValue], - [DecimalValue], - [DoubleValue], - [EnumValue], - [GuidValue], - [Int16Value], - [Int32Value], - [Int64Value], - [SingleValue], - [StringValue], - [TimeOnlyValue], - [TimeSpanValue] - FROM - #Entities - """; - - using var dataReader = selectCommand.ExecuteReader(); - - while (dataReader.Read()) - { - var charBuffer = new Char[1]; - - var ordinal = 0; - result.Add(new() - { - Id = dataReader.GetInt64(ordinal++), - BooleanValue = dataReader.GetBoolean(ordinal++), - ByteValue = dataReader.GetByte(ordinal++), - CharValue = dataReader.GetChars(ordinal++, 0, charBuffer, 0, 1) == 1 ? charBuffer[0] : throw new(), - DateOnlyValue = DateOnly.FromDateTime((DateTime)dataReader.GetValue(ordinal++)), - DateTimeValue = dataReader.GetDateTime(ordinal++), - DecimalValue = dataReader.GetDecimal(ordinal++), - DoubleValue = dataReader.GetDouble(ordinal++), - EnumValue = Enum.Parse(dataReader.GetString(ordinal++)), - GuidValue = dataReader.GetGuid(ordinal++), - Int16Value = dataReader.GetInt16(ordinal++), - Int32Value = dataReader.GetInt32(ordinal++), - Int64Value = dataReader.GetInt64(ordinal++), - SingleValue = dataReader.GetFloat(ordinal++), - StringValue = dataReader.GetString(ordinal++), - TimeOnlyValue = TimeOnly.FromTimeSpan((TimeSpan)dataReader.GetValue(ordinal++)), - TimeSpanValue = (TimeSpan)dataReader.GetValue(ordinal) - }); - } - - using var dropTableCommand = connection.CreateCommand(); - dropTableCommand.CommandText = "DROP TABLE #Entities"; - dropTableCommand.ExecuteNonQuery(); - } - - return result; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = TemporaryTable_ComplexObjects_OperationsPerInvoke)] - [BenchmarkCategory(TemporaryTable_ComplexObjects_Category)] - public List TemporaryTable_ComplexObjects_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - var entities = Generate.Multiple(TemporaryTable_ComplexObjects_EntitiesPerOperation); - - List result = []; - - for (var i = 0; i < TemporaryTable_ComplexObjects_OperationsPerInvoke; i++) - { - result = connection.Query($"SELECT * FROM {TemporaryTable(entities)}").ToList(); - } - - return result; - } - #endregion TemporaryTable_ComplexObjects - - #region TemporaryTable_ScalarValues - private const String TemporaryTable_ScalarValues_Category = "TemporaryTable_ScalarValues"; - private const Int32 TemporaryTable_ScalarValues_OperationsPerInvoke = 30; - private const Int32 TemporaryTable_ScalarValues_ValuesPerOperation = 5000; - - [GlobalSetup(Targets = [ - nameof(TemporaryTable_ScalarValues_Manually), - nameof(TemporaryTable_ScalarValues_DbConnectionPlus) - ])] - public void TemporaryTable_ScalarValues_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(0); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = TemporaryTable_ScalarValues_OperationsPerInvoke)] - [BenchmarkCategory(TemporaryTable_ScalarValues_Category)] - public List TemporaryTable_ScalarValues_Manually() - { - using var connection = this.CreateConnection(); - - var scalarValues = Enumerable - .Range(0, TemporaryTable_ScalarValues_ValuesPerOperation) - .Select(a => a.ToString()) - .ToList(); - - var result = new List(); - - for (var i = 0; i < TemporaryTable_ScalarValues_OperationsPerInvoke; i++) - { - result.Clear(); - - using var valuesReader = new EnumerableReader(scalarValues, typeof(String), "Value"); - - using var getCollationCommand = connection.CreateCommand(); - getCollationCommand.CommandText = - "SELECT CONVERT (VARCHAR(256), DATABASEPROPERTYEX(DB_NAME(), 'collation'))"; - var databaseCollation = (String)getCollationCommand.ExecuteScalar()!; - - using var createTableCommand = connection.CreateCommand(); - createTableCommand.CommandText = $"CREATE TABLE #Values (Value NVARCHAR(4) COLLATE {databaseCollation})"; - createTableCommand.ExecuteNonQuery(); - - using (var bulkCopy = new SqlBulkCopy(connection)) - { - bulkCopy.DestinationTableName = "#Values"; - bulkCopy.WriteToServer(valuesReader); - } - - using var selectCommand = connection.CreateCommand(); - selectCommand.CommandText = "SELECT Value FROM #Values"; - - using var dataReader = selectCommand.ExecuteReader(); - - while (dataReader.Read()) - { - result.Add(dataReader.GetString(0)); - } - - using var dropTableCommand = connection.CreateCommand(); - dropTableCommand.CommandText = "DROP TABLE #Values"; - dropTableCommand.ExecuteNonQuery(); - } - - return result; - } - - [Benchmark(Baseline = false, OperationsPerInvoke = TemporaryTable_ScalarValues_OperationsPerInvoke)] - [BenchmarkCategory(TemporaryTable_ScalarValues_Category)] - public List TemporaryTable_ScalarValues_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - var scalarValues = Enumerable - .Range(0, TemporaryTable_ScalarValues_ValuesPerOperation) - .Select(a => a.ToString()) - .ToList(); - - List result = []; - - for (var i = 0; i < TemporaryTable_ScalarValues_OperationsPerInvoke; i++) - { - result = connection.Query($"SELECT Value FROM {TemporaryTable(scalarValues)}").ToList(); - } - - return result; - } - #endregion TemporaryTable_ScalarValues - - #region UpdateEntities - private const String UpdateEntities_Category = "UpdateEntities"; - private const Int32 UpdateEntities_OperationsPerInvoke = 10; - private const Int32 UpdateEntities_EntitiesPerOperation = 100; - - [GlobalSetup(Targets = [ - nameof(UpdateEntities_Manually), - nameof(UpdateEntities_DbConnectionPlus) - ])] - public void UpdateEntities_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(UpdateEntities_EntitiesPerOperation); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = UpdateEntities_OperationsPerInvoke)] - [BenchmarkCategory(UpdateEntities_Category)] - public void UpdateEntities_Manually() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < UpdateEntities_OperationsPerInvoke; i++) - { - var updatedEntities = Generate.UpdateFor(this.entitiesInDb); - - using var command = connection.CreateCommand(); - command.CommandText = """ - UPDATE [Entity] - SET [BooleanValue] = @BooleanValue, - [ByteValue] = @ByteValue, - [CharValue] = @CharValue, - [DateOnlyValue] = @DateOnlyValue, - [DateTimeValue] = @DateTimeValue, - [DecimalValue] = @DecimalValue, - [DoubleValue] = @DoubleValue, - [EnumValue] = @EnumValue, - [GuidValue] = @GuidValue, - [Int16Value] = @Int16Value, - [Int32Value] = @Int32Value, - [Int64Value] = @Int64Value, - [SingleValue] = @SingleValue, - [StringValue] = @StringValue, - [TimeOnlyValue] = @TimeOnlyValue, - [TimeSpanValue] = @TimeSpanValue - WHERE [Id] = @Id - """; - - var idParameter = new SqlParameter(); - idParameter.ParameterName = "@Id"; - - var booleanValueParameter = new SqlParameter(); - booleanValueParameter.ParameterName = "@BooleanValue"; - - var byteValueParameter = new SqlParameter(); - byteValueParameter.ParameterName = "@ByteValue"; - - var charValueParameter = new SqlParameter(); - charValueParameter.ParameterName = "@CharValue"; - - var dateOnlyValueParameter = new SqlParameter(); - dateOnlyValueParameter.ParameterName = "@DateOnlyValue"; - - var dateTimeValueParameter = new SqlParameter(); - dateTimeValueParameter.ParameterName = "@DateTimeValue"; - - var decimalValueParameter = new SqlParameter(); - decimalValueParameter.ParameterName = "@DecimalValue"; - - var doubleValueParameter = new SqlParameter(); - doubleValueParameter.ParameterName = "@DoubleValue"; - - var enumValueParameter = new SqlParameter(); - enumValueParameter.ParameterName = "@EnumValue"; - - var guidValueParameter = new SqlParameter(); - guidValueParameter.ParameterName = "@GuidValue"; - - var int16ValueParameter = new SqlParameter(); - int16ValueParameter.ParameterName = "@Int16Value"; - - var int32ValueParameter = new SqlParameter(); - int32ValueParameter.ParameterName = "@Int32Value"; - - var int64ValueParameter = new SqlParameter(); - int64ValueParameter.ParameterName = "@Int64Value"; - - var singleValueParameter = new SqlParameter(); - singleValueParameter.ParameterName = "@SingleValue"; - - var stringValueParameter = new SqlParameter(); - stringValueParameter.ParameterName = "@StringValue"; - - var timeOnlyValueParameter = new SqlParameter(); - timeOnlyValueParameter.ParameterName = "@TimeOnlyValue"; - - var timeSpanValueParameter = new SqlParameter(); - timeSpanValueParameter.ParameterName = "@TimeSpanValue"; - - command.Parameters.Add(idParameter); - command.Parameters.Add(booleanValueParameter); - command.Parameters.Add(byteValueParameter); - command.Parameters.Add(charValueParameter); - command.Parameters.Add(dateOnlyValueParameter); - command.Parameters.Add(dateTimeValueParameter); - command.Parameters.Add(decimalValueParameter); - command.Parameters.Add(doubleValueParameter); - command.Parameters.Add(enumValueParameter); - command.Parameters.Add(guidValueParameter); - command.Parameters.Add(int16ValueParameter); - command.Parameters.Add(int32ValueParameter); - command.Parameters.Add(int64ValueParameter); - command.Parameters.Add(singleValueParameter); - command.Parameters.Add(stringValueParameter); - command.Parameters.Add(timeOnlyValueParameter); - command.Parameters.Add(timeSpanValueParameter); - - foreach (var updatedEntity in updatedEntities) - { - idParameter.Value = updatedEntity.Id; - booleanValueParameter.Value = updatedEntity.BooleanValue; - byteValueParameter.Value = updatedEntity.ByteValue; - charValueParameter.Value = updatedEntity.CharValue; - dateOnlyValueParameter.Value = updatedEntity.DateOnlyValue; - dateTimeValueParameter.Value = updatedEntity.DateTimeValue; - decimalValueParameter.Value = updatedEntity.DecimalValue; - doubleValueParameter.Value = updatedEntity.DoubleValue; - enumValueParameter.Value = updatedEntity.EnumValue.ToString(); - guidValueParameter.Value = updatedEntity.GuidValue; - int16ValueParameter.Value = updatedEntity.Int16Value; - int32ValueParameter.Value = updatedEntity.Int32Value; - int64ValueParameter.Value = updatedEntity.Int64Value; - singleValueParameter.Value = updatedEntity.SingleValue; - stringValueParameter.Value = updatedEntity.StringValue; - timeOnlyValueParameter.Value = updatedEntity.TimeOnlyValue; - timeSpanValueParameter.Value = updatedEntity.TimeSpanValue; - - command.ExecuteNonQuery(); - } - } - } - - [Benchmark(Baseline = false, OperationsPerInvoke = UpdateEntities_OperationsPerInvoke)] - [BenchmarkCategory(UpdateEntities_Category)] - public void UpdateEntities_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < UpdateEntities_OperationsPerInvoke; i++) - { - var updatesEntities = Generate.UpdateFor(this.entitiesInDb); - - connection.UpdateEntities(updatesEntities); - } - } - #endregion UpdateEntities - - #region UpdateEntity - private const String UpdateEntity_Category = "UpdateEntity"; - private const Int32 UpdateEntity_OperationsPerInvoke = 700; - - [GlobalSetup(Targets = [ - nameof(UpdateEntity_Manually), - nameof(UpdateEntity_DbConnectionPlus) - ])] - public void UpdateEntity_Setup() - { - this.Setup_Global(); - this.PrepareEntitiesInDb(UpdateEntity_OperationsPerInvoke); - } - - [Benchmark(Baseline = true, OperationsPerInvoke = UpdateEntity_OperationsPerInvoke)] - [BenchmarkCategory(UpdateEntity_Category)] - public void UpdateEntity_Manually() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < UpdateEntity_OperationsPerInvoke; i++) - { - var entity = this.entitiesInDb[i]; - - var updatedEntity = Generate.UpdateFor(entity); - - using var command = connection.CreateCommand(); - command.CommandText = """ - UPDATE [Entity] - SET [BooleanValue] = @BooleanValue, - [ByteValue] = @ByteValue, - [CharValue] = @CharValue, - [DateOnlyValue] = @DateOnlyValue, - [DateTimeValue] = @DateTimeValue, - [DecimalValue] = @DecimalValue, - [DoubleValue] = @DoubleValue, - [EnumValue] = @EnumValue, - [GuidValue] = @GuidValue, - [Int16Value] = @Int16Value, - [Int32Value] = @Int32Value, - [Int64Value] = @Int64Value, - [SingleValue] = @SingleValue, - [StringValue] = @StringValue, - [TimeOnlyValue] = @TimeOnlyValue, - [TimeSpanValue] = @TimeSpanValue - WHERE [Id] = @Id - """; - command.Parameters.Add(new("@Id", updatedEntity.Id)); - command.Parameters.Add(new("@BooleanValue", updatedEntity.BooleanValue)); - command.Parameters.Add(new("@ByteValue", updatedEntity.ByteValue)); - command.Parameters.Add(new("@CharValue", updatedEntity.CharValue)); - command.Parameters.Add(new("@DateOnlyValue", updatedEntity.DateOnlyValue)); - command.Parameters.Add(new("@DateTimeValue", updatedEntity.DateTimeValue)); - command.Parameters.Add(new("@DecimalValue", updatedEntity.DecimalValue)); - command.Parameters.Add(new("@DoubleValue", updatedEntity.DoubleValue)); - command.Parameters.Add(new("@EnumValue", updatedEntity.EnumValue.ToString())); - command.Parameters.Add(new("@GuidValue", updatedEntity.GuidValue)); - command.Parameters.Add(new("@Int16Value", updatedEntity.Int16Value)); - command.Parameters.Add(new("@Int32Value", updatedEntity.Int32Value)); - command.Parameters.Add(new("@Int64Value", updatedEntity.Int64Value)); - command.Parameters.Add(new("@SingleValue", updatedEntity.SingleValue)); - command.Parameters.Add(new("@StringValue", updatedEntity.StringValue)); - command.Parameters.Add(new("@TimeOnlyValue", updatedEntity.TimeOnlyValue)); - command.Parameters.Add(new("@TimeSpanValue", updatedEntity.TimeSpanValue)); - - command.ExecuteNonQuery(); - } - } - - [Benchmark(Baseline = false, OperationsPerInvoke = UpdateEntity_OperationsPerInvoke)] - [BenchmarkCategory(UpdateEntity_Category)] - public void UpdateEntity_DbConnectionPlus() - { - using var connection = this.CreateConnection(); - - for (var i = 0; i < UpdateEntity_OperationsPerInvoke; i++) - { - var entity = this.entitiesInDb[i]; - - var updatedEntity = Generate.UpdateFor(entity); - - connection.UpdateEntity(updatedEntity); - } - } - #endregion UpdateEntity - - private readonly SqlServerTestDatabaseProvider testDatabaseProvider = new(); + private static void PopulateEntityParameters(BenchmarkEntity entity, Dictionary parameters) + { + parameters["Id"].Value = entity.Id; + parameters["BooleanValue"].Value = entity.BooleanValue ? 1 : 0; + parameters["BytesValue"].Value = entity.BytesValue; + parameters["ByteValue"].Value = entity.ByteValue; + parameters["CharValue"].Value = entity.CharValue; + parameters["DateTimeValue"].Value = entity.DateTimeValue.ToString(CultureInfo.InvariantCulture); + parameters["DecimalValue"].Value = entity.DecimalValue.ToString(CultureInfo.InvariantCulture); + parameters["DoubleValue"].Value = entity.DoubleValue; + parameters["EnumValue"].Value = entity.EnumValue.ToString(); + parameters["GuidValue"].Value = entity.GuidValue.ToString(); + parameters["Int16Value"].Value = entity.Int16Value; + parameters["Int32Value"].Value = entity.Int32Value; + parameters["Int64Value"].Value = entity.Int64Value; + parameters["SingleValue"].Value = entity.SingleValue; + parameters["StringValue"].Value = entity.StringValue; + parameters["TimeSpanValue"].Value = entity.TimeSpanValue.ToString(); + } + + private static BenchmarkEntity ReadEntity(IDataReader dataReader) + { + var charBuffer = new Char[1]; + + var ordinal = 0; + + return new() + { + Id = dataReader.GetInt64(ordinal++), + BooleanValue = dataReader.GetInt64(ordinal++) == 1, + BytesValue = (Byte[])dataReader.GetValue(ordinal++), + ByteValue = dataReader.GetByte(ordinal++), + CharValue = dataReader.GetChars(ordinal++, 0, charBuffer, 0, 1) == 1 ? charBuffer[0] : throw new(), + DateTimeValue = DateTime.Parse(dataReader.GetString(ordinal++), CultureInfo.InvariantCulture), + DecimalValue = Decimal.Parse(dataReader.GetString(ordinal++), CultureInfo.InvariantCulture), + DoubleValue = dataReader.GetDouble(ordinal++), + EnumValue = Enum.Parse(dataReader.GetString(ordinal++)), + GuidValue = Guid.Parse(dataReader.GetString(ordinal++)), + Int16Value = (Int16)dataReader.GetInt64(ordinal++), + Int32Value = (Int32)dataReader.GetInt64(ordinal++), + Int64Value = dataReader.GetInt64(ordinal++), + SingleValue = dataReader.GetFloat(ordinal++), + StringValue = dataReader.GetString(ordinal++), + TimeSpanValue = TimeSpan.Parse(dataReader.GetString(ordinal), CultureInfo.InvariantCulture) + }; + } + + private SqliteConnection connection = null!; + private List entitiesInDb = null!; + + private const String CreateEntityTableSql = + """ + CREATE TABLE Entity + ( + Id INTEGER, + BooleanValue INTEGER, + BytesValue BLOB, + ByteValue INTEGER, + CharValue TEXT, + DateTimeValue TEXT, + DecimalValue TEXT, + DoubleValue REAL, + EnumValue TEXT, + GuidValue TEXT, + Int16Value INTEGER, + Int32Value INTEGER, + Int64Value INTEGER, + SingleValue REAL, + StringValue TEXT, + TimeSpanValue TEXT + ); + """; } diff --git a/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksConfig.cs b/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksConfig.cs index 71f9e42..4433a2e 100644 --- a/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksConfig.cs +++ b/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksConfig.cs @@ -3,8 +3,6 @@ using BenchmarkDotNet.Exporters; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Reports; -using Perfolizer.Horology; -using Perfolizer.Mathematics.OutlierDetection; namespace RentADeveloper.DbConnectionPlus.Benchmarks; @@ -16,25 +14,15 @@ public BenchmarksConfig() this.Orderer = new BenchmarksOrderer(); this.SummaryStyle = SummaryStyle.Default.WithRatioStyle(RatioStyle.Trend); - this.AddColumn(StatisticColumn.Median); - this.AddColumn(StatisticColumn.P90); - this.AddColumn(StatisticColumn.P95); + this.HideColumns("Job", "InvocationCount", "UnrollFactor"); - this.AddExporter(PlainExporter.Default); this.AddExporter(MarkdownExporter.Default); this.AddJob( Job.Default - .WithWarmupCount(10) - .WithMinIterationTime(TimeInterval.FromMilliseconds(100)) .WithMaxIterationCount(20) - .WithInvocationCount(1) - .WithUnrollFactor(1) - // Since DbConnectionPlus will mostly be used in server applications, we test with server GC. .WithGcServer(true) - - .WithOutlierMode(OutlierMode.DontRemove) ); } } diff --git a/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksOrderer.cs b/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksOrderer.cs index 58f6f14..a340ffb 100644 --- a/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksOrderer.cs +++ b/benchmarks/DbConnectionPlus.Benchmarks/BenchmarksOrderer.cs @@ -17,12 +17,9 @@ public IEnumerable GetExecutionOrder( IEnumerable? order = null ) => benchmarksCase - .OrderBy(a => - a.Descriptor.WorkloadMethod.DeclaringType! - .GetMethods() - .ToList() - .IndexOf(a.Descriptor.WorkloadMethod) - ); + .OrderBy(a => a.Descriptor.Categories[0]) + .ThenByDescending(a => a.Descriptor.Baseline) + .ThenBy(a => a.Descriptor.WorkloadMethod.Name); /// public String? GetHighlightGroupKey(BenchmarkCase benchmarkCase) => @@ -47,10 +44,8 @@ public IEnumerable GetSummaryOrder( ImmutableArray benchmarksCases, Summary summary ) => - benchmarksCases.OrderBy(a => - a.Descriptor.WorkloadMethod.DeclaringType! - .GetMethods() - .ToList() - .IndexOf(a.Descriptor.WorkloadMethod) - ); + benchmarksCases + .OrderBy(a => a.Descriptor.Categories[0]) + .ThenByDescending(a => a.Descriptor.Baseline) + .ThenBy(a => a.Descriptor.WorkloadMethod.Name); } diff --git a/benchmarks/DbConnectionPlus.Benchmarks/DapperTypeHandlers/GuidTypeHandler.cs b/benchmarks/DbConnectionPlus.Benchmarks/DapperTypeHandlers/GuidTypeHandler.cs new file mode 100644 index 0000000..db2a668 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/DapperTypeHandlers/GuidTypeHandler.cs @@ -0,0 +1,12 @@ +namespace RentADeveloper.DbConnectionPlus.Benchmarks.DapperTypeHandlers; + +public class GuidTypeHandler : SqlMapper.StringTypeHandler +{ + /// + protected override String Format(Guid xml) => + xml.ToString(); + + /// + protected override Guid Parse(String xml) => + Guid.Parse(xml); +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/DapperTypeHandlers/TimeSpanTypeHandler.cs b/benchmarks/DbConnectionPlus.Benchmarks/DapperTypeHandlers/TimeSpanTypeHandler.cs new file mode 100644 index 0000000..a33b695 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/DapperTypeHandlers/TimeSpanTypeHandler.cs @@ -0,0 +1,12 @@ +namespace RentADeveloper.DbConnectionPlus.Benchmarks.DapperTypeHandlers; + +public class TimeSpanTypeHandler : SqlMapper.StringTypeHandler +{ + /// + protected override String Format(TimeSpan xml) => + xml.ToString(); + + /// + protected override TimeSpan Parse(String xml) => + TimeSpan.Parse(xml); +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj b/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj index 410c65c..4a23953 100644 --- a/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj +++ b/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj @@ -18,6 +18,8 @@ + + @@ -35,7 +37,7 @@ - + diff --git a/benchmarks/DbConnectionPlus.Benchmarks/GlobalUsings.cs b/benchmarks/DbConnectionPlus.Benchmarks/GlobalUsings.cs new file mode 100644 index 0000000..4fe9b5d --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/GlobalUsings.cs @@ -0,0 +1,10 @@ +global using System.Data; +global using System.Globalization; +global using BenchmarkDotNet.Attributes; +global using Dapper; +global using Dapper.Contrib.Extensions; +global using Microsoft.Data.Sqlite; +global using RentADeveloper.DbConnectionPlus.Benchmarks.DapperTypeHandlers; +global using RentADeveloper.DbConnectionPlus.Benchmarks.TestData; +global using RentADeveloper.DbConnectionPlus.UnitTests.TestData; +global using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; diff --git a/benchmarks/DbConnectionPlus.Benchmarks/Program.cs b/benchmarks/DbConnectionPlus.Benchmarks/Program.cs index 162cb6e..3d2df03 100644 --- a/benchmarks/DbConnectionPlus.Benchmarks/Program.cs +++ b/benchmarks/DbConnectionPlus.Benchmarks/Program.cs @@ -1,11 +1,11 @@ -using BenchmarkDotNet.Running; +using BenchmarkDotNet.Running; namespace RentADeveloper.DbConnectionPlus.Benchmarks; -public class Program +public static class Program { public static void Main(String[] args) => BenchmarkSwitcher .FromAssembly(typeof(Program).Assembly) .Run(args); -} \ No newline at end of file +} diff --git a/benchmarks/DbConnectionPlus.Benchmarks/TestData/BenchmarkEntity.cs b/benchmarks/DbConnectionPlus.Benchmarks/TestData/BenchmarkEntity.cs new file mode 100644 index 0000000..36c9d27 --- /dev/null +++ b/benchmarks/DbConnectionPlus.Benchmarks/TestData/BenchmarkEntity.cs @@ -0,0 +1,26 @@ +namespace RentADeveloper.DbConnectionPlus.Benchmarks.TestData; + +[System.ComponentModel.DataAnnotations.Schema.Table("Entity")] +public record BenchmarkEntity +{ + public Boolean BooleanValue { get; set; } + public Byte[] BytesValue { get; set; } = null!; + public Byte ByteValue { get; set; } + public Char CharValue { get; set; } + public DateTime DateTimeValue { get; set; } + public Decimal DecimalValue { get; set; } + public Double DoubleValue { get; set; } + public TestEnum EnumValue { get; set; } + public Guid GuidValue { get; set; } + + [System.ComponentModel.DataAnnotations.Key] + public Int64 Id { get; set; } + + public Int16 Int16Value { get; set; } + public Int32 Int32Value { get; set; } + public Int64 Int64Value { get; set; } + + public Single SingleValue { get; set; } + public String StringValue { get; set; } = null!; + public TimeSpan TimeSpanValue { get; set; } +} diff --git a/docs/docfx.json b/docs/docfx.json index 1533139..f88415b 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -4,9 +4,9 @@ { "src": [ { - "src": "../src/DbConnectionPlus/", + "src": "../src/DbConnectionPlus", "files": [ - "**/*.csproj" + "**/bin/Release/net8.0/RentADeveloper.DbConnectionPlus.dll" ] } ], diff --git a/src/DbConnectionPlus/Configuration/DbConnectionPlusConfiguration.cs b/src/DbConnectionPlus/Configuration/DbConnectionPlusConfiguration.cs index 05ec157..8693884 100644 --- a/src/DbConnectionPlus/Configuration/DbConnectionPlusConfiguration.cs +++ b/src/DbConnectionPlus/Configuration/DbConnectionPlusConfiguration.cs @@ -20,11 +20,11 @@ public sealed class DbConnectionPlusConfiguration : IFreezable /// internal DbConnectionPlusConfiguration() { - this.databaseAdapters.TryAdd(typeof(MySqlConnection), new MySqlDatabaseAdapter()); - this.databaseAdapters.TryAdd(typeof(OracleConnection), new OracleDatabaseAdapter()); - this.databaseAdapters.TryAdd(typeof(NpgsqlConnection), new PostgreSqlDatabaseAdapter()); - this.databaseAdapters.TryAdd(typeof(SqliteConnection), new SqliteDatabaseAdapter()); - this.databaseAdapters.TryAdd(typeof(SqlConnection), new SqlServerDatabaseAdapter()); + this.databaseAdapters.Add(typeof(MySqlConnection), new MySqlDatabaseAdapter()); + this.databaseAdapters.Add(typeof(OracleConnection), new OracleDatabaseAdapter()); + this.databaseAdapters.Add(typeof(NpgsqlConnection), new PostgreSqlDatabaseAdapter()); + this.databaseAdapters.Add(typeof(SqliteConnection), new SqliteDatabaseAdapter()); + this.databaseAdapters.Add(typeof(SqlConnection), new SqlServerDatabaseAdapter()); } /// @@ -66,13 +66,13 @@ internal DbConnectionPlusConfiguration() /// public EnumSerializationMode EnumSerializationMode { - get => this.enumSerializationMode; + get; set { this.EnsureNotFrozen(); - this.enumSerializationMode = value; + field = value; } - } + } = EnumSerializationMode.Strings; /// /// A function that can be used to intercept database commands executed via DbConnectionPlus. @@ -84,11 +84,11 @@ public EnumSerializationMode EnumSerializationMode /// public InterceptDbCommand? InterceptDbCommand { - get => this.interceptDbCommand; + get; set { this.EnsureNotFrozen(); - this.interceptDbCommand = value; + field = value; } } @@ -104,10 +104,14 @@ public EntityTypeBuilder Entity() { this.EnsureNotFrozen(); - return (EntityTypeBuilder)this.entityTypeBuilders.GetOrAdd( - typeof(TEntity), - _ => new EntityTypeBuilder() - ); + if (!this.entityTypeBuilders.TryGetValue(typeof(TEntity), out var builder)) + { + builder = new EntityTypeBuilder(); + + this.entityTypeBuilders.Add(typeof(TEntity), builder); + } + + return (EntityTypeBuilder)builder; } /// @@ -127,7 +131,7 @@ public void RegisterDatabaseAdapter(IDatabaseAdapter adapter) { ArgumentNullException.ThrowIfNull(adapter); - this.databaseAdapters.AddOrUpdate(typeof(TConnection), adapter, (_, _) => adapter); + this.databaseAdapters[typeof(TConnection)] = adapter; } /// @@ -193,9 +197,7 @@ private void EnsureNotFrozen() } } - private readonly ConcurrentDictionary databaseAdapters = []; - private readonly ConcurrentDictionary entityTypeBuilders = new(); - private EnumSerializationMode enumSerializationMode = EnumSerializationMode.Strings; - private InterceptDbCommand? interceptDbCommand; + private readonly Dictionary databaseAdapters = []; + private readonly Dictionary entityTypeBuilders = new(); private Boolean isFrozen; } diff --git a/src/DbConnectionPlus/Configuration/EntityPropertyBuilder.cs b/src/DbConnectionPlus/Configuration/EntityPropertyBuilder.cs index f2e18a6..7559013 100644 --- a/src/DbConnectionPlus/Configuration/EntityPropertyBuilder.cs +++ b/src/DbConnectionPlus/Configuration/EntityPropertyBuilder.cs @@ -10,8 +10,26 @@ public sealed class EntityPropertyBuilder : IEntityPropertyBuilder /// /// The entity type builder this property builder belongs to. /// The name of the property being configured. + /// + /// + /// + /// + /// is . + /// + /// + /// + /// + /// is . + /// + /// + /// + /// + /// is whitespace. internal EntityPropertyBuilder(IEntityTypeBuilder entityTypeBuilder, String propertyName) { + ArgumentNullException.ThrowIfNull(entityTypeBuilder); + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + this.entityTypeBuilder = entityTypeBuilder; this.propertyName = propertyName; } @@ -50,6 +68,23 @@ public EntityPropertyBuilder IsComputed() return this; } + /// + /// Marks the property as participating in optimistic concurrency checks. + /// Such properties will be checked during delete and update operations. + /// When their values in the database do not match the original values, the delete or update will fail. + /// + /// This builder instance for further configuration. + /// + /// The configuration of DbConnectionPlus is already frozen and can no longer be modified. + /// + public EntityPropertyBuilder IsConcurrencyToken() + { + this.EnsureNotFrozen(); + + this.isConcurrencyToken = true; + return this; + } + /// /// Marks the property as mapped to an identity database column. /// Such properties will be ignored during insert and update operations. @@ -114,6 +149,24 @@ public EntityPropertyBuilder IsKey() return this; } + /// + /// Marks the property as mapped to a row version database column. + /// Such properties will be checked during delete and update operations. + /// When their values in the database do not match the original values, the delete or update will fail. + /// After an insert or update, their values will be read back from the database and populated on the entity. + /// + /// This builder instance for further configuration. + /// + /// The configuration of DbConnectionPlus is already frozen and can no longer be modified. + /// + public EntityPropertyBuilder IsRowVersion() + { + this.EnsureNotFrozen(); + + this.isRowVersion = true; + return this; + } + /// String? IEntityPropertyBuilder.ColumnName => this.columnName; @@ -123,6 +176,9 @@ public EntityPropertyBuilder IsKey() /// Boolean IEntityPropertyBuilder.IsComputed => this.isComputed; + /// + Boolean IEntityPropertyBuilder.IsConcurrencyToken => this.isConcurrencyToken; + /// Boolean IEntityPropertyBuilder.IsIdentity => this.isIdentity; @@ -132,6 +188,9 @@ public EntityPropertyBuilder IsKey() /// Boolean IEntityPropertyBuilder.IsKey => this.isKey; + /// + Boolean IEntityPropertyBuilder.IsRowVersion => this.isRowVersion; + /// String IEntityPropertyBuilder.PropertyName => this.propertyName; @@ -152,8 +211,10 @@ private void EnsureNotFrozen() private String? columnName; private Boolean isComputed; + private Boolean isConcurrencyToken; private Boolean isFrozen; private Boolean isIdentity; private Boolean isIgnored; private Boolean isKey; + private Boolean isRowVersion; } diff --git a/src/DbConnectionPlus/Configuration/IEntityPropertyBuilder.cs b/src/DbConnectionPlus/Configuration/IEntityPropertyBuilder.cs index c0c6b79..50efe91 100644 --- a/src/DbConnectionPlus/Configuration/IEntityPropertyBuilder.cs +++ b/src/DbConnectionPlus/Configuration/IEntityPropertyBuilder.cs @@ -15,6 +15,11 @@ internal interface IEntityPropertyBuilder : IFreezable /// internal Boolean IsComputed { get; } + /// + /// Determines whether the property participates in optimistic concurrency checks. + /// + internal Boolean IsConcurrencyToken { get; } + /// /// Determines whether the property is mapped to an identity database column. /// @@ -30,6 +35,11 @@ internal interface IEntityPropertyBuilder : IFreezable /// internal Boolean IsKey { get; } + /// + /// Determines whether the property is a row version used for concurrency control. + /// + internal Boolean IsRowVersion { get; } + /// /// The name of the property being configured. /// diff --git a/src/DbConnectionPlus/DatabaseAdapters/IEntityManipulator.cs b/src/DbConnectionPlus/DatabaseAdapters/IEntityManipulator.cs index a7f800a..1927661 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/IEntityManipulator.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/IEntityManipulator.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using RentADeveloper.DbConnectionPlus.Converters; +using RentADeveloper.DbConnectionPlus.Exceptions; namespace RentADeveloper.DbConnectionPlus.DatabaseAdapters; @@ -36,18 +37,23 @@ public interface IEntityManipulator /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entities will be deleted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// public Int32 DeleteEntities( @@ -87,18 +93,23 @@ CancellationToken cancellationToken /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entities will be deleted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// public Task DeleteEntitiesAsync( @@ -134,18 +145,23 @@ CancellationToken cancellationToken /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entity will be deleted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// public Int32 DeleteEntity( @@ -185,18 +201,23 @@ CancellationToken cancellationToken /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entity will be deleted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// public Task DeleteEntityAsync( @@ -235,14 +256,14 @@ CancellationToken cancellationToken /// /// /// The table into which the entities will be inserted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used /// as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -254,7 +275,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -299,13 +320,13 @@ CancellationToken cancellationToken /// /// /// The table into which the entities will be inserted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -317,7 +338,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -358,13 +379,13 @@ CancellationToken cancellationToken /// /// /// The table into which the entity will be inserted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -376,7 +397,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -421,13 +442,13 @@ CancellationToken cancellationToken /// /// /// The table into which the entity will be inserted can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -439,7 +460,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -477,23 +498,28 @@ CancellationToken cancellationToken /// /// /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entities will be updated can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -505,7 +531,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -548,23 +574,28 @@ CancellationToken cancellationToken /// /// /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entities will be updated can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -576,7 +607,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -614,23 +645,28 @@ CancellationToken cancellationToken /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entity will be updated can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -642,7 +678,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -684,23 +720,28 @@ CancellationToken cancellationToken /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entity will be updated can be configured via or - /// . Per default, the singular name of the type + /// . Per default, the singular name of the type /// is used as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -712,7 +753,7 @@ CancellationToken cancellationToken /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// diff --git a/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlDatabaseAdapter.cs b/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlDatabaseAdapter.cs index e88a131..40b3106 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlDatabaseAdapter.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlDatabaseAdapter.cs @@ -33,6 +33,11 @@ public void BindParameterValue(DbParameter parameter, Object? value) switch (value) { + case DateTime: + parameter.DbType = DbType.DateTime; + parameter.Value = value; + break; + case Enum enumValue: parameter.DbType = DbConnectionPlusConfiguration.Instance.EnumSerializationMode switch { @@ -54,11 +59,6 @@ public void BindParameterValue(DbParameter parameter, Object? value) ); break; - case DateTime: - parameter.DbType = DbType.DateTime; - parameter.Value = value; - break; - case Byte[]: parameter.DbType = DbType.Binary; parameter.Value = value; diff --git a/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlEntityManipulator.cs b/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlEntityManipulator.cs index cba7f92..ef2336e 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlEntityManipulator.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlEntityManipulator.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using LinkDotNet.StringBuilder; -using MySqlConnector; using RentADeveloper.DbConnectionPlus.Converters; using RentADeveloper.DbConnectionPlus.DbCommands; using RentADeveloper.DbConnectionPlus.Entities; @@ -24,21 +23,6 @@ public MySqlEntityManipulator(MySqlDatabaseAdapter databaseAdapter) => #pragma warning restore IDE0290 // Use primary constructor /// - /// - /// - /// - /// - /// is not a . - /// - /// - /// - /// - /// is not and not a - /// . - /// - /// - /// - /// public Int32 DeleteEntities( DbConnection connection, IEnumerable entities, @@ -49,106 +33,57 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); - - // For a small number of entities deleting them one by one is more efficient than creating a temp table. - if (entitiesList.Count < BulkDeleteThreshold) - { - var totalNumberOfAffectedRows = 0; - - foreach (var entity in entitiesList) - { - if (entity is null) - { - continue; - } + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); - totalNumberOfAffectedRows += this.DeleteEntity(connection, entity, transaction, cancellationToken); - } + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); - return totalNumberOfAffectedRows; - } + var totalNumberOfAffectedRows = 0; - if (connection is not MySqlConnection mySqlConnection) + using (command) + using (cancellationTokenRegistration) { - return ThrowHelper.ThrowWrongConnectionTypeException(); - } + try + { + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } - var mySqlTransaction = transaction as MySqlTransaction; + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); - if (transaction is not null && mySqlTransaction is null) - { - return ThrowHelper.ThrowWrongTransactionTypeException(); - } + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + var numberOfAffectedRows = command.ExecuteNonQuery(); - var onClause = String.Join( - " AND ", - entityTypeMetadata.KeyProperties - .Select(p => $"TKeys.`{p.PropertyName}` = `{entityTypeMetadata.TableName}`.`{p.ColumnName}`") - ); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } - try - { - var keysTableName = "Keys_" + Guid.NewGuid().ToString("N"); - - this.BuildEntityKeysTemporaryTable( - mySqlConnection, - keysTableName, - entitiesList, - entityTypeMetadata, - mySqlTransaction, - cancellationToken - ); - - var numberOfAffectedRows = connection.ExecuteNonQuery( - $""" - DELETE - {Constants.Indent}`{entityTypeMetadata.TableName}` - FROM - {Constants.Indent}`{entityTypeMetadata.TableName}` - INNER JOIN - {Constants.Indent}`{keysTableName}` AS TKeys - ON - {Constants.Indent}{onClause} - """, - transaction, - cancellationToken: cancellationToken - ); - -#pragma warning disable CA2016 - connection.ExecuteNonQuery($"DROP TEMPORARY TABLE `{keysTableName}`", transaction); -#pragma warning restore CA2016 - - return numberOfAffectedRows; - } - catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( - exception, - cancellationToken + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) ) - ) - { - throw new OperationCanceledException(cancellationToken); + { + throw new OperationCanceledException(cancellationToken); + } } + + return totalNumberOfAffectedRows; } /// - /// - /// - /// - /// - /// is not a . - /// - /// - /// - /// - /// is not and not a - /// . - /// - /// - /// - /// public async Task DeleteEntitiesAsync( DbConnection connection, IEnumerable entities, @@ -159,91 +94,55 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); - - // For a small number of entities deleting them one by one is more efficient than creating a temp table. - if (entitiesList.Count < BulkDeleteThreshold) - { - var totalNumberOfAffectedRows = 0; - - foreach (var entity in entitiesList) - { - if (entity is null) - { - continue; - } + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); - totalNumberOfAffectedRows += await this - .DeleteEntityAsync(connection, entity, transaction, cancellationToken).ConfigureAwait(false); - } + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); - return totalNumberOfAffectedRows; - } + var totalNumberOfAffectedRows = 0; - if (connection is not MySqlConnection mySqlConnection) + using (command) + using (cancellationTokenRegistration) { - return ThrowHelper.ThrowWrongConnectionTypeException(); - } + try + { + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } - var mySqlTransaction = transaction as MySqlTransaction; + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); - if (transaction is not null && mySqlTransaction is null) - { - return ThrowHelper.ThrowWrongTransactionTypeException(); - } + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + var numberOfAffectedRows = + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - var onClause = String.Join( - " AND ", - entityTypeMetadata.KeyProperties - .Select(p => $"TKeys.`{p.PropertyName}` = `{entityTypeMetadata.TableName}`.`{p.ColumnName}`") - ); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } - try - { - var keysTableName = "Keys_" + Guid.NewGuid().ToString("N"); - - await this.BuildEntityKeysTemporaryTableAsync( - mySqlConnection, - keysTableName, - entitiesList, - entityTypeMetadata, - mySqlTransaction, - cancellationToken - ).ConfigureAwait(false); - - var numberOfAffectedRows = await connection.ExecuteNonQueryAsync( - $""" - DELETE - {Constants.Indent}`{entityTypeMetadata.TableName}` - FROM - {Constants.Indent}`{entityTypeMetadata.TableName}` - INNER JOIN - {Constants.Indent}`{keysTableName}` AS TKeys - ON - {Constants.Indent}{onClause} - """, - transaction, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - -#pragma warning disable CA2016 - // ReSharper disable once MethodSupportsCancellation - await connection.ExecuteNonQueryAsync($"DROP TEMPORARY TABLE `{keysTableName}`", transaction) - .ConfigureAwait(false); -#pragma warning restore CA2016 - - return numberOfAffectedRows; - } - catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( - exception, - cancellationToken + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) ) - ) - { - throw new OperationCanceledException(cancellationToken); + { + throw new OperationCanceledException(cancellationToken); + } } + + return totalNumberOfAffectedRows; } /// @@ -270,7 +169,19 @@ CancellationToken cancellationToken try { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return command.ExecuteNonQuery(); + + var numberOfAffectedRows = command.ExecuteNonQuery(); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -307,7 +218,19 @@ CancellationToken cancellationToken try { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + var numberOfAffectedRows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -573,6 +496,20 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -637,6 +574,20 @@ await UpdateDatabaseGeneratedPropertiesAsync( cancellationToken ).ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -684,6 +635,20 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -733,6 +698,20 @@ CancellationToken cancellationToken await UpdateDatabaseGeneratedPropertiesAsync(entityTypeMetadata, reader, entity, cancellationToken) .ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -744,125 +723,6 @@ await UpdateDatabaseGeneratedPropertiesAsync(entityTypeMetadata, reader, entity, } } - /// - /// Builds a temporary table containing the keys of the provided entities. - /// - /// The type of entities for which the table is built. - /// The database connection to use to build the table. - /// The name of the table to build. - /// The entities whose keys should be stored in the table. - /// The metadata for the entity type. - /// The database transaction within to perform the operation. - /// A token that can be used to cancel the operation. - private void BuildEntityKeysTemporaryTable( - MySqlConnection connection, - String keysTableName, - List entities, - EntityTypeMetadata entityTypeMetadata, - MySqlTransaction? transaction, - CancellationToken cancellationToken - ) - { - connection.ExecuteNonQuery( - this.CreateEntityKeysTemporaryTableSqlCode(keysTableName, entityTypeMetadata), - transaction, - cancellationToken: cancellationToken - ); - - using var keysTable = new DataTable(); - - foreach (var property in entityTypeMetadata.KeyProperties) - { - var columnType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - keysTable.Columns.Add(property.PropertyName, columnType); - } - - foreach (var entity in entities) - { - if (entity is null) - { - continue; - } - - var keysRow = keysTable.NewRow(); - - foreach (var keyProperty in entityTypeMetadata.KeyProperties) - { - keysRow[keyProperty.PropertyName] = keyProperty.PropertyGetter!(entity); - } - - keysTable.Rows.Add(keysRow); - } - - var mySqlBulkCopy = new MySqlBulkCopy(connection, transaction) - { - BulkCopyTimeout = 0, - DestinationTableName = $"`{keysTableName}`" - }; - - mySqlBulkCopy.WriteToServer(keysTable); - } - - /// - /// Asynchronously builds a temporary table containing the keys of the provided entities. - /// - /// The type of entities for which the table is built. - /// The database connection to use to build the table. - /// The name of the table to build. - /// The entities whose keys should be stored in the table. - /// The metadata for the entity type. - /// The database transaction within to perform the operation. - /// A token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - private async Task BuildEntityKeysTemporaryTableAsync( - MySqlConnection connection, - String keysTableName, - List entities, - EntityTypeMetadata entityTypeMetadata, - MySqlTransaction? transaction, - CancellationToken cancellationToken - ) - { - await connection.ExecuteNonQueryAsync( - this.CreateEntityKeysTemporaryTableSqlCode(keysTableName, entityTypeMetadata), - transaction, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - - using var keysTable = new DataTable(); - - foreach (var property in entityTypeMetadata.KeyProperties) - { - var columnType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - keysTable.Columns.Add(property.PropertyName, columnType); - } - - foreach (var entity in entities) - { - if (entity is null) - { - continue; - } - - var keysRow = keysTable.NewRow(); - - foreach (var keyProperty in entityTypeMetadata.KeyProperties) - { - keysRow[keyProperty.PropertyName] = keyProperty.PropertyGetter!(entity); - } - - keysTable.Rows.Add(keysRow); - } - - var mySqlBulkCopy = new MySqlBulkCopy(connection, transaction) - { - BulkCopyTimeout = 0, - DestinationTableName = $"`{keysTableName}`" - }; - - await mySqlBulkCopy.WriteToServerAsync(keysTable, cancellationToken).ConfigureAwait(false); - } - /// /// Creates a command to delete an entity. /// @@ -881,15 +741,18 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetDeleteEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetDeleteEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { var parameter = command.CreateParameter(); parameter.ParameterName = property.PropertyName; @@ -900,58 +763,6 @@ EntityTypeMetadata entityTypeMetadata return (command, parameters); } - /// - /// Creates the SQL code to create a temporary table for the keys of the provided entity type. - /// - /// The name of the table to create. - /// The metadata for the entity type to create the table for. - /// The SQL code to create the temporary table. - private String CreateEntityKeysTemporaryTableSqlCode( - String tableName, - EntityTypeMetadata entityTypeMetadata - ) - { - if (entityTypeMetadata.KeyProperties.Count == 0) - { - ThrowHelper.ThrowEntityTypeHasNoKeyPropertyException(entityTypeMetadata.EntityType); - } - - using var createKeysTableSqlBuilder = new ValueStringBuilder(stackalloc Char[200]); - - createKeysTableSqlBuilder.Append("CREATE TEMPORARY TABLE `"); - createKeysTableSqlBuilder.Append(tableName); - createKeysTableSqlBuilder.AppendLine("`"); - - createKeysTableSqlBuilder.Append(Constants.Indent); - createKeysTableSqlBuilder.Append("("); - - var prependSeparator = false; - - foreach (var property in entityTypeMetadata.KeyProperties) - { - if (prependSeparator) - { - createKeysTableSqlBuilder.Append(", "); - } - - createKeysTableSqlBuilder.Append('`'); - createKeysTableSqlBuilder.Append(property.PropertyName); - createKeysTableSqlBuilder.Append("` "); - createKeysTableSqlBuilder.Append( - this.databaseAdapter.GetDataType( - property.PropertyType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ) - ); - - prependSeparator = true; - } - - createKeysTableSqlBuilder.AppendLine(")"); - - return createKeysTableSqlBuilder.ToString(); - } - /// /// Creates a command to insert an entity. /// @@ -970,11 +781,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetInsertEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetInsertEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -1007,11 +817,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetUpdateEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetUpdateEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -1055,7 +864,11 @@ private String GetDeleteEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => var prependSeparator = false; - foreach (var keyProperty in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var keyProperty in whereProperties) { if (prependSeparator) { @@ -1260,7 +1073,11 @@ private String GetUpdateEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => prependSeparator = false; - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { if (prependSeparator) { @@ -1315,7 +1132,11 @@ private String GetUpdateEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => prependSeparator = false; - foreach (var keyProperty in entityTypeMetadata.KeyProperties) + whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .ToList(); + + foreach (var keyProperty in whereProperties) { if (prependSeparator) { @@ -1440,5 +1261,4 @@ await reader.ReadAsync(cancellationToken).ConfigureAwait(false) private readonly ConcurrentDictionary entityDeleteSqlCodePerEntityType = new(); private readonly ConcurrentDictionary entityInsertSqlCodePerEntityType = new(); private readonly ConcurrentDictionary entityUpdateSqlCodePerEntityType = new(); - private const Int32 BulkDeleteThreshold = 10; } diff --git a/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlTemporaryTableBuilder.cs b/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlTemporaryTableBuilder.cs index a9f51bf..6dc072b 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlTemporaryTableBuilder.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/MySql/MySqlTemporaryTableBuilder.cs @@ -60,15 +60,14 @@ public TemporaryTableDisposer BuildTemporaryTable( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -79,15 +78,14 @@ public TemporaryTableDisposer BuildTemporaryTable( } else { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -162,17 +160,16 @@ public async Task BuildTemporaryTableAsync( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = #pragma warning disable CA2007 DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -185,17 +182,17 @@ public async Task BuildTemporaryTableAsync( else { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken) .ConfigureAwait(false); @@ -400,11 +397,10 @@ private static DbDataReader CreateValuesDataReader(IEnumerable values, Type valu /// The transaction within to drop the table. private static void DropTemporaryTable(String name, MySqlConnection connection, MySqlTransaction? transaction) { - using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TEMPORARY TABLE IF EXISTS `{name}`", - transaction - ); + using var command = connection.CreateCommand(); + + command.CommandText = $"DROP TEMPORARY TABLE IF EXISTS `{name}`"; + command.Transaction = transaction; DbConnectionExtensions.OnBeforeExecutingCommand(command, []); @@ -425,13 +421,12 @@ private static async ValueTask DropTemporaryTableAsync( ) { #pragma warning disable CA2007 - await using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TEMPORARY TABLE IF EXISTS `{name}`", - transaction - ); + await using var command = connection.CreateCommand(); #pragma warning restore CA2007 + command.CommandText = $"DROP TEMPORARY TABLE IF EXISTS `{name}`"; + command.Transaction = transaction; + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); await command.ExecuteNonQueryAsync().ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleDatabaseAdapter.cs b/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleDatabaseAdapter.cs index 77677d7..4f02f89 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleDatabaseAdapter.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleDatabaseAdapter.cs @@ -44,6 +44,16 @@ public void BindParameterValue(DbParameter parameter, Object? value) switch (value) { + case DateTime: + parameter.DbType = DbType.DateTime; + parameter.Value = value; + break; + + case Guid guid: + parameter.DbType = DbType.Binary; + parameter.Value = guid; + break; + case Enum enumValue: parameter.DbType = DbConnectionPlusConfiguration.Instance.EnumSerializationMode switch { @@ -65,16 +75,6 @@ public void BindParameterValue(DbParameter parameter, Object? value) ); break; - case Guid guid: - parameter.DbType = DbType.Binary; - parameter.Value = guid; - break; - - case DateTime: - parameter.DbType = DbType.DateTime; - parameter.Value = value; - break; - case Byte[]: parameter.DbType = DbType.Binary; parameter.Value = value; diff --git a/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleEntityManipulator.cs b/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleEntityManipulator.cs index a1bf7d1..aeb1486 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleEntityManipulator.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleEntityManipulator.cs @@ -31,18 +31,51 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); var totalNumberOfAffectedRows = 0; - foreach (var entity in entitiesList) + using (command) + using (cancellationTokenRegistration) { - if (entity is null) + try { - continue; - } + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } + + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); + + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); + + var numberOfAffectedRows = command.ExecuteNonQuery(); - totalNumberOfAffectedRows += this.DeleteEntity(connection, entity, transaction, cancellationToken); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) + ) + { + throw new OperationCanceledException(cancellationToken); + } } return totalNumberOfAffectedRows; @@ -59,19 +92,52 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); var totalNumberOfAffectedRows = 0; - foreach (var entity in entitiesList) + using (command) + using (cancellationTokenRegistration) { - if (entity is null) + try { - continue; - } + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } + + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); + + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); + + var numberOfAffectedRows = + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - totalNumberOfAffectedRows += await this - .DeleteEntityAsync(connection, entity, transaction, cancellationToken).ConfigureAwait(false); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) + ) + { + throw new OperationCanceledException(cancellationToken); + } } return totalNumberOfAffectedRows; @@ -102,7 +168,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return command.ExecuteNonQuery(); + var numberOfAffectedRows = command.ExecuteNonQuery(); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -140,7 +217,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + var numberOfAffectedRows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -396,7 +484,18 @@ CancellationToken cancellationToken DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - totalNumberOfAffectedRows += command.ExecuteNonQuery(); + var numberOfAffectedRows = command.ExecuteNonQuery(); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + totalNumberOfAffectedRows += numberOfAffectedRows; var outputParameters = parameters.Where(a => a.Direction == ParameterDirection.Output).ToArray(); @@ -452,9 +551,20 @@ CancellationToken cancellationToken DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - totalNumberOfAffectedRows += + var numberOfAffectedRows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + totalNumberOfAffectedRows += numberOfAffectedRows; + var outputParameters = parameters.Where(a => a.Direction == ParameterDirection.Output).ToArray(); UpdateDatabaseGeneratedProperties(entityTypeMetadata, outputParameters, entity); @@ -502,6 +612,15 @@ CancellationToken cancellationToken var numberOfAffectedRows = command.ExecuteNonQuery(); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + var outputParameters = parameters.Where(a => a.Direction == ParameterDirection.Output).ToArray(); UpdateDatabaseGeneratedProperties(entityTypeMetadata, outputParameters, entity); @@ -548,6 +667,15 @@ CancellationToken cancellationToken var numberOfAffectedRows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + var outputParameters = parameters.Where(a => a.Direction == ParameterDirection.Output).ToArray(); UpdateDatabaseGeneratedProperties(entityTypeMetadata, outputParameters, entity); @@ -581,15 +709,18 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetDeleteEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetDeleteEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { var parameter = command.CreateParameter(); parameter.ParameterName = property.PropertyName; @@ -618,11 +749,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetInsertEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetInsertEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -639,11 +769,20 @@ EntityTypeMetadata entityTypeMetadata { var parameter = command.CreateParameter(); parameter.ParameterName = "return_" + property.ColumnName; + parameter.DbType = this.databaseAdapter.GetDbType( property.PropertyType, DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + parameter.Direction = ParameterDirection.Output; + + if (property.PropertyType == typeof(Byte[])) + { + // Use max size for byte arrays to actually retrieve the full value: + parameter.Size = 32767; + } + parameters.Add(parameter); command.Parameters.Add(parameter); } @@ -669,15 +808,18 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetUpdateEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetUpdateEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); - foreach (var property in entityTypeMetadata.UpdateProperties.Concat(entityTypeMetadata.KeyProperties)) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in entityTypeMetadata.UpdateProperties.Concat(whereProperties)) { var parameter = command.CreateParameter(); parameter.ParameterName = property.PropertyName; @@ -690,11 +832,20 @@ EntityTypeMetadata entityTypeMetadata { var parameter = command.CreateParameter(); parameter.ParameterName = "return_" + property.ColumnName; + parameter.DbType = this.databaseAdapter.GetDbType( property.PropertyType, DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + parameter.Direction = ParameterDirection.Output; + + if (property.PropertyType == typeof(Byte[])) + { + // Use max size for byte arrays to actually retrieve the full value: + parameter.Size = 32767; + } + parameters.Add(parameter); command.Parameters.Add(parameter); } @@ -732,7 +883,11 @@ private String GetDeleteEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => var prependSeparator = false; - foreach (var keyProperty in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var keyProperty in whereProperties) { if (prependSeparator) { @@ -912,7 +1067,11 @@ private String GetUpdateEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => prependSeparator = false; - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { if (prependSeparator) { diff --git a/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleTemporaryTableBuilder.cs b/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleTemporaryTableBuilder.cs index a7c35dd..9b509f7 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleTemporaryTableBuilder.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/Oracle/OracleTemporaryTableBuilder.cs @@ -69,17 +69,16 @@ public TemporaryTableDisposer BuildTemporaryTable( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - quotedTableName, - // ReSharper disable once PossibleMultipleEnumeration - values, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + quotedTableName, + // ReSharper disable once PossibleMultipleEnumeration + values, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -90,15 +89,14 @@ public TemporaryTableDisposer BuildTemporaryTable( } else { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - quotedTableName, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + quotedTableName, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -166,19 +164,18 @@ public async Task BuildTemporaryTableAsync( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - quotedTableName, - // ReSharper disable once PossibleMultipleEnumeration - values, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + quotedTableName, + // ReSharper disable once PossibleMultipleEnumeration + values, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken).ConfigureAwait(false); @@ -189,17 +186,16 @@ public async Task BuildTemporaryTableAsync( else { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - quotedTableName, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + quotedTableName, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken).ConfigureAwait(false); @@ -556,11 +552,10 @@ private static void DropTemporaryTable( OracleTransaction? transaction ) { - using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TABLE {quotedTableName}", - transaction - ); + using var command = connection.CreateCommand(); + + command.CommandText = $"DROP TABLE {quotedTableName}"; + command.Transaction = transaction; DbConnectionExtensions.OnBeforeExecutingCommand(command, []); @@ -581,13 +576,12 @@ private static async ValueTask DropTemporaryTableAsync( ) { #pragma warning disable CA2007 - await using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TABLE {quotedTableName}", - transaction - ); + await using var command = connection.CreateCommand(); #pragma warning restore CA2007 + command.CommandText = $"DROP TABLE {quotedTableName}"; + command.Transaction = transaction; + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); await command.ExecuteNonQueryAsync().ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlDatabaseAdapter.cs b/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlDatabaseAdapter.cs index 275adaa..6bce060 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlDatabaseAdapter.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlDatabaseAdapter.cs @@ -34,6 +34,11 @@ public void BindParameterValue(DbParameter parameter, Object? value) switch (value) { + case DateTime: + parameter.DbType = DbType.DateTime2; + parameter.Value = value; + break; + case Enum enumValue: parameter.DbType = DbConnectionPlusConfiguration.Instance.EnumSerializationMode switch { @@ -55,11 +60,6 @@ public void BindParameterValue(DbParameter parameter, Object? value) ); break; - case DateTime: - parameter.DbType = DbType.DateTime2; - parameter.Value = value; - break; - case Byte[]: parameter.DbType = DbType.Binary; parameter.Value = value; diff --git a/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlEntityManipulator.cs b/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlEntityManipulator.cs index 9567f39..39edd3e 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlEntityManipulator.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlEntityManipulator.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using LinkDotNet.StringBuilder; -using Npgsql; using RentADeveloper.DbConnectionPlus.Converters; using RentADeveloper.DbConnectionPlus.DbCommands; using RentADeveloper.DbConnectionPlus.Entities; @@ -22,21 +21,6 @@ public PostgreSqlEntityManipulator(PostgreSqlDatabaseAdapter databaseAdapter) => this.databaseAdapter = databaseAdapter; /// - /// - /// - /// - /// - /// is not a . - /// - /// - /// - /// - /// is not and not a - /// . - /// - /// - /// - /// public Int32 DeleteEntities( DbConnection connection, IEnumerable entities, @@ -47,105 +31,57 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); - - // For a small number of entities deleting them one by one is more efficient than creating a temp table. - if (entitiesList.Count < BulkDeleteThreshold) - { - var totalNumberOfAffectedRows = 0; - - foreach (var entity in entitiesList) - { - if (entity is null) - { - continue; - } + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); - totalNumberOfAffectedRows += this.DeleteEntity(connection, entity, transaction, cancellationToken); - } + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); - return totalNumberOfAffectedRows; - } + var totalNumberOfAffectedRows = 0; - if (connection is not NpgsqlConnection npgsqlConnection) + using (command) + using (cancellationTokenRegistration) { - return ThrowHelper.ThrowWrongConnectionTypeException(); - } + try + { + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } - var npgsqlTransaction = transaction as NpgsqlTransaction; + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); - if (transaction is not null && npgsqlTransaction is null) - { - return ThrowHelper.ThrowWrongTransactionTypeException(); - } + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + var numberOfAffectedRows = command.ExecuteNonQuery(); - var whereClause = String.Join( - " AND ", - entityTypeMetadata.KeyProperties.Select(p => - $"TKeys.\"{p.PropertyName}\" = \"{entityTypeMetadata.TableName}\".\"{p.ColumnName}\"" - ) - ); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } - try - { - var keysTableName = "Keys_" + Guid.NewGuid().ToString("N"); - - this.BuildEntityKeysTemporaryTable( - npgsqlConnection, - keysTableName, - entitiesList, - entityTypeMetadata, - npgsqlTransaction, - cancellationToken - ); - - var numberOfAffectedRows = connection.ExecuteNonQuery( - $""" - DELETE FROM - {Constants.Indent}"{entityTypeMetadata.TableName}" - USING - {Constants.Indent}"{keysTableName}" AS TKeys - WHERE - {Constants.Indent}{whereClause} - """, - transaction, - cancellationToken: cancellationToken - ); - -#pragma warning disable CA2016 - connection.ExecuteNonQuery($"DROP TABLE \"{keysTableName}\"", transaction); -#pragma warning restore CA2016 - - return numberOfAffectedRows; - } - catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( - exception, - cancellationToken + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) ) - ) - { - throw new OperationCanceledException(cancellationToken); + { + throw new OperationCanceledException(cancellationToken); + } } + + return totalNumberOfAffectedRows; } /// - /// - /// - /// - /// - /// is not a . - /// - /// - /// - /// - /// is not and not a - /// . - /// - /// - /// - /// public async Task DeleteEntitiesAsync( DbConnection connection, IEnumerable entities, @@ -156,92 +92,55 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); - - // For a small number of entities deleting them one by one is more efficient than creating a temp table. - if (entitiesList.Count < BulkDeleteThreshold) - { - var totalNumberOfAffectedRows = 0; - - foreach (var entity in entitiesList) - { - if (entity is null) - { - continue; - } + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); - totalNumberOfAffectedRows += await this - .DeleteEntityAsync(connection, entity, transaction, cancellationToken).ConfigureAwait(false); - } + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); - return totalNumberOfAffectedRows; - } + var totalNumberOfAffectedRows = 0; - if (connection is not NpgsqlConnection npgsqlConnection) + using (command) + using (cancellationTokenRegistration) { - return ThrowHelper.ThrowWrongConnectionTypeException(); - } + try + { + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } - var npgsqlTransaction = transaction as NpgsqlTransaction; + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); - if (transaction is not null && npgsqlTransaction is null) - { - return ThrowHelper.ThrowWrongTransactionTypeException(); - } + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + var numberOfAffectedRows = + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - var whereClause = String.Join( - " AND ", - entityTypeMetadata.KeyProperties.Select(p => - $"TKeys.\"{p.PropertyName}\" = \"{entityTypeMetadata.TableName}\".\"{p.ColumnName}\"" - ) - ); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } - try - { - var keysTableName = "Keys_" + Guid.NewGuid().ToString("N"); - - await this.BuildEntityKeysTemporaryTableAsync( - npgsqlConnection, - keysTableName, - entitiesList, - entityTypeMetadata, - npgsqlTransaction, - cancellationToken - ).ConfigureAwait(false); - - var numberOfAffectedRows = await connection.ExecuteNonQueryAsync( - $""" - DELETE FROM - {Constants.Indent}"{entityTypeMetadata.TableName}" - USING - {Constants.Indent}"{keysTableName}" AS TKeys - WHERE - {Constants.Indent}{whereClause} - """, - transaction, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - -#pragma warning disable CA2016 - await connection.ExecuteNonQueryAsync( - $"DROP TABLE \"{keysTableName}\"", - transaction, - cancellationToken: cancellationToken - ).ConfigureAwait(false); -#pragma warning restore CA2016 - - return numberOfAffectedRows; - } - catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( - exception, - cancellationToken + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) ) - ) - { - throw new OperationCanceledException(cancellationToken); + { + throw new OperationCanceledException(cancellationToken); + } } + + return totalNumberOfAffectedRows; } /// @@ -269,7 +168,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return command.ExecuteNonQuery(); + var numberOfAffectedRows = command.ExecuteNonQuery(); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -307,7 +217,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + var numberOfAffectedRows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -574,6 +495,19 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the RETURNING clause. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -638,6 +572,19 @@ await UpdateDatabaseGeneratedPropertiesAsync( cancellationToken ).ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the RETURNING clause. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -685,6 +632,19 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the RETURNING clause. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -734,6 +694,19 @@ CancellationToken cancellationToken await UpdateDatabaseGeneratedPropertiesAsync(entityTypeMetadata, reader, entity, cancellationToken) .ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the RETURNING clause. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -745,146 +718,6 @@ await UpdateDatabaseGeneratedPropertiesAsync(entityTypeMetadata, reader, entity, } } - /// - /// Builds a temporary table containing the keys of the provided entities. - /// - /// The type of entities for which the table is built. - /// The database connection to use to build the table. - /// The name of the table to build. - /// The entities whose keys should be stored in the table. - /// The metadata for the entity type. - /// The database transaction within to perform the operation. - /// A token that can be used to cancel the operation. - private void BuildEntityKeysTemporaryTable( - NpgsqlConnection connection, - String keysTableName, - List entities, - EntityTypeMetadata entityTypeMetadata, - NpgsqlTransaction? transaction, - CancellationToken cancellationToken - ) - { - connection.ExecuteNonQuery( - this.CreateEntityKeysTemporaryTableSqlCode(keysTableName, entityTypeMetadata), - transaction, - cancellationToken: cancellationToken - ); - - var npgsqlDbTypes = entityTypeMetadata - .KeyProperties - .Select(p => this.databaseAdapter.GetDbType( - p.PropertyType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ) - ) - .ToArray(); - - using var importer = connection.BeginBinaryImport($"COPY \"{keysTableName}\" FROM STDIN (FORMAT BINARY)"); - - foreach (var entity in entities) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (entity is null) - { - continue; - } - - importer.StartRow(); - - for (var i = 0; i < entityTypeMetadata.KeyProperties.Count; i++) - { - var keyProperty = entityTypeMetadata.KeyProperties[i]; - var keyValue = keyProperty.PropertyGetter!(entity); - - if (keyValue is null) - { - importer.WriteNull(); - } - else - { - importer.Write(keyValue, npgsqlDbTypes[i]); - } - } - } - - importer.Complete(); - importer.Close(); - } - - /// - /// Asynchronously builds a temporary table containing the keys of the provided entities. - /// - /// The type of entities for which the table is built. - /// The database connection to use to build the table. - /// The name of the table to build. - /// The entities whose keys should be stored in the table. - /// The metadata for the entity type. - /// The database transaction within to perform the operation. - /// A token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - private async Task BuildEntityKeysTemporaryTableAsync( - NpgsqlConnection connection, - String keysTableName, - List entities, - EntityTypeMetadata entityTypeMetadata, - NpgsqlTransaction? transaction, - CancellationToken cancellationToken - ) - { - await connection.ExecuteNonQueryAsync( - this.CreateEntityKeysTemporaryTableSqlCode(keysTableName, entityTypeMetadata), - transaction, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - - var npgsqlDbTypes = entityTypeMetadata - .KeyProperties - .Select(p => this.databaseAdapter.GetDbType( - p.PropertyType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ) - ) - .ToArray(); - -#pragma warning disable CA2007 // Consider calling ConfigureAwait on the awaited task - await using var importer = await connection.BeginBinaryImportAsync( - $"COPY \"{keysTableName}\" FROM STDIN (FORMAT BINARY)", - cancellationToken - ).ConfigureAwait(false); -#pragma warning restore CA2007 // Consider calling ConfigureAwait on the awaited task - - foreach (var entity in entities) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (entity is null) - { - continue; - } - - await importer.StartRowAsync(cancellationToken).ConfigureAwait(false); - - for (var i = 0; i < entityTypeMetadata.KeyProperties.Count; i++) - { - var keyProperty = entityTypeMetadata.KeyProperties[i]; - var keyValue = keyProperty.PropertyGetter!(entity); - - if (keyValue is null) - { - await importer.WriteNullAsync(cancellationToken).ConfigureAwait(false); - } - else - { - await importer.WriteAsync(keyValue, npgsqlDbTypes[i], cancellationToken).ConfigureAwait(false); - } - } - } - - await importer.CompleteAsync(cancellationToken).ConfigureAwait(false); - await importer.CloseAsync(cancellationToken).ConfigureAwait(false); - } - /// /// Creates a command to delete an entity. /// @@ -903,15 +736,18 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetDeleteEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetDeleteEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { var parameter = command.CreateParameter(); parameter.ParameterName = property.PropertyName; @@ -922,58 +758,6 @@ EntityTypeMetadata entityTypeMetadata return (command, parameters); } - /// - /// Creates the SQL code to create a temporary table for the keys of the provided entity type. - /// - /// The name of the table to create. - /// The metadata for the entity type to create the table for. - /// The SQL code to create the temporary table. - private String CreateEntityKeysTemporaryTableSqlCode( - String tableName, - EntityTypeMetadata entityTypeMetadata - ) - { - if (entityTypeMetadata.KeyProperties.Count == 0) - { - ThrowHelper.ThrowEntityTypeHasNoKeyPropertyException(entityTypeMetadata.EntityType); - } - - using var createKeysTableSqlBuilder = new ValueStringBuilder(stackalloc Char[200]); - - createKeysTableSqlBuilder.Append("CREATE TEMP TABLE \""); - createKeysTableSqlBuilder.Append(tableName); - createKeysTableSqlBuilder.AppendLine("\""); - - createKeysTableSqlBuilder.Append(Constants.Indent); - createKeysTableSqlBuilder.Append("("); - - var prependSeparator = false; - - foreach (var property in entityTypeMetadata.KeyProperties) - { - if (prependSeparator) - { - createKeysTableSqlBuilder.Append(", "); - } - - createKeysTableSqlBuilder.Append('"'); - createKeysTableSqlBuilder.Append(property.PropertyName); - createKeysTableSqlBuilder.Append("\" "); - createKeysTableSqlBuilder.Append( - this.databaseAdapter.GetDataType( - property.PropertyType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ) - ); - - prependSeparator = true; - } - - createKeysTableSqlBuilder.AppendLine(")"); - - return createKeysTableSqlBuilder.ToString(); - } - /// /// Creates a command to insert an entity. /// @@ -992,11 +776,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetInsertEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetInsertEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -1029,11 +812,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetUpdateEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetUpdateEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -1078,7 +860,11 @@ private String GetDeleteEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => var prependSeparator = false; - foreach (var keyProperty in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var keyProperty in whereProperties) { if (prependSeparator) { @@ -1239,7 +1025,11 @@ private String GetUpdateEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => prependSeparator = false; - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { if (prependSeparator) { @@ -1387,5 +1177,4 @@ await reader.ReadAsync(cancellationToken).ConfigureAwait(false) private readonly ConcurrentDictionary entityDeleteSqlCodePerEntityType = new(); private readonly ConcurrentDictionary entityInsertSqlCodePerEntityType = new(); private readonly ConcurrentDictionary entityUpdateSqlCodePerEntityType = new(); - private const Int32 BulkDeleteThreshold = 10; } diff --git a/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlTemporaryTableBuilder.cs b/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlTemporaryTableBuilder.cs index aa78493..97a5bda 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlTemporaryTableBuilder.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/PostgreSql/PostgreSqlTemporaryTableBuilder.cs @@ -60,15 +60,14 @@ public TemporaryTableDisposer BuildTemporaryTable( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -79,15 +78,14 @@ public TemporaryTableDisposer BuildTemporaryTable( } else { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -137,17 +135,16 @@ public async Task BuildTemporaryTableAsync( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken).ConfigureAwait(false); @@ -158,17 +155,16 @@ public async Task BuildTemporaryTableAsync( else { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken).ConfigureAwait(false); @@ -419,11 +415,10 @@ private static DbDataReader CreateValuesDataReader(IEnumerable values, Type valu /// The transaction within to drop the table. private static void DropTemporaryTable(String name, NpgsqlConnection connection, NpgsqlTransaction? transaction) { - using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TABLE IF EXISTS \"{name}\"", - transaction - ); + using var command = connection.CreateCommand(); + + command.CommandText = $"DROP TABLE IF EXISTS \"{name}\""; + command.Transaction = transaction; DbConnectionExtensions.OnBeforeExecutingCommand(command, []); @@ -444,13 +439,12 @@ private static async ValueTask DropTemporaryTableAsync( ) { #pragma warning disable CA2007 - await using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TABLE IF EXISTS \"{name}\"", - transaction - ); + await using var command = connection.CreateCommand(); #pragma warning restore CA2007 + command.CommandText = $"DROP TABLE IF EXISTS \"{name}\""; + command.Transaction = transaction; + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); await command.ExecuteNonQueryAsync().ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerDatabaseAdapter.cs b/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerDatabaseAdapter.cs index 554a70c..0c2182f 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerDatabaseAdapter.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerDatabaseAdapter.cs @@ -33,6 +33,11 @@ public void BindParameterValue(DbParameter parameter, Object? value) switch (value) { + case DateTime: + parameter.DbType = DbType.DateTime2; + parameter.Value = value; + break; + case Enum enumValue: parameter.DbType = DbConnectionPlusConfiguration.Instance.EnumSerializationMode switch { @@ -54,11 +59,6 @@ public void BindParameterValue(DbParameter parameter, Object? value) ); break; - case DateTime: - parameter.DbType = DbType.DateTime2; - parameter.Value = value; - break; - case Byte[]: parameter.DbType = DbType.Binary; parameter.Value = value; diff --git a/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerEntityManipulator.cs b/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerEntityManipulator.cs index cbd2924..da65721 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerEntityManipulator.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerEntityManipulator.cs @@ -21,21 +21,6 @@ public SqlServerEntityManipulator(SqlServerDatabaseAdapter databaseAdapter) => this.databaseAdapter = databaseAdapter; /// - /// - /// - /// - /// - /// is not a . - /// - /// - /// - /// - /// is not and not a - /// . - /// - /// - /// - /// public Int32 DeleteEntities( DbConnection connection, IEnumerable entities, @@ -46,105 +31,57 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); - - // For a small number of entities deleting them one by one is more efficient than creating a temp table. - if (entitiesList.Count < BulkDeleteThreshold) - { - var totalNumberOfAffectedRows = 0; - - foreach (var entity in entitiesList) - { - if (entity is null) - { - continue; - } + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); - totalNumberOfAffectedRows += this.DeleteEntity(connection, entity, transaction, cancellationToken); - } + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); - return totalNumberOfAffectedRows; - } + var totalNumberOfAffectedRows = 0; - if (connection is not SqlConnection sqlConnection) + using (command) + using (cancellationTokenRegistration) { - return ThrowHelper.ThrowWrongConnectionTypeException(); - } + try + { + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } - var sqlTransaction = transaction as SqlTransaction; + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); - if (transaction is not null && sqlTransaction is null) - { - return ThrowHelper.ThrowWrongTransactionTypeException(); - } + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + var numberOfAffectedRows = command.ExecuteNonQuery(); - var onClause = String.Join( - " AND ", - entityTypeMetadata.KeyProperties.Select(p => $"TKeys.[{p.PropertyName}] = TEntities.[{p.ColumnName}]") - ); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } - try - { - var keysTableName = "Keys_" + Guid.NewGuid().ToString("N"); - - this.BuildEntityKeysTemporaryTable( - sqlConnection, - keysTableName, - entitiesList, - entityTypeMetadata, - sqlTransaction, - cancellationToken - ); - - var numberOfAffectedRows = connection.ExecuteNonQuery( - $""" - DELETE - {Constants.Indent}[{entityTypeMetadata.TableName}] - FROM - {Constants.Indent}[{entityTypeMetadata.TableName}] AS TEntities - INNER JOIN - {Constants.Indent}[#{keysTableName}] AS TKeys - ON - {Constants.Indent}{onClause} - """, - transaction, - cancellationToken: cancellationToken - ); - -#pragma warning disable CA2016 - connection.ExecuteNonQuery($"DROP TABLE [#{keysTableName}]", transaction); -#pragma warning restore CA2016 - - return numberOfAffectedRows; - } - catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( - exception, - cancellationToken + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) ) - ) - { - throw new OperationCanceledException(cancellationToken); + { + throw new OperationCanceledException(cancellationToken); + } } + + return totalNumberOfAffectedRows; } /// - /// - /// - /// - /// - /// is not a . - /// - /// - /// - /// - /// is not and not a - /// . - /// - /// - /// - /// public async Task DeleteEntitiesAsync( DbConnection connection, IEnumerable entities, @@ -155,89 +92,55 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); - var entitiesList = entities.ToList(); - - // For a small number of entities deleting them one by one is more efficient than creating a temp table. - if (entitiesList.Count < BulkDeleteThreshold) - { - var totalNumberOfAffectedRows = 0; - - foreach (var entity in entitiesList) - { - if (entity is null) - { - continue; - } + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); - totalNumberOfAffectedRows += await this - .DeleteEntityAsync(connection, entity, transaction, cancellationToken).ConfigureAwait(false); - } + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); - return totalNumberOfAffectedRows; - } + var totalNumberOfAffectedRows = 0; - if (connection is not SqlConnection sqlConnection) + using (command) + using (cancellationTokenRegistration) { - return ThrowHelper.ThrowWrongConnectionTypeException(); - } + try + { + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } - var sqlTransaction = transaction as SqlTransaction; + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); - if (transaction is not null && sqlTransaction is null) - { - return ThrowHelper.ThrowWrongTransactionTypeException(); - } + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + var numberOfAffectedRows = + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); - var onClause = String.Join( - " AND ", - entityTypeMetadata.KeyProperties.Select(p => $"TKeys.[{p.PropertyName}] = TEntities.[{p.ColumnName}]") - ); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } - try - { - var keysTableName = "Keys_" + Guid.NewGuid().ToString("N"); - - await this.BuildEntityKeysTemporaryTableAsync( - sqlConnection, - keysTableName, - entitiesList, - entityTypeMetadata, - sqlTransaction, - cancellationToken - ).ConfigureAwait(false); - - var numberOfAffectedRows = await connection.ExecuteNonQueryAsync( - $""" - DELETE - {Constants.Indent}[{entityTypeMetadata.TableName}] - FROM - {Constants.Indent}[{entityTypeMetadata.TableName}] AS TEntities - INNER JOIN - {Constants.Indent}[#{keysTableName}] AS TKeys - ON - {Constants.Indent}{onClause} - """, - transaction, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - -#pragma warning disable CA2016 - // ReSharper disable once MethodSupportsCancellation - await connection.ExecuteNonQueryAsync($"DROP TABLE [#{keysTableName}]", transaction).ConfigureAwait(false); -#pragma warning restore CA2016 - - return numberOfAffectedRows; - } - catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( - exception, - cancellationToken + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) ) - ) - { - throw new OperationCanceledException(cancellationToken); + { + throw new OperationCanceledException(cancellationToken); + } } + + return totalNumberOfAffectedRows; } /// @@ -265,7 +168,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return command.ExecuteNonQuery(); + var numberOfAffectedRows = command.ExecuteNonQuery(); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -303,7 +217,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + var numberOfAffectedRows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -570,6 +495,19 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the OUTPUT clause. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -634,6 +572,19 @@ await UpdateDatabaseGeneratedPropertiesAsync( cancellationToken ).ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the OUTPUT clause. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -681,6 +632,19 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the OUTPUT clause. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -730,6 +694,19 @@ CancellationToken cancellationToken await UpdateDatabaseGeneratedPropertiesAsync(entityTypeMetadata, reader, entity, cancellationToken) .ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the OUTPUT clause. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -741,119 +718,6 @@ await UpdateDatabaseGeneratedPropertiesAsync(entityTypeMetadata, reader, entity, } } - /// - /// Builds a temporary table containing the keys of the provided entities. - /// - /// The type of entities for which the table is built. - /// The database connection to use to build the table. - /// The name of the table to build. - /// The entities whose keys should be stored in the table. - /// The metadata for the entity type. - /// The database transaction within to perform the operation. - /// A token that can be used to cancel the operation. - private void BuildEntityKeysTemporaryTable( - SqlConnection connection, - String keysTableName, - List entities, - EntityTypeMetadata entityTypeMetadata, - SqlTransaction? transaction, - CancellationToken cancellationToken - ) - { - connection.ExecuteNonQuery( - this.CreateEntityKeysTemporaryTableSqlCode(keysTableName, entityTypeMetadata), - transaction, - cancellationToken: cancellationToken - ); - - using var keysTable = new DataTable(); - - foreach (var property in entityTypeMetadata.KeyProperties) - { - var columnType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - keysTable.Columns.Add(property.PropertyName, columnType); - } - - foreach (var entity in entities) - { - if (entity is null) - { - continue; - } - - var keysRow = keysTable.NewRow(); - - foreach (var keyProperty in entityTypeMetadata.KeyProperties) - { - keysRow[keyProperty.PropertyName] = keyProperty.PropertyGetter!(entity); - } - - keysTable.Rows.Add(keysRow); - } - - using var sqlBulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.Default, transaction); - sqlBulkCopy.BatchSize = 0; - sqlBulkCopy.DestinationTableName = "#" + keysTableName; - sqlBulkCopy.WriteToServer(keysTable); - } - - /// - /// Asynchronously builds a temporary table containing the keys of the provided entities. - /// - /// The type of entities for which the table is built. - /// The database connection to use to build the table. - /// The name of the table to build. - /// The entities whose keys should be stored in the table. - /// The metadata for the entity type. - /// The database transaction within to perform the operation. - /// A token that can be used to cancel the operation. - /// A task that represents the asynchronous operation. - private async Task BuildEntityKeysTemporaryTableAsync( - SqlConnection connection, - String keysTableName, - List entities, - EntityTypeMetadata entityTypeMetadata, - SqlTransaction? transaction, - CancellationToken cancellationToken - ) - { - await connection.ExecuteNonQueryAsync( - this.CreateEntityKeysTemporaryTableSqlCode(keysTableName, entityTypeMetadata), - transaction, - cancellationToken: cancellationToken - ).ConfigureAwait(false); - - using var keysTable = new DataTable(); - - foreach (var property in entityTypeMetadata.KeyProperties) - { - var columnType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; - keysTable.Columns.Add(property.PropertyName, columnType); - } - - foreach (var entity in entities) - { - if (entity is null) - { - continue; - } - - var keysRow = keysTable.NewRow(); - - foreach (var keyProperty in entityTypeMetadata.KeyProperties) - { - keysRow[keyProperty.PropertyName] = keyProperty.PropertyGetter!(entity); - } - - keysTable.Rows.Add(keysRow); - } - - using var sqlBulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.Default, transaction); - sqlBulkCopy.BatchSize = 0; - sqlBulkCopy.DestinationTableName = "#" + keysTableName; - await sqlBulkCopy.WriteToServerAsync(keysTable, cancellationToken).ConfigureAwait(false); - } - /// /// Creates a command to delete an entity. /// @@ -872,15 +736,18 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetDeleteEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetDeleteEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { var parameter = command.CreateParameter(); parameter.ParameterName = property.PropertyName; @@ -891,58 +758,6 @@ EntityTypeMetadata entityTypeMetadata return (command, parameters); } - /// - /// Creates the SQL code to create a temporary table for the keys of the provided entity type. - /// - /// The name of the table to create. - /// The metadata for the entity type to create the table for. - /// The SQL code to create the temporary table. - private String CreateEntityKeysTemporaryTableSqlCode( - String tableName, - EntityTypeMetadata entityTypeMetadata - ) - { - if (entityTypeMetadata.KeyProperties.Count == 0) - { - ThrowHelper.ThrowEntityTypeHasNoKeyPropertyException(entityTypeMetadata.EntityType); - } - - using var createKeysTableSqlBuilder = new ValueStringBuilder(stackalloc Char[200]); - - createKeysTableSqlBuilder.Append("CREATE TABLE [#"); - createKeysTableSqlBuilder.Append(tableName); - createKeysTableSqlBuilder.AppendLine("]"); - - createKeysTableSqlBuilder.Append(Constants.Indent); - createKeysTableSqlBuilder.Append("("); - - var prependSeparator = false; - - foreach (var property in entityTypeMetadata.KeyProperties) - { - if (prependSeparator) - { - createKeysTableSqlBuilder.Append(", "); - } - - createKeysTableSqlBuilder.Append('['); - createKeysTableSqlBuilder.Append(property.PropertyName); - createKeysTableSqlBuilder.Append("] "); - createKeysTableSqlBuilder.Append( - this.databaseAdapter.GetDataType( - property.PropertyType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ) - ); - - prependSeparator = true; - } - - createKeysTableSqlBuilder.AppendLine(")"); - - return createKeysTableSqlBuilder.ToString(); - } - /// /// Creates a command to insert an entity. /// @@ -961,11 +776,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetInsertEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetInsertEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -998,11 +812,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetUpdateEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetUpdateEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -1047,7 +860,11 @@ private String GetDeleteEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => var prependSeparator = false; - foreach (var keyProperty in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { if (prependSeparator) { @@ -1055,9 +872,9 @@ private String GetDeleteEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => } sqlBuilder.Append('['); - sqlBuilder.Append(keyProperty.ColumnName); + sqlBuilder.Append(property.ColumnName); sqlBuilder.Append("] = @"); - sqlBuilder.Append(keyProperty.PropertyName); + sqlBuilder.Append(property.PropertyName); prependSeparator = true; } @@ -1232,7 +1049,11 @@ private String GetUpdateEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => prependSeparator = false; - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { if (prependSeparator) { @@ -1356,5 +1177,4 @@ await reader.ReadAsync(cancellationToken).ConfigureAwait(false) private readonly ConcurrentDictionary entityDeleteSqlCodePerEntityType = new(); private readonly ConcurrentDictionary entityInsertSqlCodePerEntityType = new(); private readonly ConcurrentDictionary entityUpdateSqlCodePerEntityType = new(); - private const Int32 BulkDeleteThreshold = 10; } diff --git a/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerTemporaryTableBuilder.cs b/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerTemporaryTableBuilder.cs index 260cd00..c63d73a 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerTemporaryTableBuilder.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/SqlServer/SqlServerTemporaryTableBuilder.cs @@ -64,18 +64,17 @@ public TemporaryTableDisposer BuildTemporaryTable( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - // ReSharper disable once PossibleMultipleEnumeration - values, - valuesType, - databaseCollation, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + // ReSharper disable once PossibleMultipleEnumeration + values, + valuesType, + databaseCollation, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -86,16 +85,15 @@ public TemporaryTableDisposer BuildTemporaryTable( } else { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - databaseCollation, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + databaseCollation, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -177,18 +175,16 @@ public async Task BuildTemporaryTableAsync( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - // ReSharper disable once PossibleMultipleEnumeration - values, - valuesType, - databaseCollation, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + await using var createCommand = connection.CreateCommand(); + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + // ReSharper disable once PossibleMultipleEnumeration + values, + valuesType, + databaseCollation, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; #pragma warning restore CA2007 await using var cancellationTokenRegistration = @@ -201,18 +197,17 @@ public async Task BuildTemporaryTableAsync( else { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - databaseCollation, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + databaseCollation, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken).ConfigureAwait(false); @@ -443,11 +438,10 @@ private static DbDataReader CreateValuesDataReader(IEnumerable values, Type valu /// The transaction within to drop the table. private static void DropTemporaryTable(String name, SqlConnection connection, SqlTransaction? transaction) { - using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"IF OBJECT_ID('tempdb..#{name}', 'U') IS NOT NULL DROP TABLE [#{name}]", - transaction - ); + using var command = connection.CreateCommand(); + + command.CommandText = $"IF OBJECT_ID('tempdb..#{name}', 'U') IS NOT NULL DROP TABLE [#{name}]"; + command.Transaction = transaction; DbConnectionExtensions.OnBeforeExecutingCommand(command, []); @@ -468,13 +462,12 @@ private static async ValueTask DropTemporaryTableAsync( ) { #pragma warning disable CA2007 - await using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"IF OBJECT_ID('tempdb..#{name}', 'U') IS NOT NULL DROP TABLE [#{name}]", - transaction - ); + await using var command = connection.CreateCommand(); #pragma warning restore CA2007 + command.CommandText = $"IF OBJECT_ID('tempdb..#{name}', 'U') IS NOT NULL DROP TABLE [#{name}]"; + command.Transaction = transaction; + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); await command.ExecuteNonQueryAsync().ConfigureAwait(false); @@ -494,11 +487,10 @@ private static String GetCurrentDatabaseCollation( (connection.DataSource, connection.Database), static (_, args) => { - using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - args.connection, - GetCurrentDatabaseCollationQuery, - args.transaction - ); + using var command = args.connection.CreateCommand(); + + command.CommandText = GetCurrentDatabaseCollationQuery; + command.Transaction = args.transaction; DbConnectionExtensions.OnBeforeExecutingCommand(command, []); @@ -528,13 +520,12 @@ private static async ValueTask GetCurrentDatabaseCollationAsync( } #pragma warning disable CA2007 - await using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - GetCurrentDatabaseCollationQuery, - transaction - ); + await using var command = connection.CreateCommand(); #pragma warning restore CA2007 + command.CommandText = GetCurrentDatabaseCollationQuery; + command.Transaction = transaction; + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); collation = (String)(await command.ExecuteScalarAsync().ConfigureAwait(false))!; diff --git a/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteDatabaseAdapter.cs b/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteDatabaseAdapter.cs index 3341b13..dc3a6f1 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteDatabaseAdapter.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteDatabaseAdapter.cs @@ -33,6 +33,11 @@ public void BindParameterValue(DbParameter parameter, Object? value) switch (value) { + case DateTime: + parameter.DbType = DbType.DateTime; + parameter.Value = value; + break; + case Enum enumValue: parameter.DbType = DbConnectionPlusConfiguration.Instance.EnumSerializationMode switch { @@ -54,11 +59,6 @@ public void BindParameterValue(DbParameter parameter, Object? value) ); break; - case DateTime: - parameter.DbType = DbType.DateTime; - parameter.Value = value; - break; - case Byte[]: parameter.DbType = DbType.Binary; parameter.Value = value; diff --git a/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteEntityManipulator.cs b/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteEntityManipulator.cs index 1519eda..c95e0b7 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteEntityManipulator.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteEntityManipulator.cs @@ -31,16 +31,51 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); + var totalNumberOfAffectedRows = 0; - foreach (var entity in entities) + using (command) + using (cancellationTokenRegistration) { - if (entity is null) + try { - continue; - } + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } + + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); + + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); + + var numberOfAffectedRows = command.ExecuteNonQuery(); - totalNumberOfAffectedRows += this.DeleteEntity(connection, entity, transaction, cancellationToken); + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) + ) + { + throw new OperationCanceledException(cancellationToken); + } } return totalNumberOfAffectedRows; @@ -57,17 +92,52 @@ CancellationToken cancellationToken ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entities); + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(typeof(TEntity)); + + var (command, parameters) = this.CreateDeleteEntityCommand(connection, transaction, entityTypeMetadata); + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); + var totalNumberOfAffectedRows = 0; - foreach (var entity in entities) + using (command) + using (cancellationTokenRegistration) { - if (entity is null) + try { - continue; - } + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } + + this.PopulateParametersFromEntityProperties(entityTypeMetadata, parameters, entity); + + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - totalNumberOfAffectedRows += await this - .DeleteEntityAsync(connection, entity, transaction, cancellationToken).ConfigureAwait(false); + var numberOfAffectedRows = + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + totalNumberOfAffectedRows += numberOfAffectedRows; + } + } + catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( + exception, + cancellationToken + ) + ) + { + throw new OperationCanceledException(cancellationToken); + } } return totalNumberOfAffectedRows; @@ -98,7 +168,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return command.ExecuteNonQuery(); + var numberOfAffectedRows = command.ExecuteNonQuery(); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -136,7 +217,18 @@ CancellationToken cancellationToken { DbConnectionExtensions.OnBeforeExecutingCommand(command, []); - return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + var numberOfAffectedRows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + + if (numberOfAffectedRows != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + numberOfAffectedRows, + entity + ); + } + + return numberOfAffectedRows; } catch (Exception exception) when (this.databaseAdapter.WasSqlStatementCancelledByCancellationToken( exception, @@ -403,6 +495,20 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -467,6 +573,20 @@ await UpdateDatabaseGeneratedPropertiesAsync( cancellationToken ).ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + totalNumberOfAffectedRows += reader.RecordsAffected; } } @@ -514,6 +634,20 @@ CancellationToken cancellationToken UpdateDatabaseGeneratedProperties(entityTypeMetadata, reader, entity, cancellationToken); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + reader.Close(); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -563,6 +697,20 @@ CancellationToken cancellationToken await UpdateDatabaseGeneratedPropertiesAsync(entityTypeMetadata, reader, entity, cancellationToken) .ConfigureAwait(false); + // We must close the reader before we can access DbDataReader.RecordsAffected, because otherwise it + // returns -1 when we select database generated properties via the SELECT statement after the + // UPDATE statement. + await reader.CloseAsync().ConfigureAwait(false); + + if (reader.RecordsAffected != 1) + { + ThrowHelper.ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + 1, + reader.RecordsAffected, + entity + ); + } + return reader.RecordsAffected; } catch (Exception exception) when ( @@ -592,15 +740,18 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetDeleteEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetDeleteEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var property in whereProperties) { var parameter = command.CreateParameter(); parameter.ParameterName = property.PropertyName; @@ -629,11 +780,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetInsertEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetInsertEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -666,11 +816,10 @@ EntityTypeMetadata entityTypeMetadata ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(entityTypeMetadata); - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.GetUpdateEntitySqlCode(entityTypeMetadata), - transaction - ); + var command = connection.CreateCommand(); + + command.CommandText = this.GetUpdateEntitySqlCode(entityTypeMetadata); + command.Transaction = transaction; var parameters = new List(); @@ -715,7 +864,11 @@ private String GetDeleteEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => var prependSeparator = false; - foreach (var keyProperty in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties); + + foreach (var keyProperty in whereProperties) { if (prependSeparator) { @@ -921,7 +1074,12 @@ private String GetUpdateEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => prependSeparator = false; - foreach (var property in entityTypeMetadata.KeyProperties) + var whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .Concat(entityTypeMetadata.RowVersionProperties) + .ToList(); + + foreach (var property in whereProperties) { if (prependSeparator) { @@ -978,7 +1136,11 @@ private String GetUpdateEntitySqlCode(EntityTypeMetadata entityTypeMetadata) => prependSeparator = false; - foreach (var keyProperty in entityTypeMetadata.KeyProperties) + whereProperties = entityTypeMetadata.KeyProperties + .Concat(entityTypeMetadata.ConcurrencyTokenProperties) + .ToList(); + + foreach (var keyProperty in whereProperties) { if (prependSeparator) { diff --git a/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteTemporaryTableBuilder.cs b/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteTemporaryTableBuilder.cs index 501793f..fcdb91d 100644 --- a/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteTemporaryTableBuilder.cs +++ b/src/DbConnectionPlus/DatabaseAdapters/Sqlite/SqliteTemporaryTableBuilder.cs @@ -60,15 +60,14 @@ public TemporaryTableDisposer BuildTemporaryTable( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -79,15 +78,14 @@ public TemporaryTableDisposer BuildTemporaryTable( } else { - using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction + using var createCommand = connection.CreateCommand(); + + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode ); + createCommand.Transaction = transaction; using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken); @@ -137,17 +135,16 @@ public async Task BuildTemporaryTableAsync( if (valuesType.IsBuiltInTypeOrNullableBuiltInType() || valuesType.IsEnumOrNullableEnumType()) { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateSingleColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateSingleColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken).ConfigureAwait(false); @@ -158,17 +155,16 @@ public async Task BuildTemporaryTableAsync( else { #pragma warning disable CA2007 - await using var createCommand = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - this.BuildCreateMultiColumnTemporaryTableSqlCode( - name, - valuesType, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ), - transaction - ); + await using var createCommand = connection.CreateCommand(); #pragma warning restore CA2007 + createCommand.CommandText = this.BuildCreateMultiColumnTemporaryTableSqlCode( + name, + valuesType, + DbConnectionPlusConfiguration.Instance.EnumSerializationMode + ); + createCommand.Transaction = transaction; + await using var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(createCommand, cancellationToken).ConfigureAwait(false); @@ -389,11 +385,10 @@ private static DbDataReader CreateValuesDataReader(IEnumerable values, Type valu /// The transaction within to drop the table. private static void DropTemporaryTable(String name, SqliteConnection connection, SqliteTransaction? transaction) { - using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TABLE IF EXISTS temp.\"{name}\"", - transaction - ); + using var command = connection.CreateCommand(); + + command.CommandText = $"DROP TABLE IF EXISTS temp.\"{name}\""; + command.Transaction = transaction; DbConnectionExtensions.OnBeforeExecutingCommand(command, []); @@ -414,13 +409,12 @@ private static async ValueTask DropTemporaryTableAsync( ) { #pragma warning disable CA2007 - await using var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - $"DROP TABLE IF EXISTS temp.\"{name}\"", - transaction - ); + await using var command = connection.CreateCommand(); #pragma warning restore CA2007 + command.CommandText = $"DROP TABLE IF EXISTS temp.\"{name}\""; + command.Transaction = transaction; + DbConnectionExtensions.OnBeforeExecutingCommand(command, []); await command.ExecuteNonQueryAsync().ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DbCommands/DbCommandBuilder.cs b/src/DbConnectionPlus/DbCommands/DbCommandBuilder.cs index a079b78..9e7fad8 100644 --- a/src/DbConnectionPlus/DbCommands/DbCommandBuilder.cs +++ b/src/DbConnectionPlus/DbCommands/DbCommandBuilder.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using LinkDotNet.StringBuilder; -using RentADeveloper.DbConnectionPlus.Converters; using RentADeveloper.DbConnectionPlus.SqlStatements; namespace RentADeveloper.DbConnectionPlus.DbCommands; @@ -60,7 +59,7 @@ internal static (DbCommand, DbCommandDisposer) BuildDbCommand( ArgumentNullException.ThrowIfNull(databaseAdapter); ArgumentNullException.ThrowIfNull(connection); - var (command, temporaryTables, cancellationTokenRegistration) = BuildDbCommandCore( + var (command, cancellationTokenRegistration) = BuildDbCommandCore( statement, databaseAdapter, connection, @@ -72,10 +71,10 @@ internal static (DbCommand, DbCommandDisposer) BuildDbCommand( TemporaryTableDisposer[] temporaryTableDisposers = []; - if (temporaryTables.Length > 0) + if (statement.TemporaryTables.Count > 0) { temporaryTableDisposers = BuildTemporaryTables( - temporaryTables, + statement.TemporaryTables, databaseAdapter, connection, transaction, @@ -135,7 +134,7 @@ internal static (DbCommand, DbCommandDisposer) BuildDbCommand( ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(databaseAdapter); - var (command, temporaryTables, cancellationTokenRegistration) = BuildDbCommandCore( + var (command, cancellationTokenRegistration) = BuildDbCommandCore( statement, databaseAdapter, connection, @@ -147,10 +146,10 @@ internal static (DbCommand, DbCommandDisposer) BuildDbCommand( TemporaryTableDisposer[] temporaryTableDisposers = []; - if (temporaryTables.Length > 0) + if (statement.TemporaryTables.Count > 0) { temporaryTableDisposers = await BuildTemporaryTablesAsync( - temporaryTables, + statement.TemporaryTables, databaseAdapter, connection, transaction, @@ -174,10 +173,9 @@ internal static (DbCommand, DbCommandDisposer) BuildDbCommand( /// The to assign to the command. /// A token that can be used to cancel the command. /// - /// A tuple containing the created , the temporary tables for the statement, and the - /// cancellation token registration for the command. + /// A tuple containing the created and the cancellation token registration for the command. /// - private static (DbCommand, InterpolatedTemporaryTable[], CancellationTokenRegistration) BuildDbCommandCore( + private static (DbCommand, CancellationTokenRegistration) BuildDbCommandCore( InterpolatedSqlStatement statement, IDatabaseAdapter databaseAdapter, DbConnection connection, @@ -187,107 +185,83 @@ private static (DbCommand, InterpolatedTemporaryTable[], CancellationTokenRegist CancellationToken cancellationToken = default ) { - using var codeBuilder = new ValueStringBuilder(stackalloc Char[500]); - var parameters = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - var temporaryTables = new List(); + using var codeBuilder = new ValueStringBuilder(stackalloc Char[512]); + + var parameterNameOccurrences = new Dictionary( + statement.Fragments.Count, + StringComparer.OrdinalIgnoreCase + ); + + var parameterCount = 0; + + var command = connection.CreateCommand(); + + command.Transaction = transaction; + command.CommandType = commandType; + + if (commandTimeout is not null) + { + command.CommandTimeout = (Int32)commandTimeout.Value.TotalSeconds; + } + + var dbParameters = command.Parameters; foreach (var fragment in statement.Fragments) { switch (fragment) { - case Parameter parameter: - { - var parameterValue = parameter.Value; - - if (parameterValue is Enum enumValue) - { - parameterValue = EnumSerializer.SerializeEnum( - enumValue, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ); - } - - parameters.Add(parameter.Name, parameterValue); + case Literal literal: + codeBuilder.Append(literal.Value); break; - } case InterpolatedParameter interpolatedParameter: { - var parameterName = interpolatedParameter.InferredName; + var parameterName = interpolatedParameter.InferredName ?? + "Parameter_" + (parameterCount + 1); - if (String.IsNullOrWhiteSpace(parameterName)) + if (!parameterNameOccurrences.TryAdd(parameterName, 1)) { - parameterName = "Parameter_" + (parameters.Count + 1).ToString(CultureInfo.InvariantCulture); + // Parameter name is already used, so we append a suffix to make it unique. + var count = ++parameterNameOccurrences[parameterName]; + parameterName += count; } - if (parameters.ContainsKey(parameterName)) - { - var suffix = 2; - - var newParameterName = parameterName + suffix.ToString(CultureInfo.InvariantCulture); - - while (parameters.ContainsKey(newParameterName)) - { - suffix++; - newParameterName = parameterName + suffix.ToString(CultureInfo.InvariantCulture); - } + var dbParameter = command.CreateParameter(); + dbParameter.ParameterName = parameterName; + databaseAdapter.BindParameterValue(dbParameter, interpolatedParameter.Value); + dbParameters.Add(dbParameter); - parameterName = newParameterName; - } + codeBuilder.Append(databaseAdapter.FormatParameterName(parameterName)); - var parameterValue = interpolatedParameter.Value; + parameterCount++; + break; + } - if (parameterValue is Enum enumValue) - { - parameterValue = EnumSerializer.SerializeEnum( - enumValue, - DbConnectionPlusConfiguration.Instance.EnumSerializationMode - ); - } + case Parameter parameter: + { + var dbParameter = command.CreateParameter(); + dbParameter.ParameterName = parameter.Name; + databaseAdapter.BindParameterValue(dbParameter, parameter.Value); + dbParameters.Add(dbParameter); - parameters.Add(parameterName, parameterValue); - codeBuilder.Append(databaseAdapter.FormatParameterName(parameterName)); + parameterNameOccurrences[parameter.Name] = 1; + parameterCount++; break; } case InterpolatedTemporaryTable interpolatedTemporaryTable: - temporaryTables.Add(interpolatedTemporaryTable); codeBuilder.Append( databaseAdapter.QuoteTemporaryTableName(interpolatedTemporaryTable.Name, connection) ); break; - - case Literal literal: - codeBuilder.Append(literal.Value); - break; } } - var command = DbConnectionExtensions.DbCommandFactory.CreateDbCommand( - connection, - codeBuilder.ToString(), - transaction, - commandTimeout, - commandType - ); - - var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation( - command, - cancellationToken - ); + command.CommandText = codeBuilder.ToString(); - foreach (var (name, value) in parameters) - { - var parameter = command.CreateParameter(); - - parameter.ParameterName = name; - - databaseAdapter.BindParameterValue(parameter, value); - - command.Parameters.Add(parameter); - } + var cancellationTokenRegistration = DbCommandHelper.RegisterDbCommandCancellation(command, cancellationToken); - return (command, temporaryTables.ToArray(), cancellationTokenRegistration); + return (command, cancellationTokenRegistration); } /// @@ -309,7 +283,7 @@ private static (DbCommand, InterpolatedTemporaryTable[], CancellationTokenRegist /// The operation was cancelled via . /// private static TemporaryTableDisposer[] BuildTemporaryTables( - InterpolatedTemporaryTable[] temporaryTables, + IReadOnlyList temporaryTables, IDatabaseAdapter databaseAdapter, DbConnection connection, DbTransaction? transaction, @@ -321,11 +295,11 @@ CancellationToken cancellationToken ThrowHelper.ThrowDatabaseAdapterDoesNotSupportTemporaryTablesException(databaseAdapter); } - var temporaryTableDisposers = new TemporaryTableDisposer?[temporaryTables.Length]; + var temporaryTableDisposers = new TemporaryTableDisposer?[temporaryTables.Count]; try { - for (var i = 0; i < temporaryTables.Length; i++) + for (var i = 0; i < temporaryTables.Count; i++) { var interpolatedTemporaryTable = temporaryTables[i]; @@ -374,7 +348,7 @@ CancellationToken cancellationToken /// The operation was cancelled via . /// private static async Task BuildTemporaryTablesAsync( - InterpolatedTemporaryTable[] temporaryTables, + IReadOnlyList temporaryTables, IDatabaseAdapter databaseAdapter, DbConnection connection, DbTransaction? transaction, @@ -386,11 +360,11 @@ CancellationToken cancellationToken ThrowHelper.ThrowDatabaseAdapterDoesNotSupportTemporaryTablesException(databaseAdapter); } - var temporaryTableDisposers = new TemporaryTableDisposer?[temporaryTables.Length]; + var temporaryTableDisposers = new TemporaryTableDisposer?[temporaryTables.Count]; try { - for (var i = 0; i < temporaryTables.Length; i++) + for (var i = 0; i < temporaryTables.Count; i++) { var interpolatedTemporaryTable = temporaryTables[i]; diff --git a/src/DbConnectionPlus/DbCommands/DbCommandDisposer.cs b/src/DbConnectionPlus/DbCommands/DbCommandDisposer.cs index 8e905ee..4056000 100644 --- a/src/DbConnectionPlus/DbCommands/DbCommandDisposer.cs +++ b/src/DbConnectionPlus/DbCommands/DbCommandDisposer.cs @@ -8,7 +8,7 @@ namespace RentADeveloper.DbConnectionPlus.DbCommands; /// When disposed, disposes the command, any temporary tables created for the command, and the cancellation token /// registration associated with the command. /// -internal sealed class DbCommandDisposer : IDisposable, IAsyncDisposable +internal class DbCommandDisposer : IDisposable, IAsyncDisposable { /// /// Initializes a new instance of the class. diff --git a/src/DbConnectionPlus/DbCommands/DefaultDbCommandFactory.cs b/src/DbConnectionPlus/DbCommands/DefaultDbCommandFactory.cs deleted file mode 100644 index 90abccb..0000000 --- a/src/DbConnectionPlus/DbCommands/DefaultDbCommandFactory.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2026 David Liebeherr -// Licensed under the MIT License. See LICENSE.md in the project root for more information. - -namespace RentADeveloper.DbConnectionPlus.DbCommands; - -/// -/// The default implementation of . -/// -internal sealed class DefaultDbCommandFactory : IDbCommandFactory -{ - /// - public DbCommand CreateDbCommand( - DbConnection connection, - String commandText, - DbTransaction? transaction = null, - TimeSpan? commandTimeout = null, - CommandType commandType = CommandType.Text - ) - { - ArgumentNullException.ThrowIfNull(connection); - ArgumentNullException.ThrowIfNull(commandText); - - var command = connection.CreateCommand(); - -#pragma warning disable CA2100 - command.CommandText = commandText; -#pragma warning restore CA2100 - - command.Transaction = transaction; - command.CommandType = commandType; - - if (commandTimeout is not null) - { - command.CommandTimeout = (Int32)commandTimeout.Value.TotalSeconds; - } - - return command; - } -} diff --git a/src/DbConnectionPlus/DbCommands/IDbCommandFactory.cs b/src/DbConnectionPlus/DbCommands/IDbCommandFactory.cs deleted file mode 100644 index 2623278..0000000 --- a/src/DbConnectionPlus/DbCommands/IDbCommandFactory.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2026 David Liebeherr -// Licensed under the MIT License. See LICENSE.md in the project root for more information. - -namespace RentADeveloper.DbConnectionPlus.DbCommands; - -/// -/// Represents a factory that creates instances of . -/// -public interface IDbCommandFactory -{ - /// - /// Creates an instance of with the specified settings. - /// - /// The connection to use to create the . - /// The command text to assign to the . - /// The transaction to assign to the . - /// The command timeout to assign to the . - /// The to assign to the . - /// An instance of with the specified settings. - /// - /// - /// - /// - /// is . - /// - /// - /// - /// - /// is . - /// - /// - /// - /// - public DbCommand CreateDbCommand( - DbConnection connection, - String commandText, - DbTransaction? transaction = null, - TimeSpan? commandTimeout = null, - CommandType commandType = CommandType.Text - ); -} diff --git a/src/DbConnectionPlus/DbConnectionExtensions.Configuration.cs b/src/DbConnectionPlus/DbConnectionExtensions.Configuration.cs index 9d68a24..ceaa39c 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.Configuration.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.Configuration.cs @@ -1,7 +1,6 @@ // Copyright (c) 2026 David Liebeherr // Licensed under the MIT License. See LICENSE.md in the project root for more information. -using RentADeveloper.DbConnectionPlus.DbCommands; using RentADeveloper.DbConnectionPlus.Entities; using RentADeveloper.DbConnectionPlus.SqlStatements; @@ -16,27 +15,27 @@ public static partial class DbConnectionExtensions /// Configures DbConnectionPlus. /// /// The action that configures DbConnectionPlus. + /// + /// This method should only be called once during the application's lifetime. + /// This is because the configuration is frozen after it is set for the first time to ensure thread safety and to + /// prevent changes to the configuration after it has been used. + /// public static void Configure(Action configureAction) { ArgumentNullException.ThrowIfNull(configureAction); - configureAction(DbConnectionPlusConfiguration.Instance); + lock (configurationLockObject) + { + configureAction(DbConnectionPlusConfiguration.Instance); - ((IFreezable)DbConnectionPlusConfiguration.Instance).Freeze(); + ((IFreezable)DbConnectionPlusConfiguration.Instance).Freeze(); - // We need to reset the entity type metadata cache, because the configuration may have changed how entities - // are mapped that were previously mapped via data annotation attributes or conventions. - EntityHelper.ResetEntityTypeMetadataCache(); + // We need to reset the entity type metadata cache, because the configuration may have changed how entities + // are mapped that were previously mapped via data annotation attributes or conventions. + EntityHelper.ResetEntityTypeMetadataCache(); + } } - /// - /// The factory to use to create instances of . - /// - /// - /// This property is mainly used to test the cancellation of SQL statements in integration tests. - /// - internal static IDbCommandFactory DbCommandFactory { get; set; } = new DefaultDbCommandFactory(); - /// /// A function to be called before executing a database command via DbConnectionPlus. /// @@ -47,4 +46,6 @@ internal static void OnBeforeExecutingCommand( IReadOnlyList temporaryTables ) => DbConnectionPlusConfiguration.Instance.InterceptDbCommand?.Invoke(command, temporaryTables); + + private static readonly Object configurationLockObject = new(); } diff --git a/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntities.cs b/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntities.cs index 751fc3e..4531190 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntities.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntities.cs @@ -1,6 +1,8 @@ // Copyright (c) 2026 David Liebeherr // Licensed under the MIT License. See LICENSE.md in the project root for more information. +using RentADeveloper.DbConnectionPlus.Exceptions; + namespace RentADeveloper.DbConnectionPlus; /// @@ -34,18 +36,23 @@ public static partial class DbConnectionExtensions /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entities will be deleted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// @@ -112,18 +119,23 @@ public static Int32 DeleteEntities( /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entities will be deleted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// diff --git a/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntity.cs b/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntity.cs index 289d038..ff484d0 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntity.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.DeleteEntity.cs @@ -1,6 +1,8 @@ // Copyright (c) 2026 David Liebeherr // Licensed under the MIT License. See LICENSE.md in the project root for more information. +using RentADeveloper.DbConnectionPlus.Exceptions; + namespace RentADeveloper.DbConnectionPlus; /// @@ -34,18 +36,23 @@ public static partial class DbConnectionExtensions /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entity will be deleted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// @@ -115,18 +122,23 @@ public static Int32 DeleteEntity( /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while deleting an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by a delete operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table from which the entity will be deleted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// diff --git a/src/DbConnectionPlus/DbConnectionExtensions.ExecuteReader.cs b/src/DbConnectionPlus/DbConnectionExtensions.ExecuteReader.cs index 4e8cc70..62489eb 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.ExecuteReader.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.ExecuteReader.cs @@ -79,18 +79,12 @@ public static DbDataReader ExecuteReader( OnBeforeExecutingCommand(command, statement.TemporaryTables); dataReader = command.ExecuteReader(commandBehavior); - var disposeSignalingDecorator = new DisposeSignalingDataReaderDecorator( + return new CommandDisposingDataReaderDecorator( dataReader, databaseAdapter, + commandDisposer, cancellationToken ); - - // ReSharper disable AccessToDisposedClosure - disposeSignalingDecorator.OnDisposing = () => commandDisposer.Dispose(); - disposeSignalingDecorator.OnDisposingAsync = () => commandDisposer.DisposeAsync(); - // ReSharper restore AccessToDisposedClosure - - return disposeSignalingDecorator; } catch (Exception exception) when ( databaseAdapter.WasSqlStatementCancelledByCancellationToken(exception, cancellationToken) @@ -174,16 +168,12 @@ public static async Task ExecuteReaderAsync( OnBeforeExecutingCommand(command, statement.TemporaryTables); dataReader = await command.ExecuteReaderAsync(commandBehavior, cancellationToken).ConfigureAwait(false); - var disposeSignalingDecorator = new DisposeSignalingDataReaderDecorator( + return new CommandDisposingDataReaderDecorator( dataReader, databaseAdapter, + commandDisposer, cancellationToken ); - - disposeSignalingDecorator.OnDisposing = () => commandDisposer.Dispose(); - disposeSignalingDecorator.OnDisposingAsync = () => commandDisposer.DisposeAsync(); - - return disposeSignalingDecorator; } catch (Exception exception) when ( databaseAdapter.WasSqlStatementCancelledByCancellationToken(exception, cancellationToken) diff --git a/src/DbConnectionPlus/DbConnectionExtensions.Exists.cs b/src/DbConnectionPlus/DbConnectionExtensions.Exists.cs index 5d6cbb2..7b729d8 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.Exists.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.Exists.cs @@ -75,7 +75,7 @@ public static Boolean Exists( try { OnBeforeExecutingCommand(command, statement.TemporaryTables); - using var reader = command.ExecuteReader(CommandBehavior.SingleRow | CommandBehavior.SingleResult); + using var reader = command.ExecuteReader(CommandBehavior.SingleResult | CommandBehavior.SingleRow); return reader.Read(); } catch (Exception exception) when ( @@ -155,7 +155,7 @@ public static async Task ExistsAsync( OnBeforeExecutingCommand(command, statement.TemporaryTables); #pragma warning disable CA2007 await using var reader = await command.ExecuteReaderAsync( - CommandBehavior.SingleRow | CommandBehavior.SingleResult, + CommandBehavior.SingleResult | CommandBehavior.SingleRow, cancellationToken ).ConfigureAwait(false); #pragma warning restore CA2007 diff --git a/src/DbConnectionPlus/DbConnectionExtensions.InsertEntities.cs b/src/DbConnectionPlus/DbConnectionExtensions.InsertEntities.cs index a22f16d..895a29f 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.InsertEntities.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.InsertEntities.cs @@ -39,13 +39,13 @@ public static partial class DbConnectionExtensions /// /// /// The table into which the entities will be inserted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -57,7 +57,7 @@ public static partial class DbConnectionExtensions /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -133,13 +133,13 @@ public static Int32 InsertEntities( /// /// /// The table into which the entities will be inserted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -151,7 +151,7 @@ public static Int32 InsertEntities( /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// diff --git a/src/DbConnectionPlus/DbConnectionExtensions.InsertEntity.cs b/src/DbConnectionPlus/DbConnectionExtensions.InsertEntity.cs index b73962f..bb39e85 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.InsertEntity.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.InsertEntity.cs @@ -39,13 +39,13 @@ public static partial class DbConnectionExtensions /// /// /// The table into which the entity will be inserted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -57,7 +57,7 @@ public static partial class DbConnectionExtensions /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -133,13 +133,13 @@ public static Int32 InsertEntity( /// /// /// The table into which the entity will be inserted can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -151,7 +151,7 @@ public static Int32 InsertEntity( /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not inserted. + /// ) are also not inserted. /// Once an entity is inserted, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// diff --git a/src/DbConnectionPlus/DbConnectionExtensions.Parameter.cs b/src/DbConnectionPlus/DbConnectionExtensions.Parameter.cs index e343b8e..941681c 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.Parameter.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.Parameter.cs @@ -73,14 +73,14 @@ public static InterpolatedParameter Parameter( { String? inferredParameterName = null; - if (!String.IsNullOrWhiteSpace(parameterValueExpression)) + if (parameterValueExpression?.Length > 0) { var nameFromCallerArgumentExpression = NameHelper.CreateNameFromCallerArgumentExpression( parameterValueExpression, MaximumParameterNameLength ); - if (!String.IsNullOrWhiteSpace(nameFromCallerArgumentExpression)) + if (nameFromCallerArgumentExpression.Length > 0) { inferredParameterName = nameFromCallerArgumentExpression; } diff --git a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirst.cs b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirst.cs index 0e349a8..db42d58 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirst.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirst.cs @@ -74,7 +74,7 @@ public static dynamic QueryFirst( try { OnBeforeExecutingCommand(command, statement.TemporaryTables); - var reader = command.ExecuteReader(CommandBehavior.SingleRow | CommandBehavior.SingleResult); + var reader = command.ExecuteReader(CommandBehavior.SingleResult | CommandBehavior.SingleRow); using (reader) { @@ -160,7 +160,7 @@ public static async Task QueryFirstAsync( { OnBeforeExecutingCommand(command, statement.TemporaryTables); var reader = await command.ExecuteReaderAsync( - CommandBehavior.SingleRow | CommandBehavior.SingleResult, + CommandBehavior.SingleResult | CommandBehavior.SingleRow, cancellationToken ) .ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOfT.cs b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOfT.cs index 22118ef..619b5ab 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOfT.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOfT.cs @@ -205,7 +205,7 @@ public static T QueryFirst( try { OnBeforeExecutingCommand(command, statement.TemporaryTables); - var reader = command.ExecuteReader(CommandBehavior.SingleRow | CommandBehavior.SingleResult); + var reader = command.ExecuteReader(CommandBehavior.SingleResult | CommandBehavior.SingleRow); using (reader) { @@ -453,7 +453,7 @@ public static async Task QueryFirstAsync( { OnBeforeExecutingCommand(command, statement.TemporaryTables); var reader = await command.ExecuteReaderAsync( - CommandBehavior.SingleRow | CommandBehavior.SingleResult, + CommandBehavior.SingleResult | CommandBehavior.SingleRow, cancellationToken ) .ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefault.cs b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefault.cs index 08b6d3d..26c0a90 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefault.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefault.cs @@ -77,7 +77,7 @@ public static partial class DbConnectionExtensions try { OnBeforeExecutingCommand(command, statement.TemporaryTables); - var reader = command.ExecuteReader(CommandBehavior.SingleRow | CommandBehavior.SingleResult); + var reader = command.ExecuteReader(CommandBehavior.SingleResult | CommandBehavior.SingleRow); using (reader) { @@ -165,7 +165,7 @@ public static partial class DbConnectionExtensions { OnBeforeExecutingCommand(command, statement.TemporaryTables); var reader = await command.ExecuteReaderAsync( - CommandBehavior.SingleRow | CommandBehavior.SingleResult, + CommandBehavior.SingleResult | CommandBehavior.SingleRow, cancellationToken ) .ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefaultOfT.cs b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefaultOfT.cs index 30cadc3..7585a09 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefaultOfT.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.QueryFirstOrDefaultOfT.cs @@ -205,7 +205,7 @@ public static partial class DbConnectionExtensions try { OnBeforeExecutingCommand(command, statement.TemporaryTables); - var reader = command.ExecuteReader(CommandBehavior.SingleRow | CommandBehavior.SingleResult); + var reader = command.ExecuteReader(CommandBehavior.SingleResult | CommandBehavior.SingleRow); using (reader) { @@ -457,7 +457,7 @@ public static partial class DbConnectionExtensions { OnBeforeExecutingCommand(command, statement.TemporaryTables); var reader = await command.ExecuteReaderAsync( - CommandBehavior.SingleRow | CommandBehavior.SingleResult, + CommandBehavior.SingleResult | CommandBehavior.SingleRow, cancellationToken ) .ConfigureAwait(false); diff --git a/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntities.cs b/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntities.cs index f736f63..1b0b090 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntities.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntities.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using RentADeveloper.DbConnectionPlus.Converters; +using RentADeveloper.DbConnectionPlus.Exceptions; namespace RentADeveloper.DbConnectionPlus; @@ -36,23 +37,28 @@ public static partial class DbConnectionExtensions /// /// /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entities will be updated can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -64,7 +70,7 @@ public static partial class DbConnectionExtensions /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -150,23 +156,28 @@ public static Int32 UpdateEntities( /// /// /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entities will be updated can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -178,7 +189,7 @@ public static Int32 UpdateEntities( /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// diff --git a/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntity.cs b/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntity.cs index 8114990..bddad01 100644 --- a/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntity.cs +++ b/src/DbConnectionPlus/DbConnectionExtensions.UpdateEntity.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using RentADeveloper.DbConnectionPlus.Converters; +using RentADeveloper.DbConnectionPlus.Exceptions; namespace RentADeveloper.DbConnectionPlus; @@ -36,23 +37,28 @@ public static partial class DbConnectionExtensions /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entity will be updated can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -64,7 +70,7 @@ public static partial class DbConnectionExtensions /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// @@ -140,23 +146,28 @@ public static Int32 UpdateEntity( /// /// No instance property of the type is configured as a key property. /// + /// + /// A concurrency violation was encountered while updating an entity. A concurrency violation occurs when an + /// unexpected number of rows are affected by an update operation. This is usually because the data in the database + /// has been modified since the entity has been loaded. + /// /// /// The operation was cancelled via . /// /// /// /// The table in which the entity will be updated can be configured via or - /// . Per default, the singular name of the type is used + /// . Per default, the singular name of the type is used /// as the table name. /// /// /// The type must have at least one instance property configured as key property. - /// Use or to configure key properties. + /// Use or to configure key properties. /// /// /// Per default, each instance property of the type is mapped to a column with the /// same name (case-sensitive) in the table. This can be configured via or - /// . + /// . /// /// /// The columns must have data types that are compatible with the property types of the corresponding properties. @@ -168,7 +179,7 @@ public static Int32 UpdateEntity( /// /// /// Properties configured as identity or computed properties (via or - /// ) are also not updated. + /// ) are also not updated. /// Once an entity is updated, the values for these properties are retrieved from the database and the entity /// properties are updated accordingly. /// diff --git a/src/DbConnectionPlus/DbConnectionPlus.csproj b/src/DbConnectionPlus/DbConnectionPlus.csproj index ba8ce80..a2203ca 100644 --- a/src/DbConnectionPlus/DbConnectionPlus.csproj +++ b/src/DbConnectionPlus/DbConnectionPlus.csproj @@ -21,7 +21,7 @@ true true snupkg - 1.1.0 + 1.2.0 See CHANGELOG.md. true README.md diff --git a/src/DbConnectionPlus/Entities/EntityHelper.cs b/src/DbConnectionPlus/Entities/EntityHelper.cs index bc364d7..b99ce95 100644 --- a/src/DbConnectionPlus/Entities/EntityHelper.cs +++ b/src/DbConnectionPlus/Entities/EntityHelper.cs @@ -174,39 +174,43 @@ entityTypeBuilder is not null && ) { propertiesMetadata[i] = new( + property.CanRead, + property.CanWrite, !String.IsNullOrWhiteSpace(propertyBuilder.ColumnName) ? propertyBuilder.ColumnName : property.Name, - property.Name, - property.PropertyType, - property, - propertyBuilder.IsIgnored, - propertyBuilder.IsKey, propertyBuilder.IsComputed, + propertyBuilder.IsConcurrencyToken, propertyBuilder.IsIdentity, - property.CanRead, - property.CanWrite, + propertyBuilder.IsIgnored, + propertyBuilder.IsKey, + propertyBuilder.IsRowVersion, property.CanRead ? Reflect.PropertyGetter(property) : null, - property.CanWrite ? Reflect.PropertySetter(property) : null + property, + property.Name, + property.CanWrite ? Reflect.PropertySetter(property) : null, + property.PropertyType ); } else { propertiesMetadata[i] = new( + property.CanRead, + property.CanWrite, property.GetCustomAttribute()?.Name ?? property.Name, - property.Name, - property.PropertyType, - property, - property.GetCustomAttribute() is not null, - property.GetCustomAttribute() is not null, property.GetCustomAttribute()?.DatabaseGeneratedOption is DatabaseGeneratedOption.Computed, + property.GetCustomAttribute() is not null, property.GetCustomAttribute()?.DatabaseGeneratedOption is DatabaseGeneratedOption.Identity, - property.CanRead, - property.CanWrite, + property.GetCustomAttribute() is not null, + property.GetCustomAttribute() is not null, + property.GetCustomAttribute() is not null, property.CanRead ? Reflect.PropertyGetter(property) : null, - property.CanWrite ? Reflect.PropertySetter(property) : null + property, + property.Name, + property.CanWrite ? Reflect.PropertySetter(property) : null, + property.PropertyType ); } } @@ -221,22 +225,59 @@ entityTypeBuilder is not null && ); } + IReadOnlyList computedProperties = + [.. propertiesMetadata.Where(p => p is { IsIgnored: false, IsComputed: true })]; + + IReadOnlyList concurrencyTokenProperties = + [.. propertiesMetadata.Where(p => p is { IsIgnored: false, IsConcurrencyToken: true })]; + + IReadOnlyList databaseGeneratedProperties = + [.. propertiesMetadata.Where(p => !p.IsIgnored && (p.IsComputed || p.IsIdentity || p.IsRowVersion))]; + + IReadOnlyList insertProperties = + [ + .. propertiesMetadata.Where(p => p is + { IsIgnored: false, IsComputed: false, IsIdentity: false, IsRowVersion: false } + ) + ]; + + IReadOnlyList keyProperties = + [.. propertiesMetadata.Where(p => p is { IsIgnored: false, IsKey: true })]; + + IReadOnlyList mappedProperties = + [.. propertiesMetadata.Where(p => !p.IsIgnored)]; + + IReadOnlyList rowVersionProperties = + [.. propertiesMetadata.Where(p => p is { IsIgnored: false, IsRowVersion: true })]; + + IReadOnlyList updateProperties = + [ + .. propertiesMetadata.Where(p => p is + { + IsComputed: false, + IsConcurrencyToken: false, + IsIgnored: false, + IsIdentity: false, + IsKey: false, + IsRowVersion: false + } + ) + ]; + return new( entityType, tableName, propertiesMetadata, propertiesMetadata.ToDictionary(p => p.PropertyName), - [.. propertiesMetadata.Where(p => !p.IsIgnored)], - [.. propertiesMetadata.Where(p => p is { IsIgnored: false, IsKey: true })], - [.. propertiesMetadata.Where(p => p is { IsIgnored: false, IsComputed: true })], + computedProperties, + concurrencyTokenProperties, + databaseGeneratedProperties, identityProperties.FirstOrDefault(), - [.. propertiesMetadata.Where(p => !p.IsIgnored && (p.IsComputed || p.IsIdentity))], - [.. propertiesMetadata.Where(p => p is { IsIgnored: false, IsComputed: false, IsIdentity: false })], - [ - .. propertiesMetadata.Where(p => p is - { IsIgnored: false, IsKey: false, IsComputed: false, IsIdentity: false } - ) - ] + insertProperties, + keyProperties, + mappedProperties, + rowVersionProperties, + updateProperties ); } diff --git a/src/DbConnectionPlus/Entities/EntityPropertyMetadata.cs b/src/DbConnectionPlus/Entities/EntityPropertyMetadata.cs index adfc45d..8975c22 100644 --- a/src/DbConnectionPlus/Entities/EntityPropertyMetadata.cs +++ b/src/DbConnectionPlus/Entities/EntityPropertyMetadata.cs @@ -9,35 +9,41 @@ namespace RentADeveloper.DbConnectionPlus.Entities; /// /// Metadata of an entity property. /// +/// Determines whether the property can be read. +/// Determines whether the property can be written to. /// The name of the column to which the property is mapped. -/// The name of the property. -/// The property type of the property. -/// The property info of the property. -/// Determines whether the property is ignored and not mapped to a database column. -/// Determines whether the property is a key property. /// Determines whether the property is a computed property. +/// +/// Determines whether the property participates in optimistic concurrency checks. +/// /// Determines whether the property is an identity property. -/// Determines whether the property can be read. -/// Determines whether the property can be written to. +/// Determines whether the property is ignored and not mapped to a database column. +/// Determines whether the property is a key property. +/// Determines whether the property is a row version used for concurrency control. /// /// The getter function for the property. /// This is if the property has no getter. /// +/// The property info of the property. +/// The name of the property. /// /// The setter function for the property. /// This is if the property has no setter. /// +/// The property type of the property. public sealed record EntityPropertyMetadata( + Boolean CanRead, + Boolean CanWrite, String ColumnName, - String PropertyName, - Type PropertyType, - PropertyInfo PropertyInfo, - Boolean IsIgnored, - Boolean IsKey, Boolean IsComputed, + Boolean IsConcurrencyToken, Boolean IsIdentity, - Boolean CanRead, - Boolean CanWrite, + Boolean IsIgnored, + Boolean IsKey, + Boolean IsRowVersion, MemberGetter? PropertyGetter, - MemberSetter? PropertySetter + PropertyInfo PropertyInfo, + String PropertyName, + MemberSetter? PropertySetter, + Type PropertyType ); diff --git a/src/DbConnectionPlus/Entities/EntityTypeMetadata.cs b/src/DbConnectionPlus/Entities/EntityTypeMetadata.cs index 3b55e8a..e7da079 100644 --- a/src/DbConnectionPlus/Entities/EntityTypeMetadata.cs +++ b/src/DbConnectionPlus/Entities/EntityTypeMetadata.cs @@ -14,25 +14,31 @@ namespace RentADeveloper.DbConnectionPlus.Entities; /// The keys of the dictionary are the property names. /// The values of the dictionary are the corresponding property metadata. /// -/// -/// The metadata of the mapped properties of the entity type. -/// -/// -/// The metadata of the key properties of the entity type. -/// /// /// The metadata of the computed properties of the entity type. /// -/// -/// The metadata of the identity property of the entity type. -/// This is if the entity type does not have an identity property. +/// +/// The metadata of the concurrency token properties of the entity type. /// /// /// The metadata of the database-generated properties of the entity type. /// +/// +/// The metadata of the identity property of the entity type. +/// This is if the entity type does not have an identity property. +/// /// /// The metadata of the properties needed to insert an entity of the entity type into the database. /// +/// +/// The metadata of the key properties of the entity type. +/// +/// +/// The metadata of the mapped properties of the entity type. +/// +/// +/// The metadata of the row version properties of the entity type. +/// /// /// The metadata of the properties needed to update an entity of the entity type in the database. /// @@ -41,11 +47,13 @@ public sealed record EntityTypeMetadata( String TableName, IReadOnlyList AllProperties, IReadOnlyDictionary AllPropertiesByPropertyName, - IReadOnlyList MappedProperties, - IReadOnlyList KeyProperties, IReadOnlyList ComputedProperties, - EntityPropertyMetadata? IdentityProperty, + IReadOnlyList ConcurrencyTokenProperties, IReadOnlyList DatabaseGeneratedProperties, + EntityPropertyMetadata? IdentityProperty, IReadOnlyList InsertProperties, + IReadOnlyList KeyProperties, + IReadOnlyList MappedProperties, + IReadOnlyList RowVersionProperties, IReadOnlyList UpdateProperties ); diff --git a/src/DbConnectionPlus/Exceptions/DbUpdateConcurrencyException.cs b/src/DbConnectionPlus/Exceptions/DbUpdateConcurrencyException.cs new file mode 100644 index 0000000..10b172e --- /dev/null +++ b/src/DbConnectionPlus/Exceptions/DbUpdateConcurrencyException.cs @@ -0,0 +1,46 @@ +namespace RentADeveloper.DbConnectionPlus.Exceptions; + +/// +/// An exception that is thrown when a concurrency violation is encountered while deleting or updating an entity in a +/// database. A concurrency violation occurs when an unexpected number of rows are affected by a delete or update +/// operation. This is usually because the data in the database has been modified since the entity has been loaded. +/// +public class DbUpdateConcurrencyException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The entity that was involved in the concurrency violation. + public DbUpdateConcurrencyException(String message, Object entity) : base(message) => + this.Entity = entity; + + /// + /// Initializes a new instance of the class. + /// + public DbUpdateConcurrencyException() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + public DbUpdateConcurrencyException(String message) : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The error message. + /// The inner exception. + public DbUpdateConcurrencyException(String message, Exception innerException) : base(message, innerException) + { + } + + /// + /// The entity that was involved in the concurrency violation. + /// + public Object? Entity { get; set; } +} diff --git a/src/DbConnectionPlus/Helpers/NameHelper.cs b/src/DbConnectionPlus/Helpers/NameHelper.cs index 2d22824..c58e7d8 100644 --- a/src/DbConnectionPlus/Helpers/NameHelper.cs +++ b/src/DbConnectionPlus/Helpers/NameHelper.cs @@ -1,6 +1,8 @@ // Copyright (c) 2026 David Liebeherr // Licensed under the MIT License. See LICENSE.md in the project root for more information. +using System.Runtime.InteropServices; + namespace RentADeveloper.DbConnectionPlus.Helpers; /// @@ -21,42 +23,54 @@ internal static class NameHelper /// to the specified maximum length. /// The first character of the resulting name is converted to uppercase if it is a lowercase letter. /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static String CreateNameFromCallerArgumentExpression(ReadOnlySpan expression, Int32 maximumLength) { - if (expression.StartsWith("this.", StringComparison.OrdinalIgnoreCase)) + // Remove common prefixes that are not relevant for the name. + + if (expression.StartsWith("this.", StringComparison.Ordinal)) { expression = expression[5..]; } - if (expression.StartsWith("new", StringComparison.OrdinalIgnoreCase)) + if (expression.StartsWith("new", StringComparison.Ordinal)) { expression = expression[3..]; } - if (expression.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) + if (expression.StartsWith("Get", StringComparison.Ordinal)) { expression = expression[3..]; } - var buffer = expression.Length <= 512 ? stackalloc Char[expression.Length] : new Char[expression.Length]; + var scanLength = Math.Min(expression.Length, maximumLength); + + var buffer = scanLength <= 512 ? stackalloc Char[scanLength] : new Char[scanLength]; + + ref var src = ref MemoryMarshal.GetReference(expression); + ref var dst = ref MemoryMarshal.GetReference(buffer); + var count = 0; - foreach (var character in expression) + for (var i = 0; i < scanLength; i++) { - if (count >= maximumLength) - { - break; - } + var character = Unsafe.Add(ref src, i); - if (character is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '_') + if ( + (UInt32)(character - '0') <= 9 || // Digits + (UInt32)(character - 'A') <= 25 || // Uppercase letters + (UInt32)(character - 'a') <= 25 || // Lowercase letters + character == '_' + ) { - buffer[count++] = character; + Unsafe.Add(ref dst, count++) = character; } } - if (count > 0 && Char.IsLower(buffer[0])) + // Convert the first character to uppercase if it is a lowercase letter. + if (count != 0 && (UInt32)(buffer[0] - 'a') <= 25) { - buffer[0] = Char.ToUpper(buffer[0], CultureInfo.InvariantCulture); + buffer[0] = (Char)(buffer[0] - 32); } return new(buffer[..count]); diff --git a/src/DbConnectionPlus/Readers/DisposeSignalingDataReaderDecorator.cs b/src/DbConnectionPlus/Readers/CommandDisposingDataReaderDecorator.cs similarity index 91% rename from src/DbConnectionPlus/Readers/DisposeSignalingDataReaderDecorator.cs rename to src/DbConnectionPlus/Readers/CommandDisposingDataReaderDecorator.cs index 419ad41..2c599e7 100644 --- a/src/DbConnectionPlus/Readers/DisposeSignalingDataReaderDecorator.cs +++ b/src/DbConnectionPlus/Readers/CommandDisposingDataReaderDecorator.cs @@ -2,22 +2,26 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using System.Collections.ObjectModel; +using RentADeveloper.DbConnectionPlus.DbCommands; namespace RentADeveloper.DbConnectionPlus.Readers; /// -/// A decorator for a that signals when it is being disposed and handles the case when a -/// read operation is cancelled by a . +/// A decorator for a that disposes the associated when disposed +/// and handles the case when a read operation is cancelled by a . /// -internal sealed class DisposeSignalingDataReaderDecorator : DbDataReader +internal sealed class CommandDisposingDataReaderDecorator : DbDataReader { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The to decorate. /// /// The database adapter for the database for which was obtained. /// + /// + /// The that is responsible for disposing the associated . + /// /// /// The that is associated with the from which the /// to decorate was obtained. @@ -36,17 +40,20 @@ internal sealed class DisposeSignalingDataReaderDecorator : DbDataReader /// /// /// - public DisposeSignalingDataReaderDecorator( + public CommandDisposingDataReaderDecorator( DbDataReader dataReader, IDatabaseAdapter databaseAdapter, + DbCommandDisposer commandDisposer, CancellationToken commandCancellationToken ) { ArgumentNullException.ThrowIfNull(dataReader); ArgumentNullException.ThrowIfNull(databaseAdapter); + ArgumentNullException.ThrowIfNull(commandDisposer); this.dataReader = dataReader; this.databaseAdapter = databaseAdapter; + this.commandDisposer = commandDisposer; this.commandCancellationToken = commandCancellationToken; } @@ -94,11 +101,7 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); await this.dataReader.DisposeAsync().ConfigureAwait(false); - - if (this.OnDisposingAsync is not null) - { - await this.OnDisposingAsync().ConfigureAwait(false); - } + await this.commandDisposer.DisposeAsync().ConfigureAwait(false); } /// @@ -314,16 +317,6 @@ public override async Task ReadAsync(CancellationToken cancellationToke public override String? ToString() => this.dataReader.ToString(); - /// - /// A function that is invoked when this instance is being disposed synchronously. - /// - internal Action? OnDisposing { get; set; } - - /// - /// A function that is invoked when this instance is being disposed asynchronously. - /// - internal Func? OnDisposingAsync { get; set; } - /// protected override void Dispose(Boolean disposing) { @@ -339,12 +332,12 @@ protected override void Dispose(Boolean disposing) if (disposing) { this.dataReader.Dispose(); - - this.OnDisposing?.Invoke(); + this.commandDisposer.Dispose(); } } private readonly CancellationToken commandCancellationToken; + private readonly DbCommandDisposer commandDisposer; private readonly IDatabaseAdapter databaseAdapter; private readonly DbDataReader dataReader; private Boolean isDisposed; diff --git a/src/DbConnectionPlus/SqlStatements/InterpolatedParameter.cs b/src/DbConnectionPlus/SqlStatements/InterpolatedParameter.cs index 48a1107..3728bb8 100644 --- a/src/DbConnectionPlus/SqlStatements/InterpolatedParameter.cs +++ b/src/DbConnectionPlus/SqlStatements/InterpolatedParameter.cs @@ -11,5 +11,5 @@ namespace RentADeveloper.DbConnectionPlus.SqlStatements; /// This is if no name could be inferred. /// /// The value of the parameter. -public readonly record struct InterpolatedParameter(String? InferredName, Object? Value) +public record InterpolatedParameter(String? InferredName, Object? Value) : IInterpolatedSqlStatementFragment; diff --git a/src/DbConnectionPlus/SqlStatements/InterpolatedSqlStatement.cs b/src/DbConnectionPlus/SqlStatements/InterpolatedSqlStatement.cs index 9c24c8d..0b0c416 100644 --- a/src/DbConnectionPlus/SqlStatements/InterpolatedSqlStatement.cs +++ b/src/DbConnectionPlus/SqlStatements/InterpolatedSqlStatement.cs @@ -182,15 +182,15 @@ public void AppendLiteral(String? value) } /// - public Boolean Equals(InterpolatedSqlStatement other) => + public readonly Boolean Equals(InterpolatedSqlStatement other) => this.fragments.SequenceEqual(other.Fragments); /// - public override Boolean Equals(Object? obj) => + public readonly override Boolean Equals(Object? obj) => obj is InterpolatedSqlStatement other && this.Equals(other); /// - public override Int32 GetHashCode() + public readonly override Int32 GetHashCode() { var hashCode = new HashCode(); @@ -203,7 +203,7 @@ public override Int32 GetHashCode() } /// - public override String ToString() + public readonly override String ToString() { using var stringBuilder = new ValueStringBuilder(stackalloc Char[500]); @@ -220,8 +220,8 @@ public override String ToString() { switch (fragment) { - case Parameter parameter: - parameters.Add(parameter.Name, parameter.Value); + case Literal literal: + stringBuilder.Append(literal.Value); break; case InterpolatedParameter interpolatedParameter: @@ -258,8 +258,8 @@ public override String ToString() interpolatedTemporaryTables.Add(interpolatedTemporaryTable); break; - case Literal literal: - stringBuilder.Append(literal.Value); + case Parameter parameter: + parameters.Add(parameter.Name, parameter.Value); break; } } @@ -355,7 +355,7 @@ public static implicit operator InterpolatedSqlStatement(String value) /// /// The temporary tables used in this SQL statement. /// - internal IReadOnlyList TemporaryTables => this.temporaryTables; + internal readonly IReadOnlyList TemporaryTables => this.temporaryTables; private readonly List fragments; private readonly List temporaryTables; diff --git a/src/DbConnectionPlus/SqlStatements/InterpolatedTemporaryTable.cs b/src/DbConnectionPlus/SqlStatements/InterpolatedTemporaryTable.cs index b35556c..4f51fe7 100644 --- a/src/DbConnectionPlus/SqlStatements/InterpolatedTemporaryTable.cs +++ b/src/DbConnectionPlus/SqlStatements/InterpolatedTemporaryTable.cs @@ -10,5 +10,5 @@ namespace RentADeveloper.DbConnectionPlus.SqlStatements; /// The name for the table. /// The values with which to populate the table. /// The type of values in . -public readonly record struct InterpolatedTemporaryTable(String Name, IEnumerable Values, Type ValuesType) +public record InterpolatedTemporaryTable(String Name, IEnumerable Values, Type ValuesType) : IInterpolatedSqlStatementFragment; diff --git a/src/DbConnectionPlus/SqlStatements/Literal.cs b/src/DbConnectionPlus/SqlStatements/Literal.cs index efe5c87..e3705ea 100644 --- a/src/DbConnectionPlus/SqlStatements/Literal.cs +++ b/src/DbConnectionPlus/SqlStatements/Literal.cs @@ -7,4 +7,4 @@ namespace RentADeveloper.DbConnectionPlus.SqlStatements; /// A fragment of an interpolated SQL statement that represents a literal string. /// /// The literal string. -internal readonly record struct Literal(String Value) : IInterpolatedSqlStatementFragment; +internal record Literal(String Value) : IInterpolatedSqlStatementFragment; diff --git a/src/DbConnectionPlus/SqlStatements/Parameter.cs b/src/DbConnectionPlus/SqlStatements/Parameter.cs index 208feb4..783be96 100644 --- a/src/DbConnectionPlus/SqlStatements/Parameter.cs +++ b/src/DbConnectionPlus/SqlStatements/Parameter.cs @@ -8,4 +8,4 @@ namespace RentADeveloper.DbConnectionPlus.SqlStatements; /// /// The name of the parameter. /// The value of the parameter. -internal readonly record struct Parameter(String Name, Object? Value) : IInterpolatedSqlStatementFragment; +internal record Parameter(String Name, Object? Value) : IInterpolatedSqlStatementFragment; diff --git a/src/DbConnectionPlus/ThrowHelper.cs b/src/DbConnectionPlus/ThrowHelper.cs index a25c5ac..c0bf03b 100644 --- a/src/DbConnectionPlus/ThrowHelper.cs +++ b/src/DbConnectionPlus/ThrowHelper.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. See LICENSE.md in the project root for more information. using System.Diagnostics.CodeAnalysis; +using RentADeveloper.DbConnectionPlus.Exceptions; using RentADeveloper.DbConnectionPlus.Extensions; namespace RentADeveloper.DbConnectionPlus; @@ -41,6 +42,31 @@ public static void ThrowDatabaseAdapterDoesNotSupportTemporaryTablesException(ID #pragma warning restore CA1062 ); + /// + /// Throws an indicating that a concurrency violation was encountered + /// while deleting or updating an entity in a database. A concurrency violation occurs when an unexpected number of + /// rows are affected by a delete or update operation. This is usually because the data in the database has been + /// modified since the entity has been loaded. + /// + /// The expected number of affected rows. + /// The actual number of affected rows. + /// The entity that was involved in the operation. + /// Always thrown. + [MethodImpl(MethodImplOptions.NoInlining)] + [DoesNotReturn] + public static void ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException( + Int32 expectedNumberOfAffectedRows, + Int32 actualNumberOfAffectedRows, + Object entity + ) => + throw new DbUpdateConcurrencyException( + $"The database operation was expected to affect {expectedNumberOfAffectedRows} row(s), but actually " + + $"affected {actualNumberOfAffectedRows} row(s). Data in the database may have been modified or deleted " + + $"since entities were loaded. See {nameof(DbUpdateConcurrencyException)}." + + $"{nameof(DbUpdateConcurrencyException.Entity)} for the entity that was involved in the operation.", + entity + ); + /// /// Throws an indicating that the specified entity type has no key property. /// diff --git a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntitiesTests.cs b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntitiesTests.cs index 8ba0bea..a08b27a 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntitiesTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntitiesTests.cs @@ -1,5 +1,6 @@ using System.Data.Common; using RentADeveloper.DbConnectionPlus.DatabaseAdapters; +using RentADeveloper.DbConnectionPlus.Exceptions; namespace RentADeveloper.DbConnectionPlus.IntegrationTests.DatabaseAdapters; @@ -45,7 +46,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => this.CallApi( useAsyncApi, @@ -67,20 +68,55 @@ await Invoking(() => this.CallApi( } [Theory] - [InlineData(false, 10)] - [InlineData(true, 10)] - // Some database adapters (like the SQL Server one) use batch deletion for more than 10 entities, so we need - // to test that as well. - [InlineData(false, 30)] - [InlineData(true, 30)] - public async Task DeleteEntities_Mapping_Attributes_ShouldUseAttributesMapping( - Boolean useAsyncApi, - Int32 numberOfEntities - ) + [InlineData(false)] + [InlineData(true)] + public async Task DeleteEntities_ConcurrencyTokenMismatch_ShouldThrow(Boolean useAsyncApi) { - var entities = this.CreateEntitiesInDb(numberOfEntities); - var entitiesToDelete = entities.Take(numberOfEntities / 2).ToList(); - var entitiesToKeep = entities.Skip(numberOfEntities / 2).ToList(); + var entitiesToDelete = this.CreateEntitiesInDb(5); + + var failingEntity = entitiesToDelete[^1]; + failingEntity.ConcurrencyToken_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + entitiesToDelete, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()) + .Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(failingEntity); + + foreach (var entity in entitiesToDelete.Except([failingEntity])) + { + this.ExistsEntityInDb(entity) + .Should().BeFalse(); + } + + this.ExistsEntityInDb(failingEntity) + .Should().BeTrue(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DeleteEntities_Mapping_Attributes_ShouldUseAttributesMapping(Boolean useAsyncApi) + { + var entities = this.CreateEntitiesInDb(10); + var entitiesToDelete = entities.Take(5).ToList(); + var entitiesToKeep = entities.Skip(5).ToList(); await this.CallApi( useAsyncApi, @@ -104,22 +140,15 @@ await this.CallApi( } [Theory] - [InlineData(false, 10)] - [InlineData(true, 10)] - // Some database adapters (like the SQL Server one) use batch deletion for more than 10 entities, so we need - // to test that as well. - [InlineData(false, 30)] - [InlineData(true, 30)] - public async Task DeleteEntities_Mapping_FluentApi_ShouldUseFluentApiMapping( - Boolean useAsyncApi, - Int32 numberOfEntities - ) + [InlineData(false)] + [InlineData(true)] + public async Task DeleteEntities_Mapping_FluentApi_ShouldUseFluentApiMapping(Boolean useAsyncApi) { MappingTestEntityFluentApi.Configure(); - var entities = this.CreateEntitiesInDb(numberOfEntities); - var entitiesToDelete = entities.Take(numberOfEntities / 2).ToList(); - var entitiesToKeep = entities.Skip(numberOfEntities / 2).ToList(); + var entities = this.CreateEntitiesInDb(10); + var entitiesToDelete = entities.Take(5).ToList(); + var entitiesToKeep = entities.Skip(5).ToList(); await this.CallApi( useAsyncApi, @@ -165,20 +194,13 @@ public Task DeleteEntities_Mapping_MissingKeyProperty_ShouldThrow(Boolean useAsy } [Theory] - [InlineData(false, 10)] - [InlineData(true, 10)] - // Some database adapters (like the SQL Server one) use batch deletion for more than 10 entities, so we need - // to test that as well. - [InlineData(false, 30)] - [InlineData(true, 30)] - public async Task DeleteEntities_Mapping_NoMapping_ShouldUseEntityTypeNameAndPropertyNames( - Boolean useAsyncApi, - Int32 numberOfEntities - ) + [InlineData(false)] + [InlineData(true)] + public async Task DeleteEntities_Mapping_NoMapping_ShouldUseEntityTypeNameAndPropertyNames(Boolean useAsyncApi) { - var entities = this.CreateEntitiesInDb(numberOfEntities); - var entitiesToDelete = entities.Take(numberOfEntities / 2).ToList(); - var entitiesToKeep = entities.Skip(numberOfEntities / 2).ToList(); + var entities = this.CreateEntitiesInDb(10); + var entitiesToDelete = entities.Take(5).ToList(); + var entitiesToKeep = entities.Skip(5).ToList(); await this.CallApi( useAsyncApi, @@ -201,6 +223,47 @@ await this.CallApi( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DeleteEntities_RowVersionMismatch_ShouldThrow(Boolean useAsyncApi) + { + var entitiesToDelete = this.CreateEntitiesInDb(5); + + var failingEntity = entitiesToDelete[^1]; + failingEntity.RowVersion_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + entitiesToDelete, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()).Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(failingEntity); + + foreach (var entity in entitiesToDelete.Except([failingEntity])) + { + this.ExistsEntityInDb(entity) + .Should().BeFalse(); + } + + this.ExistsEntityInDb(failingEntity) + .Should().BeTrue(); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -220,7 +283,7 @@ public async Task DeleteEntities_ShouldReturnNumberOfAffectedRows(Boolean useAsy (await this.CallApi( useAsyncApi, this.Connection, - entitiesToDelete, + Array.Empty(), null, TestContext.Current.CancellationToken )) diff --git a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntityTests.cs b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntityTests.cs index 974d820..3197b77 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntityTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.DeleteEntityTests.cs @@ -1,5 +1,6 @@ using System.Data.Common; using RentADeveloper.DbConnectionPlus.DatabaseAdapters; +using RentADeveloper.DbConnectionPlus.Exceptions; namespace RentADeveloper.DbConnectionPlus.IntegrationTests.DatabaseAdapters; @@ -42,7 +43,7 @@ public async Task DeleteEntity_CancellationToken_ShouldCancelOperationIfCancella var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => this.CallApi( useAsyncApi, @@ -60,6 +61,39 @@ await Invoking(() => this.CallApi( .Should().BeTrue(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DeleteEntity_ConcurrencyTokenMismatch_ShouldThrow(Boolean useAsyncApi) + { + var entityToDelete = this.CreateEntityInDb(); + entityToDelete.ConcurrencyToken_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + entityToDelete, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()).Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(entityToDelete); + + this.ExistsEntityInDb(entityToDelete) + .Should().BeTrue(); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -156,6 +190,39 @@ await this.CallApi( .Should().BeTrue(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DeleteEntity_RowVersionMismatch_ShouldThrow(Boolean useAsyncApi) + { + var entityToDelete = this.CreateEntityInDb(); + entityToDelete.RowVersion_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + entityToDelete, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()).Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(entityToDelete); + + this.ExistsEntityInDb(entityToDelete) + .Should().BeTrue(); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -171,15 +238,6 @@ public async Task DeleteEntity_ShouldReturnNumberOfAffectedRows(Boolean useAsync TestContext.Current.CancellationToken )) .Should().Be(1); - - (await this.CallApi( - useAsyncApi, - this.Connection, - entityToDelete, - null, - TestContext.Current.CancellationToken - )) - .Should().Be(0); } [Theory] diff --git a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntitiesTests.cs b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntitiesTests.cs index 85b00e1..185ec00 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntitiesTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntitiesTests.cs @@ -44,7 +44,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => this.CallApi(useAsyncApi, this.Connection, entities, null, cancellationToken) @@ -118,9 +118,9 @@ public async Task InsertEntities_Mapping_Attributes_ShouldUseAttributesMapping(B var entities = Generate.Multiple(); entities.ForEach(a => { - a.ComputedColumn_ = 0; - a.IdentityColumn_ = 0; - a.NotMappedColumn = "ShouldNotBePersisted"; + a.Computed_ = 0; + a.Identity_ = 0; + a.NotMapped = "ShouldNotBePersisted"; } ); @@ -136,7 +136,7 @@ await this.CallApi( .Should().BeEquivalentTo( entities, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -150,9 +150,9 @@ public async Task InsertEntities_Mapping_FluentApi_ShouldUseFluentApiMapping(Boo var entities = Generate.Multiple(); entities.ForEach(a => { - a.ComputedColumn_ = 0; - a.IdentityColumn_ = 0; - a.NotMappedColumn = "ShouldNotBePersisted"; + a.Computed_ = 0; + a.Identity_ = 0; + a.NotMapped = "ShouldNotBePersisted"; } ); @@ -168,7 +168,7 @@ await this.CallApi( .Should().BeEquivalentTo( entities, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } diff --git a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntityTests.cs b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntityTests.cs index b44664a..ce35e76 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntityTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.InsertEntityTests.cs @@ -42,7 +42,7 @@ public async Task InsertEntity_CancellationToken_ShouldCancelOperationIfCancella var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => this.CallApi(useAsyncApi, this.Connection, entity, null, cancellationToken) @@ -97,9 +97,9 @@ public async Task InsertEntity_EnumSerializationModeIsStrings_ShouldStoreEnumVal public async Task InsertEntity_Mapping_Attributes_ShouldUseAttributesMapping(Boolean useAsyncApi) { var entity = Generate.Single(); - entity.ComputedColumn_ = 0; - entity.IdentityColumn_ = 0; - entity.NotMappedColumn = "ShouldNotBePersisted"; + entity.Computed_ = 0; + entity.Identity_ = 0; + entity.NotMapped = "ShouldNotBePersisted"; await this.CallApi( useAsyncApi, @@ -113,7 +113,7 @@ await this.CallApi( .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -125,9 +125,9 @@ public async Task InsertEntity_Mapping_FluentApi_ShouldUseFluentApiMapping(Boole MappingTestEntityFluentApi.Configure(); var entity = Generate.Single(); - entity.ComputedColumn_ = 0; - entity.IdentityColumn_ = 0; - entity.NotMappedColumn = "ShouldNotBePersisted"; + entity.Computed_ = 0; + entity.Identity_ = 0; + entity.NotMapped = "ShouldNotBePersisted"; await this.CallApi( useAsyncApi, @@ -141,7 +141,7 @@ await this.CallApi( .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } diff --git a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntitiesTests.cs b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntitiesTests.cs index 558f574..9a14194 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntitiesTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntitiesTests.cs @@ -1,5 +1,6 @@ using System.Data.Common; using RentADeveloper.DbConnectionPlus.DatabaseAdapters; +using RentADeveloper.DbConnectionPlus.Exceptions; namespace RentADeveloper.DbConnectionPlus.IntegrationTests.DatabaseAdapters; @@ -45,7 +46,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => this.CallApi(useAsyncApi, this.Connection, updatedEntities, null, cancellationToken) @@ -61,6 +62,64 @@ await Invoking(() => .Should().BeEquivalentTo(entities); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateEntities_ConcurrencyTokenMismatch_ShouldThrow(Boolean useAsyncApi) + { + var entities = this.CreateEntitiesInDb(); + var updatedEntities = Generate.UpdateFor(entities); + + var failingEntity = updatedEntities[^1]; + failingEntity.ConcurrencyToken_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + updatedEntities, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()).Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(failingEntity); + + foreach (var entity in updatedEntities.Except([failingEntity])) + { + (await this.Connection.QueryFirstAsync( + $""" + SELECT * + FROM {Q("MappingTestEntity")} + WHERE {Q("Key1")} = {Parameter(entity.Key1_)} AND + {Q("Key2")} = {Parameter(entity.Key2_)} + """, + cancellationToken: TestContext.Current.CancellationToken + )) + .Should().BeEquivalentTo(entity); + } + + (await this.Connection.QueryFirstAsync( + $""" + SELECT * + FROM {Q("MappingTestEntity")} + WHERE {Q("Key1")} = {Parameter(failingEntity.Key1_)} AND + {Q("Key2")} = {Parameter(failingEntity.Key2_)} + """, + cancellationToken: TestContext.Current.CancellationToken + )) + .Should().BeEquivalentTo(entities[^1]); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -155,9 +214,9 @@ public async Task UpdateEntities_Mapping_Attributes_ShouldUseAttributesMapping(B var updatedEntities = Generate.UpdateFor(entities); updatedEntities.ForEach(a => { - a.ComputedColumn_ = 0; - a.IdentityColumn_ = 0; - a.NotMappedColumn = "ShouldNotBePersisted"; + a.Computed_ = 0; + a.Identity_ = 0; + a.NotMapped = "ShouldNotBePersisted"; } ); @@ -172,8 +231,9 @@ await this.CallApi( this.Connection.Query($"SELECT * FROM {Q("MappingTestEntity")}") .Should().BeEquivalentTo( updatedEntities, - options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + options => + options.Using(context => context.Subject.Should().BeNull()) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -189,9 +249,9 @@ public async Task UpdateEntities_Mapping_FluentApi_ShouldUseFluentApiMapping(Boo var updatedEntities = Generate.UpdateFor(entities); updatedEntities.ForEach(a => { - a.ComputedColumn_ = 0; - a.IdentityColumn_ = 0; - a.NotMappedColumn = "ShouldNotBePersisted"; + a.Computed_ = 0; + a.Identity_ = 0; + a.NotMapped = "ShouldNotBePersisted"; } ); @@ -207,7 +267,7 @@ await this.CallApi( .Should().BeEquivalentTo( updatedEntities, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -253,6 +313,64 @@ await this.CallApi( .Should().BeEquivalentTo(updatedEntities); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateEntities_RowVersionMismatch_ShouldThrow(Boolean useAsyncApi) + { + var entities = this.CreateEntitiesInDb(); + var updatedEntities = Generate.UpdateFor(entities); + + var failingEntity = updatedEntities[^1]; + failingEntity.RowVersion_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + updatedEntities, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()).Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(failingEntity); + + foreach (var entity in updatedEntities.Except([failingEntity])) + { + (await this.Connection.QueryFirstAsync( + $""" + SELECT * + FROM {Q("MappingTestEntity")} + WHERE {Q("Key1")} = {Parameter(entity.Key1_)} AND + {Q("Key2")} = {Parameter(entity.Key2_)} + """, + cancellationToken: TestContext.Current.CancellationToken + )) + .Should().BeEquivalentTo(entity); + } + + (await this.Connection.QueryFirstAsync( + $""" + SELECT * + FROM {Q("MappingTestEntity")} + WHERE {Q("Key1")} = {Parameter(failingEntity.Key1_)} AND + {Q("Key2")} = {Parameter(failingEntity.Key2_)} + """, + cancellationToken: TestContext.Current.CancellationToken + )) + .Should().BeEquivalentTo(entities[^1]); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -270,12 +388,10 @@ public async Task UpdateEntities_ShouldReturnNumberOfAffectedRows(Boolean useAsy )) .Should().Be(entities.Count); - var nonExistentEntities = Generate.Multiple(); - (await this.CallApi( useAsyncApi, this.Connection, - nonExistentEntities, + Array.Empty(), null, TestContext.Current.CancellationToken )) diff --git a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntityTests.cs b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntityTests.cs index 5d7c4c8..0e48679 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntityTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/EntityManipulator.UpdateEntityTests.cs @@ -1,5 +1,6 @@ using System.Data.Common; using RentADeveloper.DbConnectionPlus.DatabaseAdapters; +using RentADeveloper.DbConnectionPlus.Exceptions; namespace RentADeveloper.DbConnectionPlus.IntegrationTests.DatabaseAdapters; @@ -43,7 +44,7 @@ public async Task UpdateEntity_CancellationToken_ShouldCancelOperationIfCancella var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => this.CallApi(useAsyncApi, this.Connection, updatedEntity, null, cancellationToken) @@ -59,6 +60,49 @@ await Invoking(() => .Should().BeEquivalentTo(entity); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateEntity_ConcurrencyTokenMismatch_ShouldThrow(Boolean useAsyncApi) + { + var entity = this.CreateEntityInDb(); + var updatedEntity = Generate.UpdateFor(entity); + + updatedEntity.ConcurrencyToken_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + updatedEntity, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()).Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(updatedEntity); + + (await this.Connection.QueryFirstAsync( + $""" + SELECT * + FROM {Q("MappingTestEntity")} + WHERE {Q("Key1")} = {Parameter(updatedEntity.Key1_)} AND + {Q("Key2")} = {Parameter(updatedEntity.Key2_)} + """, + cancellationToken: TestContext.Current.CancellationToken + )) + .Should().BeEquivalentTo(entity); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -139,9 +183,9 @@ public async Task UpdateEntity_Mapping_Attributes_ShouldUseAttributesMapping(Boo var entity = this.CreateEntityInDb(); var updatedEntity = Generate.UpdateFor(entity); - updatedEntity.ComputedColumn_ = 0; - updatedEntity.IdentityColumn_ = 0; - updatedEntity.NotMappedColumn = "ShouldNotBePersisted"; + updatedEntity.Computed_ = 0; + updatedEntity.Identity_ = 0; + updatedEntity.NotMapped = "ShouldNotBePersisted"; await this.CallApi( useAsyncApi, @@ -155,7 +199,7 @@ await this.CallApi( .Should().BeEquivalentTo( updatedEntity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -169,9 +213,9 @@ public async Task UpdateEntity_Mapping_FluentApi_ShouldUseFluentApiMapping(Boole var entity = this.CreateEntityInDb(); var updatedEntity = Generate.UpdateFor(entity); - updatedEntity.ComputedColumn_ = 0; - updatedEntity.IdentityColumn_ = 0; - updatedEntity.NotMappedColumn = "ShouldNotBePersisted"; + updatedEntity.Computed_ = 0; + updatedEntity.Identity_ = 0; + updatedEntity.NotMapped = "ShouldNotBePersisted"; await this.CallApi( useAsyncApi, @@ -185,7 +229,7 @@ await this.CallApi( .Should().BeEquivalentTo( updatedEntity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -231,6 +275,49 @@ await this.CallApi( .Should().BeEquivalentTo(updatedEntity); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task UpdateEntity_RowVersionMismatch_ShouldThrow(Boolean useAsyncApi) + { + var entity = this.CreateEntityInDb(); + var updatedEntity = Generate.UpdateFor(entity); + + updatedEntity.RowVersion_ = Generate.Single(); + + var exception = (await Invoking(() => this.CallApi( + useAsyncApi, + this.Connection, + updatedEntity, + null, + TestContext.Current.CancellationToken + ) + ) + .Should().ThrowAsync()).Subject.First(); + + exception.Message + .Should().Be( + "The database operation was expected to affect 1 row(s), but actually affected 0 row(s). " + + "Data in the database may have been modified or deleted since entities were loaded. See " + + $"{nameof(DbUpdateConcurrencyException)}.{nameof(DbUpdateConcurrencyException.Entity)} for " + + "the entity that was involved in the operation." + ); + + exception.Entity + .Should().Be(updatedEntity); + + (await this.Connection.QueryFirstAsync( + $""" + SELECT * + FROM {Q("MappingTestEntity")} + WHERE {Q("Key1")} = {Parameter(updatedEntity.Key1_)} AND + {Q("Key2")} = {Parameter(updatedEntity.Key2_)} + """, + cancellationToken: TestContext.Current.CancellationToken + )) + .Should().BeEquivalentTo(entity); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -247,17 +334,6 @@ public async Task UpdateEntity_ShouldReturnNumberOfAffectedRows(Boolean useAsync TestContext.Current.CancellationToken )) .Should().Be(1); - - var nonExistentEntity = Generate.Single(); - - (await this.CallApi( - useAsyncApi, - this.Connection, - nonExistentEntity, - null, - TestContext.Current.CancellationToken - )) - .Should().Be(0); } [Theory] diff --git a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/TemporaryTableBuilderTests.cs b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/TemporaryTableBuilderTests.cs index 07dbbaa..efb49f7 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/TemporaryTableBuilderTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/TemporaryTableBuilderTests.cs @@ -71,7 +71,7 @@ Boolean useAsyncApi { DbConnectionPlusConfiguration.Instance.EnumSerializationMode = EnumSerializationMode.Integers; - var entities = Generate.Multiple(); + var entities = Generate.Multiple(); await using var tableDisposer = await this.CallApi( useAsyncApi, @@ -79,7 +79,7 @@ Boolean useAsyncApi null, "Objects", entities, - typeof(EntityWithEnumProperty), + typeof(EntityWithEnumStoredAsInteger), TestContext.Current.CancellationToken ); @@ -116,7 +116,7 @@ Boolean useAsyncApi { DbConnectionPlusConfiguration.Instance.EnumSerializationMode = EnumSerializationMode.Strings; - var entities = Generate.Multiple(); + var entities = Generate.Multiple(); await using var tableDisposer = await this.CallApi( useAsyncApi, @@ -124,7 +124,7 @@ Boolean useAsyncApi null, "Objects", entities, - typeof(EntityWithEnumProperty), + typeof(EntityWithEnumStoredAsString), TestContext.Current.CancellationToken ); @@ -168,8 +168,8 @@ Boolean useAsyncApi this.Connection, null, "Objects", - Generate.Multiple(), - typeof(EntityWithEnumProperty), + Generate.Multiple(), + typeof(EntityWithEnumStoredAsString), TestContext.Current.CancellationToken ); @@ -187,7 +187,7 @@ Boolean useAsyncApi ) { var entities = Generate.Multiple(); - entities.ForEach(a => a.NotMappedColumn = "ShouldNotBePersisted"); + entities.ForEach(a => a.NotMapped = "ShouldNotBePersisted"); await using var tableDisposer = await this.CallApi( useAsyncApi, @@ -205,7 +205,7 @@ Boolean useAsyncApi ); reader.GetFieldNames() - .Should().NotContain(nameof(MappingTestEntityAttributes.NotMappedColumn)); + .Should().NotContain(nameof(MappingTestEntityAttributes.NotMapped)); await reader.DisposeAsync(); @@ -213,7 +213,7 @@ Boolean useAsyncApi .Should().BeEquivalentTo( entities, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -227,7 +227,7 @@ Boolean useAsyncApi MappingTestEntityFluentApi.Configure(); var entities = Generate.Multiple(); - entities.ForEach(a => a.NotMappedColumn = "ShouldNotBePersisted"); + entities.ForEach(a => a.NotMapped = "ShouldNotBePersisted"); await using var tableDisposer = await this.CallApi( useAsyncApi, @@ -245,7 +245,7 @@ Boolean useAsyncApi ); reader.GetFieldNames() - .Should().NotContain(nameof(MappingTestEntityFluentApi.NotMappedColumn)); + .Should().NotContain(nameof(MappingTestEntityFluentApi.NotMapped)); await reader.DisposeAsync(); @@ -253,7 +253,7 @@ Boolean useAsyncApi .Should().BeEquivalentTo( entities, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -316,12 +316,12 @@ public async Task BuildTemporaryTable_ComplexObjects_ShouldUseCollationOfDatabas this.Connection, null, "Objects", - Generate.Multiple(), - typeof(EntityWithStringProperty), + Generate.Multiple(), + typeof(Entity), TestContext.Current.CancellationToken ); - var columnCollation = this.GetCollationOfTemporaryTableColumn("Objects", "String"); + var columnCollation = this.GetCollationOfTemporaryTableColumn("Objects", "StringValue"); columnCollation .Should().Be(this.TestDatabaseProvider.DatabaseCollation); diff --git a/tests/DbConnectionPlus.IntegrationTests/DbCommands/DefaultDbCommandFactoryTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbCommands/DefaultDbCommandFactoryTests.cs deleted file mode 100644 index 86bac48..0000000 --- a/tests/DbConnectionPlus.IntegrationTests/DbCommands/DefaultDbCommandFactoryTests.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.IntegrationTests.DbCommands; - -public sealed class - DefaultDbCommandFactoryTests_MySql : - DefaultDbCommandFactoryTests; - -public sealed class - DefaultDbCommandFactoryTests_Oracle : - DefaultDbCommandFactoryTests; - -public sealed class - DefaultDbCommandFactoryTests_PostgreSql : - DefaultDbCommandFactoryTests; - -public sealed class - DefaultDbCommandFactoryTests_Sqlite : - DefaultDbCommandFactoryTests; - -public sealed class - DefaultDbCommandFactoryTests_SqlServer : - DefaultDbCommandFactoryTests; - -public abstract class DefaultDbCommandFactoryTests : IntegrationTestsBase - where TTestDatabaseProvider : ITestDatabaseProvider, new() -{ - [Fact] - public void CreateDbCommand_NoTimeout_ShouldUseDefaultTimeout() - { - var command = this.factory.CreateDbCommand(this.Connection, "SELECT 1"); - - command.CommandTimeout - .Should().Be(this.Connection.CreateCommand().CommandTimeout); - } - - [Fact] - public void CreateDbCommand_ShouldCreateDbCommandWithSpecifiedSettings() - { - var commandType = this.TestDatabaseProvider.SupportsStoredProcedures - ? CommandType.StoredProcedure - : CommandType.Text; - - using var transaction = this.Connection.BeginTransaction(); - - var timeout = Generate.Single(); - - var command = this.factory.CreateDbCommand( - this.Connection, - "SELECT 1", - transaction, - timeout, - commandType - ); - - command.Connection - .Should().BeSameAs(this.Connection); - - command.CommandText - .Should().Be("SELECT 1"); - - command.Transaction - .Should().BeSameAs(transaction); - - command.CommandTimeout - .Should().Be((Int32)timeout.TotalSeconds); - - command.CommandType - .Should().Be(commandType); - } - - private readonly DefaultDbCommandFactory factory = new(); -} diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteNonQueryTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteNonQueryTests.cs index e31a439..8c07bea 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteNonQueryTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteNonQueryTests.cs @@ -39,7 +39,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( useAsyncApi, diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteReaderTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteReaderTests.cs index 3879b34..0399f4f 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteReaderTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteReaderTests.cs @@ -37,7 +37,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(async () => { diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteScalarTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteScalarTests.cs index 3839540..8d7baa5 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteScalarTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExecuteScalarTests.cs @@ -37,7 +37,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExistsTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExistsTests.cs index dcb1211..8ee17ed 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExistsTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.ExistsTests.cs @@ -35,7 +35,7 @@ public async Task Exists_CancellationToken_ShouldCancelOperationIfCancellationIs var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi(useAsyncApi, this.Connection, "SELECT 1", cancellationToken: cancellationToken)) .Should().ThrowAsync() diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOfTTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOfTTests.cs index ac1fc12..de5ae7a 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOfTTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOfTTests.cs @@ -242,7 +242,7 @@ public async Task QueryFirst_CancellationToken_ShouldCancelOperationIfCancellati var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( @@ -272,7 +272,7 @@ public async Task QueryFirst_CommandType_ShouldUseCommandType(Boolean useAsyncAp commandType: CommandType.StoredProcedure, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -319,7 +319,7 @@ Boolean useAsyncApi $"SELECT * FROM {TemporaryTable(entities)}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -333,18 +333,18 @@ public async Task // Oracle doesn't allow to return an empty string, because it treats empty strings as NULLs. await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT '' AS {Q("Char")}", + $"SELECT '' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + - $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + "The column 'CharValue' returned by the SQL statement contains a value that could not be " + + $"converted to the type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -354,18 +354,18 @@ await Invoking(() => } await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 'ab' AS {Q("Char")}", + $"SELECT 'ab' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + + "The column 'CharValue' returned by the SQL statement contains a value that could not be converted " + $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -384,13 +384,13 @@ Boolean useAsyncApi { var character = Generate.Single(); - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT '{character}' AS {Q("Char")}", + $"SELECT '{character}' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithCharProperty { Char = character }); + .Should().BeEquivalentTo(new Entity { CharValue = character }); } [Theory] @@ -488,17 +488,17 @@ Boolean useAsyncApi ) { var entity = (await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Value")}, 3 AS {Q("NonExistent")}", + $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Int32Value")}, 3 AS {Q("NonExistent")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().NotThrowAsync()).Subject; entity - .Should().BeEquivalentTo(new EntityWithNonNullableProperty { Id = 1, Value = 2 }); + .Should().BeEquivalentTo(new Entity { Id = 1, Int32Value = 2 }); } [Theory] @@ -517,7 +517,7 @@ Boolean useAsyncApi $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entitiesWithDifferentCasingProperties[0]); + .Should().BeEquivalentTo(entitiesWithDifferentCasingProperties[0]); } [Theory] @@ -620,7 +620,7 @@ public async Task QueryFirst_EntityType_Mapping_Attributes_ShouldUseAttributesMa .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -642,7 +642,7 @@ public async Task QueryFirst_EntityType_Mapping_FluentApi_ShouldUseFluentApiMapp .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -699,7 +699,7 @@ Boolean useAsyncApi $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -724,21 +724,21 @@ public async Task QueryFirst_EntityType_NoMapping_ShouldUseEntityTypeNameAndProp public Task QueryFirst_EntityType_NonNullableEntityProperty_ColumnContainsNull_ShouldThrow(Boolean useAsyncApi) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT * FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"property of the type {typeof(EntityWithNonNullableProperty)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding property of the type {typeof(Entity)} is non-nullable.*" ); } @@ -750,32 +750,16 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi( - useAsyncApi, - this.Connection, - $"SELECT * FROM {Q("EntityWithNullableProperty")}", - cancellationToken: TestContext.Current.CancellationToken - )) - .Should().BeEquivalentTo(new EntityWithNullableProperty { Id = 1, Value = null }); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task QueryFirst_EntityType_ShouldMaterializeBinaryData(Boolean useAsyncApi) - { - var bytes = Generate.Single(); - - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT {Parameter(bytes)} AS BinaryData", + $"SELECT {Q("Id")}, {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithBinaryProperty { BinaryData = bytes }); + .Should().BeEquivalentTo(new Entity { Id = 1, NullableBooleanValue = null }); } [Theory] @@ -793,7 +777,7 @@ public async Task QueryFirst_EntityType_ShouldSupportDateTimeOffsetValues(Boolea $"SELECT * FROM {Q("EntityWithDateTimeOffset")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -832,7 +816,7 @@ public async Task QueryFirst_InterpolatedParameter_ShouldPassInterpolatedParamet $"SELECT * FROM {Q("Entity")} WHERE {Q("Id")} = {Parameter(entities[0].Id)}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -853,7 +837,7 @@ public async Task QueryFirst_Parameter_ShouldPassParameter(Boolean useAsyncApi) statement, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -922,7 +906,7 @@ Boolean useAsyncApi """, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -941,7 +925,7 @@ public async Task QueryFirst_Transaction_ShouldUseTransaction(Boolean useAsyncAp transaction, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); await transaction.RollbackAsync(); } @@ -1140,21 +1124,21 @@ public async Task QueryFirst_ValueTupleType_EnumValueTupleField_ShouldConvertStr public Task QueryFirst_ValueTupleType_NonNullableValueTupleField_ColumnContainsNull_ShouldThrow(Boolean useAsyncApi) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi>( + CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT {Q("BooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" ); } @@ -1166,13 +1150,13 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi>( + (await CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNullableProperty")}", + $"SELECT {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) .Should().Be(new(null)); diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultOfTTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultOfTTests.cs index a2b7538..073e68f 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultOfTTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultOfTTests.cs @@ -254,7 +254,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( @@ -284,7 +284,7 @@ public async Task QueryFirstOrDefault_CommandType_ShouldUseCommandType(Boolean u commandType: CommandType.StoredProcedure, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -331,7 +331,7 @@ Boolean useAsyncApi $"SELECT * FROM {TemporaryTable(entities)}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -347,18 +347,18 @@ Boolean useAsyncApi // Oracle doesn't allow to return an empty string, because it treats empty strings as NULLs. await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT '' AS {Q("Char")}", + $"SELECT '' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + - $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + "The column 'CharValue' returned by the SQL statement contains a value that could not be " + + $"converted to the type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -368,18 +368,18 @@ await Invoking(() => } await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 'ab' AS {Q("Char")}", + $"SELECT 'ab' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + + "The column 'CharValue' returned by the SQL statement contains a value that could not be converted " + $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -398,13 +398,13 @@ Boolean useAsyncApi { var character = Generate.Single(); - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT '{character}' AS {Q("Char")}", + $"SELECT '{character}' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithCharProperty { Char = character }); + .Should().BeEquivalentTo(new Entity { CharValue = character }); } [Theory] @@ -505,17 +505,17 @@ Boolean useAsyncApi ) { var entity = (await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Value")}, 3 AS {Q("NonExistent")}", + $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Int32Value")}, 3 AS {Q("NonExistent")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().NotThrowAsync()).Subject; entity - .Should().BeEquivalentTo(new EntityWithNonNullableProperty { Id = 1, Value = 2 }); + .Should().BeEquivalentTo(new Entity { Id = 1, Int32Value = 2 }); } [Theory] @@ -535,7 +535,7 @@ Boolean useAsyncApi $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entitiesWithDifferentCasingProperties[0]); + .Should().BeEquivalentTo(entitiesWithDifferentCasingProperties[0]); } [Theory] @@ -640,7 +640,7 @@ public async Task QueryFirstOrDefault_EntityType_Mapping_Attributes_ShouldUseAtt .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -662,7 +662,7 @@ public async Task QueryFirstOrDefault_EntityType_Mapping_FluentApi_ShouldUseFlue .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -719,7 +719,7 @@ Boolean useAsyncApi $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -748,21 +748,21 @@ Boolean useAsyncApi ) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT * FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"property of the type {typeof(EntityWithNonNullableProperty)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding property of the type {typeof(Entity)} is non-nullable.*" ); } @@ -774,32 +774,16 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi( - useAsyncApi, - this.Connection, - $"SELECT * FROM {Q("EntityWithNullableProperty")}", - cancellationToken: TestContext.Current.CancellationToken - )) - .Should().BeEquivalentTo(new EntityWithNullableProperty { Id = 1, Value = null }); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task QueryFirstOrDefault_EntityType_ShouldMaterializeBinaryData(Boolean useAsyncApi) - { - var bytes = Generate.Single(); - - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT {Parameter(bytes)} AS BinaryData", + $"SELECT {Q("Id")}, {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithBinaryProperty { BinaryData = bytes }); + .Should().BeEquivalentTo(new Entity { Id = 1, NullableBooleanValue = null }); } [Theory] @@ -817,7 +801,7 @@ public async Task QueryFirstOrDefault_EntityType_ShouldSupportDateTimeOffsetValu $"SELECT * FROM {Q("EntityWithDateTimeOffset")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -856,7 +840,7 @@ public async Task QueryFirstOrDefault_InterpolatedParameter_ShouldPassInterpolat $"SELECT * FROM {Q("Entity")} WHERE {Q("Id")} = {Parameter(entities[0].Id)}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -877,7 +861,7 @@ public async Task QueryFirstOrDefault_Parameter_ShouldPassParameter(Boolean useA statement, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -960,7 +944,7 @@ Boolean useAsyncApi """, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); } [Theory] @@ -979,7 +963,7 @@ public async Task QueryFirstOrDefault_Transaction_ShouldUseTransaction(Boolean u transaction, cancellationToken: TestContext.Current.CancellationToken )) - .Should().Be(entities[0]); + .Should().BeEquivalentTo(entities[0]); await transaction.RollbackAsync(); } @@ -1185,21 +1169,21 @@ Boolean useAsyncApi ) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi>( + CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT {Q("BooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" ); } @@ -1212,13 +1196,13 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi>( + (await CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNullableProperty")}", + $"SELECT {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) .Should().Be(new(null)); diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultTests.cs index c4e4101..a843ddd 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstOrDefaultTests.cs @@ -39,7 +39,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstTests.cs index 17a2a2c..6d4ccd8 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryFirstTests.cs @@ -37,7 +37,7 @@ public async Task QueryFirst_CancellationToken_ShouldCancelOperationIfCancellati var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryOfTTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryOfTTests.cs index e6191c2..7ad6a72 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryOfTTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryOfTTests.cs @@ -240,7 +240,7 @@ public async Task Query_CancellationToken_ShouldCancelOperationIfCancellationIsR var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( @@ -345,18 +345,18 @@ public async Task // Oracle doesn't allow to return an empty string, because it treats empty strings as NULLs. await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT '' AS {Q("Char")}", + $"SELECT '' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken).AsTask() ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + - $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + "The column 'CharValue' returned by the SQL statement contains a value that could not be " + + $"converted to the type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -366,18 +366,18 @@ await Invoking(() => } await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 'ab' AS {Q("Char")}", + $"SELECT 'ab' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken).AsTask() ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + + "The column 'CharValue' returned by the SQL statement contains a value that could not be converted " + $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -396,13 +396,13 @@ Boolean useAsyncApi { var character = Generate.Single(); - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT '{character}' AS {Q("Char")}", + $"SELECT '{character}' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken)) - .Should().BeEquivalentTo([new EntityWithCharProperty { Char = character }]); + .Should().BeEquivalentTo([new Entity { CharValue = character }]); } [Theory] @@ -496,17 +496,17 @@ Boolean useAsyncApi ) { var entities = (await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Value")}, 3 AS {Q("NonExistent")}", + $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Int32Value")}, 3 AS {Q("NonExistent")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken).AsTask() ) .Should().NotThrowAsync()).Subject; entities - .Should().BeEquivalentTo([new EntityWithNonNullableProperty { Id = 1, Value = 2 }]); + .Should().BeEquivalentTo([new Entity { Id = 1, Int32Value = 2 }]); } [Theory] @@ -628,7 +628,7 @@ public async Task Query_EntityType_Mapping_Attributes_ShouldUseAttributesMapping .Should().BeEquivalentTo( entities, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -650,7 +650,7 @@ public async Task Query_EntityType_Mapping_FluentApi_ShouldUseFluentApiMapping(B .Should().BeEquivalentTo( entities, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -731,21 +731,21 @@ Boolean useAsyncApi public Task Query_EntityType_NonNullableEntityProperty_ColumnContainsNull_ShouldThrow(Boolean useAsyncApi) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT * FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken).AsTask() ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"property of the type {typeof(EntityWithNonNullableProperty)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding property of the type {typeof(Entity)} is non-nullable.*" ); } @@ -755,32 +755,16 @@ public Task Query_EntityType_NonNullableEntityProperty_ColumnContainsNull_Should public async Task Query_EntityType_NullableEntityProperty_ColumnContainsNull_ShouldReturnNull(Boolean useAsyncApi) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi( - useAsyncApi, - this.Connection, - $"SELECT * FROM {Q("EntityWithNullableProperty")}", - cancellationToken: TestContext.Current.CancellationToken - ).ToListAsync(TestContext.Current.CancellationToken)) - .Should().BeEquivalentTo([new EntityWithNullableProperty { Id = 1, Value = null }]); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task Query_EntityType_ShouldMaterializeBinaryData(Boolean useAsyncApi) - { - var bytes = Generate.Single(); - - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT {Parameter(bytes)} AS BinaryData", + $"SELECT {Q("Id")}, {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken)) - .Should().BeEquivalentTo([new EntityWithBinaryProperty { BinaryData = bytes }]); + .Should().BeEquivalentTo([new Entity { Id = 1, NullableBooleanValue = null }]); } [Theory] @@ -1139,21 +1123,21 @@ public async Task Query_ValueTupleType_EnumValueTupleField_ShouldConvertStringTo public Task Query_ValueTupleType_NonNullableValueTupleField_ColumnContainsNull_ShouldThrow(Boolean useAsyncApi) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi>( + CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT {Q("BooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken).AsTask() ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" ); } @@ -1165,16 +1149,16 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi>( + (await CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNullableProperty")}", + $"SELECT {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ).ToListAsync(TestContext.Current.CancellationToken)) - .Should().BeEquivalentTo([new ValueTuple(null)]); + .Should().BeEquivalentTo([new ValueTuple(null)]); } [Theory] diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOfTTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOfTTests.cs index e776306..9ed0dd6 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOfTTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOfTTests.cs @@ -242,7 +242,7 @@ public async Task QuerySingle_CancellationToken_ShouldCancelOperationIfCancellat var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( @@ -333,18 +333,18 @@ public async Task // Oracle doesn't allow to return an empty string, because it treats empty strings as NULLs. await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT '' AS {Q("Char")}", + $"SELECT '' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + - $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + "The column 'CharValue' returned by the SQL statement contains a value that could not be " + + $"converted to the type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -354,18 +354,18 @@ await Invoking(() => } await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 'ab' AS {Q("Char")}", + $"SELECT 'ab' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + + "The column 'CharValue' returned by the SQL statement contains a value that could not be converted " + $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -384,13 +384,13 @@ Boolean useAsyncApi { var character = Generate.Single(); - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT '{character}' AS {Q("Char")}", + $"SELECT '{character}' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithCharProperty { Char = character }); + .Should().BeEquivalentTo(new Entity { CharValue = character }); } [Theory] @@ -488,17 +488,17 @@ Boolean useAsyncApi ) { var entity = (await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Value")}, 3 AS {Q("NonExistent")}", + $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Int32Value")}, 3 AS {Q("NonExistent")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().NotThrowAsync()).Subject; entity - .Should().BeEquivalentTo(new EntityWithNonNullableProperty { Id = 1, Value = 2 }); + .Should().BeEquivalentTo(new Entity { Id = 1, Int32Value = 2 }); } [Theory] @@ -621,7 +621,7 @@ public async Task QuerySingle_EntityType_Mapping_Attributes_ShouldUseAttributesM .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -643,7 +643,7 @@ public async Task QuerySingle_EntityType_Mapping_FluentApi_ShouldUseFluentApiMap .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -725,21 +725,21 @@ public async Task QuerySingle_EntityType_NoMapping_ShouldUseEntityTypeNameAndPro public Task QuerySingle_EntityType_NonNullableEntityProperty_ColumnContainsNull_ShouldThrow(Boolean useAsyncApi) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT * FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"property of the type {typeof(EntityWithNonNullableProperty)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding property of the type {typeof(Entity)} is non-nullable.*" ); } @@ -751,32 +751,16 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi( - useAsyncApi, - this.Connection, - $"SELECT * FROM {Q("EntityWithNullableProperty")}", - cancellationToken: TestContext.Current.CancellationToken - )) - .Should().BeEquivalentTo(new EntityWithNullableProperty { Id = 1, Value = null }); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task QuerySingle_EntityType_ShouldMaterializeBinaryData(Boolean useAsyncApi) - { - var bytes = Generate.Single(); - - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT {Parameter(bytes)} AS BinaryData", + $"SELECT {Q("Id")}, {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithBinaryProperty { BinaryData = bytes }); + .Should().BeEquivalentTo(new Entity { Id = 1, NullableBooleanValue = null }); } [Theory] @@ -1163,21 +1147,21 @@ Boolean useAsyncApi ) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi>( + CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT {Q("BooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" ); } @@ -1189,13 +1173,13 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi>( + (await CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNullableProperty")}", + $"SELECT {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) .Should().Be(new(null)); diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultOfTTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultOfTTests.cs index 15953ad..9c36fb3 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultOfTTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultOfTTests.cs @@ -254,7 +254,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( @@ -347,18 +347,18 @@ Boolean useAsyncApi // Oracle doesn't allow to return an empty string, because it treats empty strings as NULLs. await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT '' AS {Q("Char")}", + $"SELECT '' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + - $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + "The column 'CharValue' returned by the SQL statement contains a value that could not be " + + $"converted to the type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -368,18 +368,18 @@ await Invoking(() => } await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 'ab' AS {Q("Char")}", + $"SELECT 'ab' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted " + + "The column 'CharValue' returned by the SQL statement contains a value that could not be converted " + $"to the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" + $"{typeof(Entity)}. See inner exception for details.*" ) .WithInnerException(typeof(InvalidCastException)) .WithMessage( @@ -398,13 +398,13 @@ Boolean useAsyncApi { var character = Generate.Single(); - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT '{character}' AS {Q("Char")}", + $"SELECT '{character}' AS {Q("CharValue")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithCharProperty { Char = character }); + .Should().BeEquivalentTo(new Entity { CharValue = character }); } [Theory] @@ -505,17 +505,17 @@ Boolean useAsyncApi ) { var entity = (await Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Value")}, 3 AS {Q("NonExistent")}", + $"SELECT 1 AS {Q("Id")}, 2 AS {Q("Int32Value")}, 3 AS {Q("NonExistent")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().NotThrowAsync()).Subject; entity - .Should().BeEquivalentTo(new EntityWithNonNullableProperty { Id = 1, Value = 2 }); + .Should().BeEquivalentTo(new Entity { Id = 1, Int32Value = 2 }); } [Theory] @@ -640,7 +640,7 @@ public async Task QuerySingleOrDefault_EntityType_Mapping_Attributes_ShouldUseAt .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -662,7 +662,7 @@ public async Task QuerySingleOrDefault_EntityType_Mapping_FluentApi_ShouldUseFlu .Should().BeEquivalentTo( entity, options => options.Using(context => context.Subject.Should().BeNull()) - .When(info => info.Path.EndsWith("NotMappedColumn")) + .When(info => info.Path.EndsWith("NotMapped")) ); } @@ -752,21 +752,21 @@ Boolean useAsyncApi ) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi( + CallApi( useAsyncApi, this.Connection, - $"SELECT * FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT * FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"property of the type {typeof(EntityWithNonNullableProperty)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding property of the type {typeof(Entity)} is non-nullable.*" ); } @@ -778,32 +778,16 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi( - useAsyncApi, - this.Connection, - $"SELECT * FROM {Q("EntityWithNullableProperty")}", - cancellationToken: TestContext.Current.CancellationToken - )) - .Should().BeEquivalentTo(new EntityWithNullableProperty { Id = 1, Value = null }); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task QuerySingleOrDefault_EntityType_ShouldMaterializeBinaryData(Boolean useAsyncApi) - { - var bytes = Generate.Single(); - - (await CallApi( + (await CallApi( useAsyncApi, this.Connection, - $"SELECT {Parameter(bytes)} AS BinaryData", + $"SELECT {Q("Id")}, {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) - .Should().BeEquivalentTo(new EntityWithBinaryProperty { BinaryData = bytes }); + .Should().BeEquivalentTo(new Entity { Id = 1, NullableBooleanValue = null }); } [Theory] @@ -1210,21 +1194,21 @@ Boolean useAsyncApi ) { this.Connection.ExecuteNonQuery( - $"INSERT INTO {Q("EntityWithNonNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("BooleanValue")}) VALUES(1, NULL)" ); return Invoking(() => - CallApi>( + CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNonNullableProperty")}", + $"SELECT {Q("BooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken ) ) .Should().ThrowAsync() .WithMessage( - "The column 'Value' returned by the SQL statement contains a NULL value, but the corresponding " + - $"field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" + "The column 'BooleanValue' returned by the SQL statement contains a NULL value, but the " + + $"corresponding field of the value tuple type {typeof(ValueTuple)} is non-nullable.*" ); } @@ -1237,13 +1221,13 @@ Boolean useAsyncApi ) { await this.Connection.ExecuteNonQueryAsync( - $"INSERT INTO {Q("EntityWithNullableProperty")} ({Q("Id")}, {Q("Value")}) VALUES(1, NULL)" + $"INSERT INTO {Q("Entity")} ({Q("Id")}, {Q("NullableBooleanValue")}) VALUES(1, NULL)" ); - (await CallApi>( + (await CallApi>( useAsyncApi, this.Connection, - $"SELECT {Q("Value")} FROM {Q("EntityWithNullableProperty")}", + $"SELECT {Q("NullableBooleanValue")} FROM {Q("Entity")}", cancellationToken: TestContext.Current.CancellationToken )) .Should().Be(new(null)); diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultTests.cs index 5616799..abb7f63 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleOrDefaultTests.cs @@ -40,7 +40,7 @@ Boolean useAsyncApi var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleTests.cs index 92e873b..a97795e 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QuerySingleTests.cs @@ -37,7 +37,7 @@ public async Task QuerySingle_CancellationToken_ShouldCancelOperationIfCancellat var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryTests.cs b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryTests.cs index 40d77e0..5614a58 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionExtensions.QueryTests.cs @@ -37,7 +37,7 @@ public async Task Query_CancellationToken_ShouldCancelOperationIfCancellationIsR var cancellationToken = CreateCancellationTokenThatIsCancelledAfter100Milliseconds(); - this.DbCommandFactory.DelayNextDbCommand = true; + this.DelayNextDbCommand = true; await Invoking(() => CallApi( diff --git a/tests/DbConnectionPlus.IntegrationTests/GlobalUsings.cs b/tests/DbConnectionPlus.IntegrationTests/GlobalUsings.cs index d350e53..a22400f 100644 --- a/tests/DbConnectionPlus.IntegrationTests/GlobalUsings.cs +++ b/tests/DbConnectionPlus.IntegrationTests/GlobalUsings.cs @@ -5,7 +5,6 @@ global using RentADeveloper.DbConnectionPlus.Configuration; global using RentADeveloper.DbConnectionPlus.DbCommands; global using RentADeveloper.DbConnectionPlus.IntegrationTests.TestDatabase; -global using RentADeveloper.DbConnectionPlus.IntegrationTests.TestHelpers; global using RentADeveloper.DbConnectionPlus.SqlStatements; global using RentADeveloper.DbConnectionPlus.UnitTests.TestData; global using static AwesomeAssertions.FluentActions; diff --git a/tests/DbConnectionPlus.IntegrationTests/IntegrationTestsBase.cs b/tests/DbConnectionPlus.IntegrationTests/IntegrationTestsBase.cs index 7931e13..53f6325 100644 --- a/tests/DbConnectionPlus.IntegrationTests/IntegrationTestsBase.cs +++ b/tests/DbConnectionPlus.IntegrationTests/IntegrationTestsBase.cs @@ -5,9 +5,11 @@ using System.Data.Common; using System.Globalization; +using LinkDotNet.StringBuilder; using RentADeveloper.DbConnectionPlus.DatabaseAdapters; using RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle; using RentADeveloper.DbConnectionPlus.Entities; +using RentADeveloper.DbConnectionPlus.Extensions; namespace RentADeveloper.DbConnectionPlus.IntegrationTests; @@ -26,7 +28,7 @@ protected IntegrationTestsBase() Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = new("en-US"); - DbCommandLogger.LogCommands = false; + this.logDbCommands = false; this.TestDatabaseProvider = new(); this.TestDatabaseProvider.ResetDatabase(); @@ -37,10 +39,7 @@ protected IntegrationTestsBase() currentTestDatabaseConnection.Value = this.Connection; - this.DbCommandFactory = new(this.TestDatabaseProvider); - DbConnectionExtensions.DbCommandFactory = this.DbCommandFactory; - - DbCommandLogger.LogCommands = true; + this.logDbCommands = true; OracleDatabaseAdapter.AllowTemporaryTables = true; @@ -48,11 +47,19 @@ protected IntegrationTestsBase() DbConnectionPlusConfiguration.Instance = new() { EnumSerializationMode = EnumSerializationMode.Strings, - InterceptDbCommand = DbCommandLogger.LogDbCommand + InterceptDbCommand = this.InterceptDbCommand }; EntityHelper.ResetEntityTypeMetadataCache(); } + /// + /// Determines whether the next database command created by DbConnectionPlus will be delayed by 2 seconds. + /// If set to , a 2 second delay will be injected into the next database command created by + /// DbConnectionPlus. Subsequent commands will not be delayed unless this property is set to + /// again. + /// + public Boolean DelayNextDbCommand { get; set; } + /// public void Dispose() { @@ -134,7 +141,7 @@ public static String QT(String tableName) => /// The entities that were created and inserted. protected List CreateEntitiesInDb(Int32? numberOfEntities = null, DbTransaction? transaction = null) where T : class => - ExecuteWithoutDbCommandLogging(() => + this.ExecuteWithoutDbCommandLogging(() => { var entities = Generate.Multiple(numberOfEntities); @@ -164,7 +171,7 @@ protected List CreateEntitiesInDb(Int32? numberOfEntities = null, DbTransa protected T CreateEntityInDb(DbTransaction? transaction = null) where T : class => - ExecuteWithoutDbCommandLogging(() => + this.ExecuteWithoutDbCommandLogging(() => { var entity = Generate.Single(); @@ -218,7 +225,7 @@ [.. keyProperties.Select(p => $"{Q(p.ColumnName)} = {P(p.PropertyName)}")] keyProperties.Select(p => (p.PropertyName, p.PropertyGetter!(entity))).ToArray()! ); - return ExecuteWithoutDbCommandLogging(() => this.Connection.Exists( + return this.ExecuteWithoutDbCommandLogging(() => this.Connection.Exists( statement, transaction, cancellationToken: TestContext.Current.CancellationToken @@ -236,7 +243,7 @@ [.. keyProperties.Select(p => $"{Q(p.ColumnName)} = {P(p.PropertyName)}")] /// otherwise, . /// protected Boolean ExistsTemporaryTableInDb(String tableName, DbTransaction? transaction = null) => - ExecuteWithoutDbCommandLogging(() => + this.ExecuteWithoutDbCommandLogging(() => this.TestDatabaseProvider.ExistsTemporaryTable( tableName, this.Connection, @@ -251,7 +258,7 @@ protected Boolean ExistsTemporaryTableInDb(String tableName, DbTransaction? tran /// The name of the column of which to get the collation. /// The collation of the specified column of the specified temporary table. protected String GetCollationOfTemporaryTableColumn(String temporaryTableName, String columnName) => - ExecuteWithoutDbCommandLogging(() => + this.ExecuteWithoutDbCommandLogging(() => this.TestDatabaseProvider.GetCollationOfTemporaryTableColumn( temporaryTableName, columnName, @@ -269,7 +276,7 @@ protected String GetDataTypeOfTemporaryTableColumn( String temporaryTableName, String columnName ) => - ExecuteWithoutDbCommandLogging(() => + this.ExecuteWithoutDbCommandLogging(() => this.TestDatabaseProvider.GetDataTypeOfTemporaryTableColumn( temporaryTableName, columnName, @@ -277,6 +284,84 @@ String columnName ) ); + /// + /// Executes while disabling database command logging during the execution. + /// + /// The type of the return value of . + /// The function to execute. + /// The return value of . + private T ExecuteWithoutDbCommandLogging(Func func) + { + this.logDbCommands = false; + var result = func(); + this.logDbCommands = true; + return result; + } + + private void InterceptDbCommand(DbCommand command, IReadOnlyList temporaryTables) + { + if (this.DelayNextDbCommand) + { + command.CommandText = this.TestDatabaseProvider.DelayTwoSecondsStatement + command.CommandText; + this.DelayNextDbCommand = false; + } + + if (this.logDbCommands) + { + using var logMessageBuilder = new ValueStringBuilder(stackalloc Char[500]); + + logMessageBuilder.AppendLine(); + logMessageBuilder.AppendLine("-----------------"); + logMessageBuilder.AppendLine("Executing Command"); + logMessageBuilder.AppendLine("-----------------"); + logMessageBuilder.AppendLine(command.CommandText.Trim()); + + if (command.Parameters.Count > 0) + { + logMessageBuilder.AppendLine(); + logMessageBuilder.AppendLine("----------"); + logMessageBuilder.AppendLine("Parameters"); + logMessageBuilder.AppendLine("----------"); + + foreach (DbParameter parameter in command.Parameters) + { + logMessageBuilder.Append(" - "); + logMessageBuilder.Append(parameter.ParameterName); + + logMessageBuilder.Append(" ("); + logMessageBuilder.Append(parameter.Direction.ToString()); + logMessageBuilder.Append(")"); + + logMessageBuilder.Append(" = "); + logMessageBuilder.Append(parameter.Value.ToDebugString()); + logMessageBuilder.AppendLine(); + } + } + + if (temporaryTables.Count > 0) + { + logMessageBuilder.AppendLine(); + logMessageBuilder.AppendLine("----------------"); + logMessageBuilder.AppendLine("Temporary Tables"); + logMessageBuilder.AppendLine("----------------"); + + foreach (var temporaryTable in temporaryTables) + { + logMessageBuilder.AppendLine(); + logMessageBuilder.AppendLine(temporaryTable.Name); + logMessageBuilder.AppendLine(new String('-', temporaryTable.Name.Length)); + + foreach (var value in temporaryTable.Values) + { + logMessageBuilder.AppendLine(value.ToDebugString()); + } + } + } + + Console.WriteLine(logMessageBuilder.ToString()); + } + } + /// /// Creates a that will be cancelled after 100 milliseconds. /// @@ -288,19 +373,7 @@ protected static CancellationToken CreateCancellationTokenThatIsCancelledAfter10 return cancellationTokenSource.Token; } - /// - /// Executes while disabling database command logging during the execution. - /// - /// The type of the return value of . - /// The function to execute. - /// The return value of . - private static T ExecuteWithoutDbCommandLogging(Func func) - { - DbCommandLogger.LogCommands = false; - var result = func(); - DbCommandLogger.LogCommands = true; - return result; - } + private Boolean logDbCommands; /// /// The connection to the test database for the currently running integration test. @@ -311,9 +384,4 @@ private static T ExecuteWithoutDbCommandLogging(Func func) /// The test database provider for the currently running integration test. /// private static readonly AsyncLocal currentTestDatabaseProvider = new(); - - /// - /// The database command factory used for testing cancellation of SQL statements. - /// - protected readonly DelayDbCommandFactory DbCommandFactory; } diff --git a/tests/DbConnectionPlus.IntegrationTests/Readers/DisposeSignalingDataReaderDecoratorTests.cs b/tests/DbConnectionPlus.IntegrationTests/Readers/CommandDisposingDataReaderDecoratorTests.cs similarity index 80% rename from tests/DbConnectionPlus.IntegrationTests/Readers/DisposeSignalingDataReaderDecoratorTests.cs rename to tests/DbConnectionPlus.IntegrationTests/Readers/CommandDisposingDataReaderDecoratorTests.cs index 3890133..b48aa5a 100644 --- a/tests/DbConnectionPlus.IntegrationTests/Readers/DisposeSignalingDataReaderDecoratorTests.cs +++ b/tests/DbConnectionPlus.IntegrationTests/Readers/CommandDisposingDataReaderDecoratorTests.cs @@ -3,27 +3,27 @@ namespace RentADeveloper.DbConnectionPlus.IntegrationTests.Readers; public sealed class - DisposeSignalingDataReaderDecoratorTests_MySql : - DisposeSignalingDataReaderDecoratorTests; + CommandDisposingDataReaderDecoratorTests_MySql : + CommandDisposingDataReaderDecoratorTests; public sealed class - DisposeSignalingDataReaderDecoratorTests_Oracle : - DisposeSignalingDataReaderDecoratorTests; + CommandDisposingDataReaderDecoratorTests_Oracle : + CommandDisposingDataReaderDecoratorTests; public sealed class - DisposeSignalingDataReaderDecoratorTests_PostgreSql : - DisposeSignalingDataReaderDecoratorTests; + CommandDisposingDataReaderDecoratorTests_PostgreSql : + CommandDisposingDataReaderDecoratorTests; public sealed class - DisposeSignalingDataReaderDecoratorTests_Sqlite : - DisposeSignalingDataReaderDecoratorTests; + CommandDisposingDataReaderDecoratorTests_Sqlite : + CommandDisposingDataReaderDecoratorTests; public sealed class - DisposeSignalingDataReaderDecoratorTests_SqlServer : - DisposeSignalingDataReaderDecoratorTests; + CommandDisposingDataReaderDecoratorTests_SqlServer : + CommandDisposingDataReaderDecoratorTests; public abstract class - DisposeSignalingDataReaderDecoratorTests : IntegrationTestsBase + CommandDisposingDataReaderDecoratorTests : IntegrationTestsBase where TTestDatabaseProvider : ITestDatabaseProvider, new() { [Fact] @@ -51,11 +51,14 @@ public void Read_OperationCancelledViaCancellationToken_ShouldThrowOperationCanc } ); + var commandDisposer = new DbCommandDisposer(command, [], default); + using var decoratedReader = command.ExecuteReader(); - using var decorator = new DisposeSignalingDataReaderDecorator( + using var decorator = new CommandDisposingDataReaderDecorator( decoratedReader, this.DatabaseAdapter, + commandDisposer, cancellationToken ); @@ -95,11 +98,14 @@ public async Task ReadAsync_OperationCancelledViaCancellationToken_ShouldThrowOp } ); + var commandDisposer = new DbCommandDisposer(command, [], default); + await using var decoratedReader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken); - await using var decorator = new DisposeSignalingDataReaderDecorator( + await using var decorator = new CommandDisposingDataReaderDecorator( decoratedReader, this.DatabaseAdapter, + commandDisposer, cancellationToken ); diff --git a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/ITestDatabaseProvider.cs b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/ITestDatabaseProvider.cs index b441f78..91010a5 100644 --- a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/ITestDatabaseProvider.cs +++ b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/ITestDatabaseProvider.cs @@ -114,7 +114,9 @@ DbConnection connection /// /// Gets a literal representing a data type in the test database system that is not supported by DbConnectionPlus. /// - /// + /// + /// A literal representing a data type in the test database system that is not supported by DbConnectionPlus. + /// public String GetUnsupportedDataTypeLiteral(); /// diff --git a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/MySqlTestDatabaseProvider.cs b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/MySqlTestDatabaseProvider.cs index 1401b6f..96a70d2 100644 --- a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/MySqlTestDatabaseProvider.cs +++ b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/MySqlTestDatabaseProvider.cs @@ -162,6 +162,7 @@ CREATE TABLE `Entity` ( `Id` BIGINT, `BooleanValue` TINYINT(1), + `BytesValue` BLOB, `ByteValue` TINYINT UNSIGNED, `CharValue` CHAR(1), `DateOnlyValue` DATE, @@ -173,6 +174,7 @@ CREATE TABLE `Entity` `Int16Value` SMALLINT, `Int32Value` INT, `Int64Value` BIGINT, + `NullableBooleanValue` TINYINT(1) NULL, `SingleValue` FLOAT, `StringValue` TEXT, `TimeOnlyValue` TIME, @@ -194,29 +196,33 @@ CREATE TABLE `EntityWithEnumStoredAsInteger` ); GO - CREATE TABLE `EntityWithNonNullableProperty` + CREATE TABLE `MappingTestEntity` ( - `Id` BIGINT NOT NULL PRIMARY KEY, - `Value` BIGINT NULL + `Computed` INT AS (`Value`+999), + `ConcurrencyToken` BLOB, + `Identity` INT AUTO_INCREMENT PRIMARY KEY NOT NULL, + `Key1` BIGINT NOT NULL, + `Key2` BIGINT NOT NULL, + `Value` INT NOT NULL, + `NotMapped` TEXT NULL, + `RowVersion` BLOB ); GO - CREATE TABLE `EntityWithNullableProperty` - ( - `Id` BIGINT NOT NULL PRIMARY KEY, - `Value` BIGINT NULL - ); + CREATE TRIGGER Trigger_BeforeInsert_MappingTestEntity + BEFORE INSERT ON MappingTestEntity + FOR EACH ROW + BEGIN + SET NEW.RowVersion = UNHEX(REPLACE(UUID(), '-', '')); + END; GO - CREATE TABLE `MappingTestEntity` - ( - `KeyColumn1` BIGINT NOT NULL, - `KeyColumn2` BIGINT NOT NULL, - `ValueColumn` INT NOT NULL, - `ComputedColumn` INT AS (`ValueColumn`+999), - `IdentityColumn` INT AUTO_INCREMENT PRIMARY KEY NOT NULL, - `NotMappedColumn` TEXT NULL - ); + CREATE TRIGGER Trigger_BeforeUpdate_MappingTestEntity + BEFORE UPDATE ON MappingTestEntity + FOR EACH ROW + BEGIN + SET NEW.RowVersion = UNHEX(REPLACE(UUID(), '-', '')); + END; GO CREATE PROCEDURE `GetEntities` () @@ -269,12 +275,6 @@ CREATE TABLE `MappingTestEntity` TRUNCATE TABLE `EntityWithEnumStoredAsInteger`; GO - TRUNCATE TABLE `EntityWithNonNullableProperty`; - GO - - TRUNCATE TABLE `EntityWithNullableProperty`; - GO - TRUNCATE TABLE `MappingTestEntity`; GO """; diff --git a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/OracleTestDatabaseProvider.cs b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/OracleTestDatabaseProvider.cs index aaa0d65..6613109 100644 --- a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/OracleTestDatabaseProvider.cs +++ b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/OracleTestDatabaseProvider.cs @@ -143,6 +143,7 @@ CREATE TABLE "Entity" ( "Id" NUMBER(19) NOT NULL PRIMARY KEY, "BooleanValue" NUMBER(1), + "BytesValue" RAW(2000), "ByteValue" NUMBER(3), "CharValue" CHAR(1), "DateOnlyValue" DATE, @@ -154,6 +155,7 @@ CREATE TABLE "Entity" "Int16Value" NUMBER(5), "Int32Value" NUMBER(10), "Int64Value" NUMBER(19), + "NullableBooleanValue" NUMBER(1) NULL, "SingleValue" BINARY_FLOAT, "StringValue" NVARCHAR2(2000), "TimeOnlyValue" INTERVAL DAY TO SECOND, @@ -182,30 +184,26 @@ CREATE TABLE "EntityWithEnumStoredAsInteger" ); GO - CREATE TABLE "EntityWithNonNullableProperty" - ( - "Id" NUMBER(19) NOT NULL PRIMARY KEY, - "Value" NUMBER(19) NULL - ); - GO - - CREATE TABLE "EntityWithNullableProperty" + CREATE TABLE "MappingTestEntity" ( - "Id" NUMBER(19) NOT NULL PRIMARY KEY, - "Value" NUMBER(19) NULL + "Computed" GENERATED ALWAYS AS (("Value"+999)), + "ConcurrencyToken" RAW(2000), + "Identity" NUMBER(10) GENERATED ALWAYS AS IDENTITY(START with 1 INCREMENT by 1), + "Key1" NUMBER(19) NOT NULL, + "Key2" NUMBER(19) NOT NULL, + "Value" NUMBER(10) NOT NULL, + "NotMapped" CLOB NULL, + "RowVersion" RAW(16), + PRIMARY KEY ("Key1", "Key2") ); GO - CREATE TABLE "MappingTestEntity" - ( - "KeyColumn1" NUMBER(19) NOT NULL, - "KeyColumn2" NUMBER(19) NOT NULL, - "ValueColumn" NUMBER(10) NOT NULL, - "ComputedColumn" GENERATED ALWAYS AS (("ValueColumn"+999)), - "IdentityColumn" NUMBER(10) GENERATED ALWAYS AS IDENTITY(START with 1 INCREMENT by 1), - "NotMappedColumn" CLOB NULL, - PRIMARY KEY ("KeyColumn1", "KeyColumn2") - ); + CREATE OR REPLACE TRIGGER "TriggerMappingTestEntity" + BEFORE INSERT OR UPDATE ON "MappingTestEntity" + FOR EACH ROW + BEGIN + :NEW."RowVersion" := SYS_GUID(); + END; GO CREATE OR REPLACE NONEDITIONABLE PROCEDURE "DeleteAllEntities" AS @@ -230,12 +228,6 @@ CREATE OR REPLACE NONEDITIONABLE PROCEDURE "DeleteAllEntities" AS DROP TABLE IF EXISTS "EntityWithEnumStoredAsInteger" PURGE; GO - DROP TABLE IF EXISTS "EntityWithNonNullableProperty" PURGE; - GO - - DROP TABLE IF EXISTS "EntityWithNullableProperty" PURGE; - GO - DROP TABLE IF EXISTS "MappingTestEntity" PURGE; GO @@ -257,12 +249,6 @@ CREATE OR REPLACE NONEDITIONABLE PROCEDURE "DeleteAllEntities" AS TRUNCATE TABLE "EntityWithEnumStoredAsInteger"; GO - TRUNCATE TABLE "EntityWithNonNullableProperty"; - GO - - TRUNCATE TABLE "EntityWithNullableProperty"; - GO - TRUNCATE TABLE "MappingTestEntity"; GO """; diff --git a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/PostgreSqlTestDatabaseProvider.cs b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/PostgreSqlTestDatabaseProvider.cs index 66bb79b..413d9a1 100644 --- a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/PostgreSqlTestDatabaseProvider.cs +++ b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/PostgreSqlTestDatabaseProvider.cs @@ -138,10 +138,13 @@ public void ResetDatabase() private const String CreateDatabaseObjectsSql = """ + CREATE EXTENSION IF NOT EXISTS pgcrypto; -- Needed for gen_random_bytes() + CREATE TABLE "Entity" ( "Id" bigint NOT NULL PRIMARY KEY, "BooleanValue" boolean, + "BytesValue" bytea, "ByteValue" smallint, "CharValue" char(1), "DateOnlyValue" date, @@ -153,6 +156,7 @@ CREATE TABLE "Entity" "Int16Value" smallint, "Int32Value" integer, "Int64Value" bigint, + "NullableBooleanValue" boolean NULL, "SingleValue" real, "StringValue" text, "TimeOnlyValue" time, @@ -171,28 +175,31 @@ CREATE TABLE "EntityWithEnumStoredAsInteger" "Enum" integer NULL ); - CREATE TABLE "EntityWithNonNullableProperty" + CREATE TABLE "MappingTestEntity" ( - "Id" bigint NOT NULL PRIMARY KEY, - "Value" bigint NULL + "Computed" integer GENERATED ALWAYS AS ("Value"+(999)), + "ConcurrencyToken" bytea, + "Identity" integer GENERATED ALWAYS AS IDENTITY NOT NULL, + "Key1" bigint NOT NULL, + "Key2" bigint NOT NULL, + "Value" integer NOT NULL, + "NotMapped" text NULL, + "RowVersion" bytea DEFAULT gen_random_bytes(8), + PRIMARY KEY ("Key1", "Key2") ); - CREATE TABLE "EntityWithNullableProperty" - ( - "Id" bigint NOT NULL PRIMARY KEY, - "Value" bigint NULL - ); + CREATE OR REPLACE FUNCTION "UpdateMappingTestEntityRowVersion"() + RETURNS TRIGGER AS $$ + BEGIN + NEW."RowVersion" = gen_random_bytes(8); + RETURN NEW; + END; + $$ LANGUAGE plpgsql; - CREATE TABLE "MappingTestEntity" - ( - "KeyColumn1" bigint NOT NULL, - "KeyColumn2" bigint NOT NULL, - "ValueColumn" integer NOT NULL, - "ComputedColumn" integer GENERATED ALWAYS AS ("ValueColumn"+(999)), - "IdentityColumn" integer GENERATED ALWAYS AS IDENTITY NOT NULL, - "NotMappedColumn" text NULL, - PRIMARY KEY ("KeyColumn1", "KeyColumn2") - ); + CREATE TRIGGER "TriggerMappingTestEntityRowVersion" + BEFORE UPDATE ON "MappingTestEntity" + FOR EACH ROW + EXECUTE FUNCTION "UpdateMappingTestEntityRowVersion"(); CREATE PROCEDURE "GetEntities" () LANGUAGE SQL @@ -238,8 +245,6 @@ DELETE FROM "Entity" TRUNCATE TABLE "Entity"; TRUNCATE TABLE "EntityWithEnumStoredAsString"; TRUNCATE TABLE "EntityWithEnumStoredAsInteger"; - TRUNCATE TABLE "EntityWithNonNullableProperty"; - TRUNCATE TABLE "EntityWithNullableProperty"; TRUNCATE TABLE "MappingTestEntity"; """; diff --git a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SQLiteTestDatabaseProvider.cs b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SQLiteTestDatabaseProvider.cs index b1f519c..c81b471 100644 --- a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SQLiteTestDatabaseProvider.cs +++ b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SQLiteTestDatabaseProvider.cs @@ -129,6 +129,7 @@ CREATE TABLE Entity ( Id INTEGER, BooleanValue INTEGER, + BytesValue BLOB, ByteValue INTEGER, CharValue TEXT, DateOnlyValue TEXT, @@ -140,6 +141,7 @@ CREATE TABLE Entity Int16Value INTEGER, Int32Value INTEGER, Int64Value INTEGER, + NullableBooleanValue INTEGER NULL, SingleValue REAL, StringValue TEXT, TimeOnlyValue TEXT, @@ -164,26 +166,23 @@ CREATE TABLE EntityWithEnumStoredAsInteger Enum INTEGER ); - CREATE TABLE EntityWithNonNullableProperty - ( - Id INTEGER NOT NULL, - Value INTEGER NULL - ); - - CREATE TABLE EntityWithNullableProperty - ( - Id INTEGER NOT NULL, - Value INTEGER NULL - ); - CREATE TABLE MappingTestEntity ( - KeyColumn1 INTEGER NOT NULL, - KeyColumn2 INTEGER NOT NULL, - ValueColumn INTEGER NOT NULL, - ComputedColumn INTEGER GENERATED ALWAYS AS (ValueColumn+999) VIRTUAL, - IdentityColumn INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, - NotMappedColumn TEXT NULL + Computed INTEGER GENERATED ALWAYS AS (Value+999) VIRTUAL, + ConcurrencyToken BLOB, + Identity INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + Key1 INTEGER NOT NULL, + Key2 INTEGER NOT NULL, + Value INTEGER NOT NULL, + RowVersion BLOB DEFAULT (randomblob(8)), + NotMapped TEXT NULL ); + + CREATE TRIGGER TriggerMappingTestEntity + BEFORE UPDATE ON MappingTestEntity + FOR EACH ROW + BEGIN + UPDATE MappingTestEntity SET RowVersion = randomblob(8) WHERE Key1 = OLD.Key1 AND Key2 = OLD.Key2; + END; """; } diff --git a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SqlServerTestDatabaseProvider.cs b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SqlServerTestDatabaseProvider.cs index f793914..803b8c3 100644 --- a/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SqlServerTestDatabaseProvider.cs +++ b/tests/DbConnectionPlus.IntegrationTests/TestDatabase/SqlServerTestDatabaseProvider.cs @@ -167,6 +167,7 @@ CREATE TABLE Entity ( Id BIGINT NOT NULL PRIMARY KEY, BooleanValue BIT, + BytesValue VARBINARY(MAX), ByteValue TINYINT, CharValue CHAR(1), DateOnlyValue DATE, @@ -178,6 +179,7 @@ EnumValue NVARCHAR(200), Int16Value SMALLINT, Int32Value INT, Int64Value BIGINT, + NullableBooleanValue BIT NULL, SingleValue REAL, StringValue NVARCHAR(MAX), TimeOnlyValue TIME, @@ -206,29 +208,17 @@ Enum INT NULL ); GO - CREATE TABLE EntityWithNonNullableProperty - ( - Id BIGINT NOT NULL PRIMARY KEY, - Value BIGINT NULL - ); - GO - - CREATE TABLE EntityWithNullableProperty - ( - Id BIGINT NOT NULL PRIMARY KEY, - Value BIGINT NULL - ); - GO - CREATE TABLE MappingTestEntity ( - KeyColumn1 BIGINT NOT NULL, - KeyColumn2 BIGINT NOT NULL, - ValueColumn INT NOT NULL, - ComputedColumn AS ([ValueColumn]+(999)), - IdentityColumn INT IDENTITY(1,1) NOT NULL, - NotMappedColumn VARCHAR(200) NULL, - PRIMARY KEY (KeyColumn1, KeyColumn2) + Computed AS ([Value]+(999)), + ConcurrencyToken VARBINARY(max), + [Identity] INT IDENTITY(1,1) NOT NULL, + Key1 BIGINT NOT NULL, + Key2 BIGINT NOT NULL, + Value INT NOT NULL, + NotMapped VARCHAR(200) NULL, + RowVersion ROWVERSION, + PRIMARY KEY (Key1, Key2) ); GO @@ -291,12 +281,6 @@ DELETE FROM Entity TRUNCATE TABLE EntityWithEnumStoredAsInteger; GO - TRUNCATE TABLE EntityWithNonNullableProperty; - GO - - TRUNCATE TABLE EntityWithNullableProperty; - GO - TRUNCATE TABLE MappingTestEntity; GO """; diff --git a/tests/DbConnectionPlus.IntegrationTests/TestHelpers/DbCommandLogger.cs b/tests/DbConnectionPlus.IntegrationTests/TestHelpers/DbCommandLogger.cs deleted file mode 100644 index 90c9c76..0000000 --- a/tests/DbConnectionPlus.IntegrationTests/TestHelpers/DbCommandLogger.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Data.Common; -using LinkDotNet.StringBuilder; -using RentADeveloper.DbConnectionPlus.Extensions; - -namespace RentADeveloper.DbConnectionPlus.IntegrationTests.TestHelpers; - -/// -/// Logs database commands executed during tests for debugging purposes. -/// -public static class DbCommandLogger -{ - /// - /// Determines whether database commands should be logged. - /// - public static Boolean LogCommands { get; set; } = true; - - /// - /// Logs the specified database command and the temporary tables used in the command. - /// - /// The to log. - /// The temporary tables used in the command. - public static void LogDbCommand(DbCommand command, IReadOnlyList temporaryTables) - { - if (!LogCommands) - { - return; - } - - using var logMessageBuilder = new ValueStringBuilder(stackalloc Char[500]); - - logMessageBuilder.AppendLine(); - logMessageBuilder.AppendLine("-----------------"); - logMessageBuilder.AppendLine("Executing Command"); - logMessageBuilder.AppendLine("-----------------"); - logMessageBuilder.AppendLine(command.CommandText.Trim()); - - if (command.Parameters.Count > 0) - { - logMessageBuilder.AppendLine(); - logMessageBuilder.AppendLine("----------"); - logMessageBuilder.AppendLine("Parameters"); - logMessageBuilder.AppendLine("----------"); - - foreach (DbParameter parameter in command.Parameters) - { - logMessageBuilder.Append(" - "); - logMessageBuilder.Append(parameter.ParameterName); - - logMessageBuilder.Append(" ("); - logMessageBuilder.Append(parameter.Direction.ToString()); - logMessageBuilder.Append(")"); - - logMessageBuilder.Append(" = "); - logMessageBuilder.Append(parameter.Value.ToDebugString()); - logMessageBuilder.AppendLine(); - } - } - - if (temporaryTables.Count > 0) - { - logMessageBuilder.AppendLine(); - logMessageBuilder.AppendLine("----------------"); - logMessageBuilder.AppendLine("Temporary Tables"); - logMessageBuilder.AppendLine("----------------"); - - foreach (var temporaryTable in temporaryTables) - { - logMessageBuilder.AppendLine(); - logMessageBuilder.AppendLine(temporaryTable.Name); - logMessageBuilder.AppendLine(new String('-', temporaryTable.Name.Length)); - - foreach (var value in temporaryTable.Values) - { - logMessageBuilder.AppendLine(value.ToDebugString()); - } - } - } - - Console.WriteLine(logMessageBuilder.ToString()); - } -} diff --git a/tests/DbConnectionPlus.IntegrationTests/TestHelpers/DelayDbCommandFactory.cs b/tests/DbConnectionPlus.IntegrationTests/TestHelpers/DelayDbCommandFactory.cs deleted file mode 100644 index 0178be6..0000000 --- a/tests/DbConnectionPlus.IntegrationTests/TestHelpers/DelayDbCommandFactory.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Data.Common; - -namespace RentADeveloper.DbConnectionPlus.IntegrationTests.TestHelpers; - -/// -/// An implementation of that supports delaying created commands. -/// -public class DelayDbCommandFactory(ITestDatabaseProvider testDatabaseProvider) : IDbCommandFactory -{ - /// - /// Determines whether the next database command created throw this instance will be delayed by 2 seconds. - /// If set to , a 2 second delay will be injected into the next database command created by - /// this factory. Subsequent commands will not be delayed unless this property is set to - /// again. - /// - public Boolean DelayNextDbCommand { get; set; } - - /// - public DbCommand CreateDbCommand( - DbConnection connection, - String commandText, - DbTransaction? transaction = null, - TimeSpan? commandTimeout = null, - CommandType commandType = CommandType.Text - ) - { - ArgumentNullException.ThrowIfNull(connection); - ArgumentNullException.ThrowIfNull(commandText); - - var command = connection.CreateCommand(); - -#pragma warning disable CA2100 - if (this.DelayNextDbCommand) - { - command.CommandText = testDatabaseProvider.DelayTwoSecondsStatement + commandText; - this.DelayNextDbCommand = false; - } - else - { - command.CommandText = commandText; - } -#pragma warning restore CA2100 - - command.Transaction = transaction; - command.CommandType = commandType; - - if (commandTimeout is not null) - { - command.CommandTimeout = (Int32)commandTimeout.Value.TotalSeconds; - } - - return command; - } -} diff --git a/tests/DbConnectionPlus.UnitTests/Configuration/DbConnectionPlusConfigurationTests.cs b/tests/DbConnectionPlus.UnitTests/Configuration/DbConnectionPlusConfigurationTests.cs index 6c2952c..5ec0abf 100644 --- a/tests/DbConnectionPlus.UnitTests/Configuration/DbConnectionPlusConfigurationTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Configuration/DbConnectionPlusConfigurationTests.cs @@ -1,7 +1,6 @@ -using Microsoft.Data.Sqlite; +using Microsoft.Data.Sqlite; using MySqlConnector; using Npgsql; -using NSubstitute.DbConnection; using Oracle.ManagedDataAccess.Client; using RentADeveloper.DbConnectionPlus.DatabaseAdapters; using RentADeveloper.DbConnectionPlus.DatabaseAdapters.MySql; @@ -20,8 +19,6 @@ public void EnumSerializationMode_Integers_ShouldSerializeEnumAsInteger() { var enumValue = Generate.Single(); - this.MockDbConnection.SetupQuery(_ => true).Returns(new { Id = 1 }); - DbParameter? interceptedDbParameter = null; DbConnectionPlusConfiguration.Instance.InterceptDbCommand = @@ -43,8 +40,6 @@ public void EnumSerializationMode_Strings_ShouldSerializeEnumAsString() { var enumValue = Generate.Single(); - this.MockDbConnection.SetupQuery(_ => true).Returns(new { Id = 1 }); - DbParameter? interceptedDbParameter = null; DbConnectionPlusConfiguration.Instance.InterceptDbCommand = @@ -193,8 +188,6 @@ public void InterceptDbCommand_ShouldInterceptDbCommands() DbConnectionPlusConfiguration.Instance.InterceptDbCommand = interceptor; - this.MockDbConnection.SetupQuery(_ => true).Returns(new { Id = 1 }); - var entities = Generate.Multiple(); var entityIds = Generate.Ids(); var stringValue = entities[0].StringValue; diff --git a/tests/DbConnectionPlus.UnitTests/Configuration/EntityPropertyBuilderTests.cs b/tests/DbConnectionPlus.UnitTests/Configuration/EntityPropertyBuilderTests.cs index 31eea9e..730a52e 100644 --- a/tests/DbConnectionPlus.UnitTests/Configuration/EntityPropertyBuilderTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Configuration/EntityPropertyBuilderTests.cs @@ -2,10 +2,31 @@ public class EntityPropertyBuilderTests : UnitTestsBase { + [Fact] + public void ColumnName_Configured_ShouldReturnColumnName() + { + var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + + builder.HasColumnName("Identifier"); + + ((IEntityPropertyBuilder)builder).ColumnName + .Should().Be("Identifier"); + } + + [Fact] + public void ColumnName_NotConfigured_ShouldReturnNull() + { + var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + + ((IEntityPropertyBuilder)builder).ColumnName + .Should().BeNull(); + } + [Fact] public void Freeze_ShouldFreezeBuilder() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + ((IFreezable)builder).Freeze(); Invoking(() => builder.HasColumnName("Identifier")) @@ -16,6 +37,10 @@ public void Freeze_ShouldFreezeBuilder() .Should().Throw() .WithMessage("The configuration of DbConnectionPlus is frozen and can no longer be modified."); + Invoking(() => builder.IsConcurrencyToken()) + .Should().Throw() + .WithMessage("The configuration of DbConnectionPlus is frozen and can no longer be modified."); + Invoking(() => builder.IsIdentity()) .Should().Throw() .WithMessage("The configuration of DbConnectionPlus is frozen and can no longer be modified."); @@ -27,12 +52,17 @@ public void Freeze_ShouldFreezeBuilder() Invoking(() => builder.IsKey()) .Should().Throw() .WithMessage("The configuration of DbConnectionPlus is frozen and can no longer be modified."); + + Invoking(() => builder.IsRowVersion()) + .Should().Throw() + .WithMessage("The configuration of DbConnectionPlus is frozen and can no longer be modified."); } [Fact] - public void GetColumnName_Configured_ShouldReturnColumnName() + public void HasColumnName_ShouldSetColumnName() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + builder.HasColumnName("Identifier"); ((IEntityPropertyBuilder)builder).ColumnName @@ -40,16 +70,27 @@ public void GetColumnName_Configured_ShouldReturnColumnName() } [Fact] - public void GetColumnName_NotConfigured_ShouldReturnNull() + public void IsComputed_Configured_ShouldReturnTrue() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - ((IEntityPropertyBuilder)builder).ColumnName - .Should().BeNull(); + builder.IsComputed(); + + ((IEntityPropertyBuilder)builder).IsComputed + .Should().BeTrue(); } [Fact] - public void GetIsComputed_Configured_ShouldReturnTrue() + public void IsComputed_NotConfigured_ShouldReturnFalse() + { + var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + + ((IEntityPropertyBuilder)builder).IsComputed + .Should().BeFalse(); + } + + [Fact] + public void IsComputed_ShouldMarkPropertyAsComputed() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); @@ -60,16 +101,38 @@ public void GetIsComputed_Configured_ShouldReturnTrue() } [Fact] - public void GetIsComputed_NotConfigured_ShouldReturnFalse() + public void IsConcurrencyToken_Configured_ShouldReturnTrue() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - ((IEntityPropertyBuilder)builder).IsComputed + builder.IsConcurrencyToken(); + + ((IEntityPropertyBuilder)builder).IsConcurrencyToken + .Should().BeTrue(); + } + + [Fact] + public void IsConcurrencyToken_NotConfigured_ShouldReturnFalse() + { + var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + + ((IEntityPropertyBuilder)builder).IsConcurrencyToken .Should().BeFalse(); } [Fact] - public void GetIsIdentity_Configured_ShouldReturnTrue() + public void IsConcurrencyToken_ShouldMarkPropertyAsConcurrencyToken() + { + var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + + builder.IsConcurrencyToken(); + + ((IEntityPropertyBuilder)builder).IsConcurrencyToken + .Should().BeTrue(); + } + + [Fact] + public void IsIdentity_Configured_ShouldReturnTrue() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); @@ -80,7 +143,7 @@ public void GetIsIdentity_Configured_ShouldReturnTrue() } [Fact] - public void GetIsIdentity_NotConfigured_ShouldReturnFalse() + public void IsIdentity_NotConfigured_ShouldReturnFalse() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); @@ -89,7 +152,35 @@ public void GetIsIdentity_NotConfigured_ShouldReturnFalse() } [Fact] - public void GetIsIgnored_Configured_ShouldReturnTrue() + public void IsIdentity_OtherPropertyIsAlreadyMarked_ShouldThrow() + { + var entityTypeBuilder = new EntityTypeBuilder(); + + entityTypeBuilder.Property(a => a.Id).IsIdentity(); + + var propertyBuilder = new EntityPropertyBuilder(entityTypeBuilder, "NotId"); + + Invoking(() => propertyBuilder.IsIdentity()) + .Should().Throw() + .WithMessage( + "There is already the property 'Id' marked as an identity property for the entity type " + + $"{typeof(Entity)}. Only one property can be marked as identity property per entity type." + ); + } + + [Fact] + public void IsIdentity_ShouldMarkPropertyAsIdentity() + { + var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + + builder.IsIdentity(); + + ((IEntityPropertyBuilder)builder).IsIdentity + .Should().BeTrue(); + } + + [Fact] + public void IsIgnored_Configured_ShouldReturnTrue() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); @@ -100,7 +191,7 @@ public void GetIsIgnored_Configured_ShouldReturnTrue() } [Fact] - public void GetIsIgnored_NotConfigured_ShouldReturnFalse() + public void IsIgnored_NotConfigured_ShouldReturnFalse() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); @@ -109,90 +200,90 @@ public void GetIsIgnored_NotConfigured_ShouldReturnFalse() } [Fact] - public void GetIsKey_Configured_ShouldReturnTrue() + public void IsIgnored_ShouldMarkPropertyAsIgnored() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - builder.IsKey(); + builder.IsIgnored(); - ((IEntityPropertyBuilder)builder).IsKey + ((IEntityPropertyBuilder)builder).IsIgnored .Should().BeTrue(); } [Fact] - public void GetIsKey_NotConfigured_ShouldReturnFalse() + public void IsKey_Configured_ShouldReturnTrue() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); + builder.IsKey(); + ((IEntityPropertyBuilder)builder).IsKey - .Should().BeFalse(); + .Should().BeTrue(); } [Fact] - public void HasColumnName_ShouldSetColumnName() + public void IsKey_NotConfigured_ShouldReturnFalse() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - builder.HasColumnName("Identifier"); - ((IEntityPropertyBuilder)builder).ColumnName - .Should().Be("Identifier"); + ((IEntityPropertyBuilder)builder).IsKey + .Should().BeFalse(); } [Fact] - public void IsComputed_ShouldMarkPropertyAsComputed() + public void IsKey_ShouldMarkPropertyAsKey() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - builder.IsComputed(); + builder.IsKey(); - ((IEntityPropertyBuilder)builder).IsComputed + ((IEntityPropertyBuilder)builder).IsKey .Should().BeTrue(); } [Fact] - public void IsIdentity_OtherPropertyIsAlreadyMarked_ShouldThrow() + public void IsRowVersion_Configured_ShouldReturnTrue() { - var entityTypeBuilder = new EntityTypeBuilder(); - entityTypeBuilder.Property(a => a.Id).IsIdentity(); + var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - var builder = new EntityPropertyBuilder(entityTypeBuilder, "Property"); + builder.IsRowVersion(); - Invoking(() => builder.IsIdentity()) - .Should().Throw() - .WithMessage( - "There is already the property 'Id' marked as an identity property for the entity type " + - $"{typeof(Entity)}. Only one property can be marked as identity property per entity type." - ); + ((IEntityPropertyBuilder)builder).IsRowVersion + .Should().BeTrue(); } [Fact] - public void IsIdentity_ShouldMarkPropertyAsIdentity() + public void IsRowVersion_NotConfigured_ShouldReturnFalse() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - builder.IsIdentity(); - - ((IEntityPropertyBuilder)builder).IsIdentity - .Should().BeTrue(); + ((IEntityPropertyBuilder)builder).IsRowVersion + .Should().BeFalse(); } [Fact] - public void IsIgnored_ShouldMarkPropertyAsIgnored() + public void IsRowVersion_ShouldMarkPropertyAsRowVersion() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - builder.IsIgnored(); - ((IEntityPropertyBuilder)builder).IsIgnored + builder.IsRowVersion(); + + ((IEntityPropertyBuilder)builder).IsRowVersion .Should().BeTrue(); } [Fact] - public void IsKey_ShouldMarkPropertyAsKey() + public void PropertyName_ShouldReturnPropertyName() { var builder = new EntityPropertyBuilder(Substitute.For(), "Property"); - builder.IsKey(); - ((IEntityPropertyBuilder)builder).IsKey - .Should().BeTrue(); + ((IEntityPropertyBuilder)builder).PropertyName + .Should().Be("Property"); } + + [Fact] + public void ShouldGuardAgainstNullArguments() => + ArgumentNullGuardVerifier.Verify(() => + new EntityPropertyBuilder(Substitute.For(), "Property") + ); } diff --git a/tests/DbConnectionPlus.UnitTests/Converters/ValueConverterTests.cs b/tests/DbConnectionPlus.UnitTests/Converters/ValueConverterTests.cs index b6991ff..da6f8de 100644 --- a/tests/DbConnectionPlus.UnitTests/Converters/ValueConverterTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Converters/ValueConverterTests.cs @@ -604,7 +604,7 @@ Object ExpectedTargetValue (typeof(Boolean), typeof(Decimal), true, true, (Decimal)1), (typeof(Boolean), typeof(Double), true, true, (Double)1), (typeof(Boolean), typeof(Int16), true, true, (Int16)1), - (typeof(Boolean), typeof(Int32), true, true, (Int32)1), + (typeof(Boolean), typeof(Int32), true, true, 1), (typeof(Boolean), typeof(Int64), true, true, (Int64)1), (typeof(Boolean), typeof(Object), true, true, true), (typeof(Boolean), typeof(SByte), true, true, (SByte)1), @@ -621,7 +621,7 @@ Object ExpectedTargetValue (typeof(Byte), typeof(Int16), true, byteValue, (Int16)byteValue), (typeof(Byte), typeof(Int32), true, byteValue, (Int32)byteValue), (typeof(Byte), typeof(Int64), true, byteValue, (Int64)byteValue), - (typeof(Byte), typeof(Object), true, byteValue, (Object)byteValue), + (typeof(Byte), typeof(Object), true, byteValue, byteValue), (typeof(Byte), typeof(SByte), true, byteValue, (SByte)byteValue), (typeof(Byte), typeof(Single), true, byteValue, (Single)byteValue), (typeof(Byte), typeof(String), true, byteValue, byteValue.ToString(CultureInfo.InvariantCulture)), @@ -635,21 +635,21 @@ Object ExpectedTargetValue (typeof(Char), typeof(Int16), true, charValue, (Int16)charValue), (typeof(Char), typeof(Int32), true, charValue, (Int32)charValue), (typeof(Char), typeof(Int64), true, charValue, (Int64)charValue), - (typeof(Char), typeof(Object), true, charValue, (Object)charValue), + (typeof(Char), typeof(Object), true, charValue, charValue), (typeof(Char), typeof(SByte), true, charValue, (SByte)charValue), (typeof(Char), typeof(String), true, charValue, charValue.ToString(CultureInfo.InvariantCulture)), (typeof(Char), typeof(UInt16), true, charValue, (UInt16)charValue), (typeof(Char), typeof(UInt32), true, charValue, (UInt32)charValue), (typeof(Char), typeof(UInt64), true, charValue, (UInt64)charValue), (typeof(DateOnly), typeof(DateOnly), true, dateOnlyValue, dateOnlyValue), - (typeof(DateOnly), typeof(Object), true, dateOnlyValue, (Object)dateOnlyValue), + (typeof(DateOnly), typeof(Object), true, dateOnlyValue, dateOnlyValue), (typeof(DateOnly), typeof(String), true, dateOnlyValue, dateOnlyValue.ToString("O", CultureInfo.InvariantCulture)), (typeof(DateTime), typeof(DateOnly), true, dateOnlyValue.ToDateTime(TimeOnly.MinValue), dateOnlyValue), (typeof(DateTime), typeof(DateTime), true, dateTimeValue, dateTimeValue), - (typeof(DateTime), typeof(Object), true, dateTimeValue, (Object)dateTimeValue), + (typeof(DateTime), typeof(Object), true, dateTimeValue, dateTimeValue), (typeof(DateTime), typeof(String), true, dateTimeValue, dateTimeValue.ToString("O", CultureInfo.InvariantCulture)), (typeof(DateTimeOffset), typeof(DateTimeOffset), true, dateTimeOffsetValue, dateTimeOffsetValue), - (typeof(DateTimeOffset), typeof(Object), true, dateTimeOffsetValue, (Object)dateTimeOffsetValue), + (typeof(DateTimeOffset), typeof(Object), true, dateTimeOffsetValue, dateTimeOffsetValue), (typeof(DateTimeOffset), typeof(String), true, dateTimeOffsetValue, dateTimeOffsetValue.ToString("O", CultureInfo.InvariantCulture)), (typeof(Decimal), typeof(Boolean), true, 1M, true), (typeof(Decimal), typeof(Byte), true, decimalValue, Convert.ChangeType(decimalValue, typeof(Byte), CultureInfo.InvariantCulture)), @@ -658,7 +658,7 @@ Object ExpectedTargetValue (typeof(Decimal), typeof(Int16), true, decimalValue, Convert.ChangeType(decimalValue, typeof(Int16), CultureInfo.InvariantCulture)), (typeof(Decimal), typeof(Int32), true, decimalValue, Convert.ChangeType(decimalValue, typeof(Int32), CultureInfo.InvariantCulture)), (typeof(Decimal), typeof(Int64), true, decimalValue, Convert.ChangeType(decimalValue, typeof(Int64), CultureInfo.InvariantCulture)), - (typeof(Decimal), typeof(Object), true, decimalValue, (Object)decimalValue), + (typeof(Decimal), typeof(Object), true, decimalValue, decimalValue), (typeof(Decimal), typeof(SByte), true, decimalValue, Convert.ChangeType(decimalValue, typeof(SByte), CultureInfo.InvariantCulture)), (typeof(Decimal), typeof(Single), true, decimalValue, Convert.ChangeType(decimalValue, typeof(Single), CultureInfo.InvariantCulture)), (typeof(Decimal), typeof(String), true, decimalValue, decimalValue.ToString(CultureInfo.InvariantCulture)), @@ -673,7 +673,7 @@ Object ExpectedTargetValue (typeof(Double), typeof(Int16), true, doubleValue, Convert.ChangeType(doubleValue, typeof(Int16), CultureInfo.InvariantCulture)), (typeof(Double), typeof(Int32), true, doubleValue, Convert.ChangeType(doubleValue, typeof(Int32), CultureInfo.InvariantCulture)), (typeof(Double), typeof(Int64), true, doubleValue, Convert.ChangeType(doubleValue, typeof(Int64), CultureInfo.InvariantCulture)), - (typeof(Double), typeof(Object), true, doubleValue, (Object)doubleValue), + (typeof(Double), typeof(Object), true, doubleValue, doubleValue), (typeof(Double), typeof(SByte), true, doubleValue, Convert.ChangeType(doubleValue, typeof(SByte), CultureInfo.InvariantCulture)), (typeof(Double), typeof(Single), true, doubleValue, Convert.ChangeType(doubleValue, typeof(Single), CultureInfo.InvariantCulture)), (typeof(Double), typeof(String), true, doubleValue, doubleValue.ToString(CultureInfo.InvariantCulture)), @@ -683,7 +683,7 @@ Object ExpectedTargetValue (typeof(Double), typeof(UInt64), true, doubleValue, Convert.ChangeType(doubleValue, typeof(UInt64), CultureInfo.InvariantCulture)), (typeof(Guid), typeof(Byte[]), true, guidValue, guidValue.ToByteArray()), (typeof(Guid), typeof(Guid), true, guidValue, guidValue), - (typeof(Guid), typeof(Object), true, guidValue, (Object)guidValue), + (typeof(Guid), typeof(Object), true, guidValue, guidValue), (typeof(Guid), typeof(String), true, guidValue, guidValue.ToString("D")), (typeof(Int16), typeof(Boolean), true, (Int16)1, true), (typeof(Int16), typeof(Byte), true, int16Value, (Byte) int16Value), @@ -693,7 +693,7 @@ Object ExpectedTargetValue (typeof(Int16), typeof(Int16), true, int16Value, int16Value), (typeof(Int16), typeof(Int32), true, int16Value, (Int32)int16Value), (typeof(Int16), typeof(Int64), true, int16Value, (Int64)int16Value), - (typeof(Int16), typeof(Object), true, int16Value, (Object)int16Value), + (typeof(Int16), typeof(Object), true, int16Value, int16Value), (typeof(Int16), typeof(SByte), true, int16Value, (SByte)int16Value), (typeof(Int16), typeof(Single), true, int16Value, (Single)int16Value), (typeof(Int16), typeof(String), true, int16Value, int16Value.ToString(CultureInfo.InvariantCulture)), @@ -701,7 +701,7 @@ Object ExpectedTargetValue (typeof(Int16), typeof(UInt16), true, int16Value, (UInt16)int16Value), (typeof(Int16), typeof(UInt32), true, int16Value, (UInt32)int16Value), (typeof(Int16), typeof(UInt64), true, int16Value, (UInt64)int16Value), - (typeof(Int32), typeof(Boolean), true, (Int32)1, true), + (typeof(Int32), typeof(Boolean), true, 1, true), (typeof(Int32), typeof(Byte), true, int32Value, (Byte)int32Value), (typeof(Int32), typeof(Char), true, int32Value, (Char) int32Value), (typeof(Int32), typeof(Decimal), true, int32Value, (Decimal)int32Value), @@ -709,7 +709,7 @@ Object ExpectedTargetValue (typeof(Int32), typeof(Int16), true, int32Value, (Int16)int32Value), (typeof(Int32), typeof(Int32), true, int32Value, int32Value), (typeof(Int32), typeof(Int64), true, int32Value, (Int64)int32Value), - (typeof(Int32), typeof(Object), true, int32Value, (Object)int32Value), + (typeof(Int32), typeof(Object), true, int32Value, int32Value), (typeof(Int32), typeof(SByte), true, int32Value, (SByte)int32Value), (typeof(Int32), typeof(Single), true, int32Value, (Single)int32Value), (typeof(Int32), typeof(String), true, int32Value, int32Value.ToString(CultureInfo.InvariantCulture)), @@ -725,7 +725,7 @@ Object ExpectedTargetValue (typeof(Int64), typeof(Int16), true, int64Value, (Int16)int64Value), (typeof(Int64), typeof(Int32), true, int64Value, (Int32)int64Value), (typeof(Int64), typeof(Int64), true, int64Value, int64Value), - (typeof(Int64), typeof(Object), true, int64Value, (Object)int64Value), + (typeof(Int64), typeof(Object), true, int64Value, int64Value), (typeof(Int64), typeof(SByte), true, int64Value, (SByte)int64Value), (typeof(Int64), typeof(Single), true, int64Value, (Single)int64Value), (typeof(Int64), typeof(String), true, int64Value, int64Value.ToString(CultureInfo.InvariantCulture)), @@ -734,7 +734,7 @@ Object ExpectedTargetValue (typeof(Int64), typeof(UInt32), true, int64Value, (UInt32)int64Value), (typeof(Int64), typeof(UInt64), true, int64Value, (UInt64)int64Value), (typeof(IntPtr), typeof(IntPtr), true, intPtrValue, intPtrValue), - (typeof(IntPtr), typeof(Object), true, intPtrValue, (Object)intPtrValue), + (typeof(IntPtr), typeof(Object), true, intPtrValue, intPtrValue), (typeof(SByte), typeof(Boolean), true, (SByte)1, true), (typeof(SByte), typeof(Byte), true, sbyteValue, (Byte)sbyteValue), (typeof(SByte), typeof(Char), true, sbyteValue, (Char) sbyteValue), @@ -743,7 +743,7 @@ Object ExpectedTargetValue (typeof(SByte), typeof(Int16), true, sbyteValue, (Int16)sbyteValue), (typeof(SByte), typeof(Int32), true, sbyteValue, (Int32)sbyteValue), (typeof(SByte), typeof(Int64), true, sbyteValue, (Int64)sbyteValue), - (typeof(SByte), typeof(Object), true, sbyteValue, (Object)sbyteValue), + (typeof(SByte), typeof(Object), true, sbyteValue, sbyteValue), (typeof(SByte), typeof(SByte), true, sbyteValue, sbyteValue), (typeof(SByte), typeof(Single), true, sbyteValue, (Single)sbyteValue), (typeof(SByte), typeof(String), true, sbyteValue, sbyteValue.ToString(CultureInfo.InvariantCulture)), @@ -758,7 +758,7 @@ Object ExpectedTargetValue (typeof(Single), typeof(Int16), true, singleValue, Convert.ChangeType(singleValue, typeof(Int16), CultureInfo.InvariantCulture)), (typeof(Single), typeof(Int32), true, singleValue, Convert.ChangeType(singleValue, typeof(Int32), CultureInfo.InvariantCulture)), (typeof(Single), typeof(Int64), true, singleValue, Convert.ChangeType(singleValue, typeof(Int64), CultureInfo.InvariantCulture)), - (typeof(Single), typeof(Object), true, singleValue, (Object)singleValue), + (typeof(Single), typeof(Object), true, singleValue, singleValue), (typeof(Single), typeof(SByte), true, singleValue, Convert.ChangeType(singleValue, typeof(SByte), CultureInfo.InvariantCulture)), (typeof(Single), typeof(Single), true, singleValue, Convert.ChangeType(singleValue, typeof(Single), CultureInfo.InvariantCulture)), (typeof(Single), typeof(String), true, singleValue, singleValue.ToString(CultureInfo.InvariantCulture)), @@ -778,7 +778,7 @@ Object ExpectedTargetValue (typeof(String), typeof(Int16), true, int16Value.ToString(CultureInfo.InvariantCulture), int16Value), (typeof(String), typeof(Int32), true, int32Value.ToString(CultureInfo.InvariantCulture), int32Value), (typeof(String), typeof(Int64), true, int64Value.ToString(CultureInfo.InvariantCulture), int64Value), - (typeof(String), typeof(Object), true, stringValue, (Object)stringValue), + (typeof(String), typeof(Object), true, stringValue, stringValue), (typeof(String), typeof(SByte), true, sbyteValue.ToString(CultureInfo.InvariantCulture), sbyteValue), (typeof(String), typeof(Single), true, singleValue.ToString(CultureInfo.InvariantCulture), singleValue), (typeof(String), typeof(String), true, stringValue, stringValue), @@ -801,10 +801,10 @@ Object ExpectedTargetValue (typeof(TestEnum), typeof(UInt16), true, enumValue, (UInt16)enumValue), (typeof(TestEnum), typeof(UInt32), true, enumValue, (UInt32)enumValue), (typeof(TestEnum), typeof(UInt64), true, enumValue, (UInt64)enumValue), - (typeof(TimeOnly), typeof(Object), true, timeOnlyValue, (Object)timeOnlyValue), + (typeof(TimeOnly), typeof(Object), true, timeOnlyValue, timeOnlyValue), (typeof(TimeOnly), typeof(String), true, timeOnlyValue, timeOnlyValue.ToString("O", CultureInfo.InvariantCulture)), (typeof(TimeOnly), typeof(TimeOnly), true, timeOnlyValue, timeOnlyValue), - (typeof(TimeSpan), typeof(Object), true, timeSpanValue, (Object)timeSpanValue), + (typeof(TimeSpan), typeof(Object), true, timeSpanValue, timeSpanValue), (typeof(TimeSpan), typeof(String), true, timeSpanValue, timeSpanValue.ToString("g", CultureInfo.InvariantCulture)), (typeof(TimeSpan), typeof(TimeOnly), true, timeSpanValue, TimeOnly.FromTimeSpan(timeSpanValue)), (typeof(TimeSpan), typeof(TimeSpan), true, timeSpanValue, timeSpanValue), @@ -816,12 +816,12 @@ Object ExpectedTargetValue (typeof(UInt16), typeof(Int16), true, uint16Value, (Int16)uint16Value), (typeof(UInt16), typeof(Int32), true, uint16Value, (Int32)uint16Value), (typeof(UInt16), typeof(Int64), true, uint16Value, (Int64)uint16Value), - (typeof(UInt16), typeof(Object), true, uint16Value, (Object)uint16Value), + (typeof(UInt16), typeof(Object), true, uint16Value, uint16Value), (typeof(UInt16), typeof(SByte), true, uint16Value, (SByte)uint16Value), (typeof(UInt16), typeof(Single), true, uint16Value, (Single)uint16Value), (typeof(UInt16), typeof(String), true, uint16Value, uint16Value.ToString(CultureInfo.InvariantCulture)), (typeof(UInt16), typeof(TestEnum), true, (UInt16)enumValue, enumValue), - (typeof(UInt16), typeof(UInt16), true, uint16Value, (UInt16)uint16Value), + (typeof(UInt16), typeof(UInt16), true, uint16Value, uint16Value), (typeof(UInt16), typeof(UInt32), true, uint16Value, (UInt32)uint16Value), (typeof(UInt16), typeof(UInt64), true, uint16Value, (UInt64)uint16Value), (typeof(UInt32), typeof(Boolean), true, (UInt32)1, true), @@ -832,13 +832,13 @@ Object ExpectedTargetValue (typeof(UInt32), typeof(Int32), true, uint32Value, (Int32)uint32Value), (typeof(UInt32), typeof(Int32), true, uint32Value, (Int32)uint32Value), (typeof(UInt32), typeof(Int64), true, uint32Value, (Int64)uint32Value), - (typeof(UInt32), typeof(Object), true, uint32Value, (Object)uint32Value), + (typeof(UInt32), typeof(Object), true, uint32Value, uint32Value), (typeof(UInt32), typeof(SByte), true, uint32Value, (SByte)uint32Value), (typeof(UInt32), typeof(Single), true, uint32Value, (Single)uint32Value), (typeof(UInt32), typeof(String), true, uint32Value, uint32Value.ToString(CultureInfo.InvariantCulture)), (typeof(UInt32), typeof(TestEnum), true, (UInt32)enumValue, enumValue), (typeof(UInt32), typeof(UInt16), true, uint32Value, (UInt16)uint32Value), - (typeof(UInt32), typeof(UInt32), true, uint32Value, (UInt32)uint32Value), + (typeof(UInt32), typeof(UInt32), true, uint32Value, uint32Value), (typeof(UInt32), typeof(UInt64), true, uint32Value, (UInt64)uint32Value), (typeof(UInt64), typeof(Boolean), true, (UInt64)1, true), (typeof(UInt64), typeof(Byte), true, uint64Value, (Byte) uint64Value), @@ -848,15 +848,15 @@ Object ExpectedTargetValue (typeof(UInt64), typeof(Int16), true, uint64Value, (Int16)uint64Value), (typeof(UInt64), typeof(Int32), true, uint64Value, (Int32)uint64Value), (typeof(UInt64), typeof(Int64), true, uint64Value, (Int64)uint64Value), - (typeof(UInt64), typeof(Object), true, uint64Value, (Object)uint64Value), + (typeof(UInt64), typeof(Object), true, uint64Value, uint64Value), (typeof(UInt64), typeof(SByte), true, uint64Value, (SByte)uint64Value), (typeof(UInt64), typeof(Single), true, uint64Value, (Single)uint64Value), (typeof(UInt64), typeof(String), true, uint64Value, uint64Value.ToString(CultureInfo.InvariantCulture)), (typeof(UInt64), typeof(TestEnum), true, (UInt64)enumValue, enumValue), (typeof(UInt64), typeof(UInt16), true, uint64Value, (UInt16)uint64Value), (typeof(UInt64), typeof(UInt32), true, uint64Value, (UInt32)uint64Value), - (typeof(UInt64), typeof(UInt64), true, uint64Value, (UInt64)uint64Value), - (typeof(UIntPtr), typeof(Object), true, uintPtrValue, (Object)uintPtrValue), + (typeof(UInt64), typeof(UInt64), true, uint64Value, uint64Value), + (typeof(UIntPtr), typeof(Object), true, uintPtrValue, uintPtrValue), (typeof(UIntPtr), typeof(UIntPtr), true, uintPtrValue, uintPtrValue), (typeof(Char), typeof(Guid), false, charValue, null), (typeof(Int32), typeof(Guid), false, int32Value, null), diff --git a/tests/DbConnectionPlus.UnitTests/DatabaseAdapters/Oracle/OracleDatabaseAdapterTests.cs b/tests/DbConnectionPlus.UnitTests/DatabaseAdapters/Oracle/OracleDatabaseAdapterTests.cs index 128f384..ce590a0 100644 --- a/tests/DbConnectionPlus.UnitTests/DatabaseAdapters/Oracle/OracleDatabaseAdapterTests.cs +++ b/tests/DbConnectionPlus.UnitTests/DatabaseAdapters/Oracle/OracleDatabaseAdapterTests.cs @@ -1,5 +1,4 @@ -using NSubstitute.DbConnection; -using Oracle.ManagedDataAccess.Client; +using Oracle.ManagedDataAccess.Client; using RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle; namespace RentADeveloper.DbConnectionPlus.UnitTests.DatabaseAdapters.Oracle; @@ -303,8 +302,7 @@ public void QuoteIdentifier_ShouldQuoteIdentifier() => [Fact] public void QuoteTemporaryTableName_ShouldQuoteTableName() { - this.MockDbConnection.SetupQuery("SELECT VALUE FROM v$parameter WHERE NAME = 'private_temp_table_prefix'") - .Returns(new { VALUE = "MockPrefix" }); + this.MockDbCommand.ExecuteScalar().Returns("MockPrefix"); this.adapter.QuoteTemporaryTableName("TempTable", this.MockDbConnection) .Should().Be("\"MockPrefixTempTable\""); @@ -325,8 +323,9 @@ public void ShouldGuardAgainstNullArguments() [Fact] public void SupportsTemporaryTables_OracleVersionEqualToOrGreaterThan18_ShouldReturnTrue() { - this.MockDbConnection.SetupQuery("SELECT 1 FROM v$instance WHERE version >= '18'") - .Returns(new { Value = 1 }); + // Fake the query "SELECT 1 FROM v$instance WHERE version >= '18'" to return a result, which indicates that the + // Oracle version is 18 or higher. + this.MockDbDataReader.Read().Returns(true); this.adapter.SupportsTemporaryTables(this.MockDbConnection) .Should().BeTrue(); @@ -335,8 +334,9 @@ public void SupportsTemporaryTables_OracleVersionEqualToOrGreaterThan18_ShouldRe [Fact] public void SupportsTemporaryTables_OracleVersionLessThan18_ShouldReturnFalse() { - this.MockDbConnection.SetupQuery("SELECT 1 FROM v$instance WHERE version >= '18'") - .Returns(Array.Empty()); + // Fake the query "SELECT 1 FROM v$instance WHERE version >= '18'" to return no results, which indicates that + // the Oracle version is lower than 18. + this.MockDbDataReader.Read().Returns(false); this.adapter.SupportsTemporaryTables(this.MockDbConnection) .Should().BeFalse(); diff --git a/tests/DbConnectionPlus.UnitTests/DbCommands/DbCommandBuilderTests.cs b/tests/DbConnectionPlus.UnitTests/DbCommands/DbCommandBuilderTests.cs index 9b84e94..cc30b3e 100644 --- a/tests/DbConnectionPlus.UnitTests/DbCommands/DbCommandBuilderTests.cs +++ b/tests/DbConnectionPlus.UnitTests/DbCommands/DbCommandBuilderTests.cs @@ -110,6 +110,27 @@ public async Task BuildDbCommand_CommandType_ShouldUseCommandType(Boolean useAsy .Should().Be(CommandType.StoredProcedure); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BuildDbCommand_InterpolatedParameter_DuplicateName_ShouldAppendSuffix(Boolean useAsyncApi) + { + var value = Generate.ScalarValue(); + + var (command, _) = await CallApi( + useAsyncApi, + $"SELECT {Parameter(value)}, {Parameter(value)}, {Parameter(value)}, {Parameter(value)}, {Parameter(value)}", + this.MockDatabaseAdapter, + this.MockDbConnection + ); + + command.CommandText + .Should().Be("SELECT @Value, @Value2, @Value3, @Value4, @Value5"); + + command.Parameters.OfType().Select(a => a.ParameterName) + .Should().BeEquivalentTo("Value", "Value2", "Value3", "Value4", "Value5"); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -319,7 +340,7 @@ public async Task BuildDbCommand_InterpolatedParameter_ShouldSupportComplexExpre useAsyncApi, $""" SELECT {Parameter(baseDiscount * 5 / 3)}, - {Parameter(entityIds.Where(a => a > 5).Select(a => a.ToString()).ToArray()[0])} + {Parameter(entityIds.Where(a => a > 5).ToArray()[0])} """, this.MockDatabaseAdapter, this.MockDbConnection @@ -329,7 +350,7 @@ public async Task BuildDbCommand_InterpolatedParameter_ShouldSupportComplexExpre .Should().Be( """ SELECT @BaseDiscount53, - @EntityIdsWhereaa5SelectaaToStringToArray0 + @EntityIdsWhereaa5ToArray0 """ ); @@ -343,10 +364,10 @@ public async Task BuildDbCommand_InterpolatedParameter_ShouldSupportComplexExpre .Should().Be(baseDiscount * 5 / 3); command.Parameters[1].ParameterName - .Should().Be("EntityIdsWhereaa5SelectaaToStringToArray0"); + .Should().Be("EntityIdsWhereaa5ToArray0"); command.Parameters[1].Value - .Should().Be(entityIds.Where(a => a > 5).Select(a => a.ToString()).ToArray()[0]); + .Should().Be(entityIds.Where(a => a > 5).ToArray()[0]); } [Theory] diff --git a/tests/DbConnectionPlus.UnitTests/DbCommands/DefaultDbCommandFactoryTests.cs b/tests/DbConnectionPlus.UnitTests/DbCommands/DefaultDbCommandFactoryTests.cs deleted file mode 100644 index 9b19612..0000000 --- a/tests/DbConnectionPlus.UnitTests/DbCommands/DefaultDbCommandFactoryTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using RentADeveloper.DbConnectionPlus.DbCommands; - -namespace RentADeveloper.DbConnectionPlus.UnitTests.DbCommands; - -public class DefaultDbCommandFactoryTests : UnitTestsBase -{ - [Fact] - public void CreateDbCommand_NoTimeout_ShouldUseDefaultTimeout() - { - var command = this.factory.CreateDbCommand(this.MockDbConnection, "SELECT 1"); - - command.CommandTimeout - .Should().Be(0); - } - - [Fact] - public void CreateDbCommand_ShouldCreateDbCommandWithSpecifiedSettings() - { - using var transaction = this.MockDbConnection.BeginTransaction(); - - var timeout = Generate.Single(); - - var command = this.factory.CreateDbCommand( - this.MockDbConnection, - "SELECT 1", - transaction, - timeout, - CommandType.StoredProcedure - ); - - command.Connection - .Should().BeSameAs(this.MockDbConnection); - - command.CommandText - .Should().Be("SELECT 1"); - - command.Transaction - .Should().BeSameAs(transaction); - - command.CommandType - .Should().Be(CommandType.StoredProcedure); - - command.CommandTimeout - .Should().Be((Int32)timeout.TotalSeconds); - } - - [Fact] - public void ShouldGuardAgainstNullArguments() => - ArgumentNullGuardVerifier.Verify(() => - this.factory.CreateDbCommand(this.MockDbConnection, "SELECT 1") - ); - - private readonly DefaultDbCommandFactory factory = new(); -} diff --git a/tests/DbConnectionPlus.UnitTests/DbConnectionExtensions.ConfigurationTests.cs b/tests/DbConnectionPlus.UnitTests/DbConnectionExtensions.ConfigurationTests.cs index a70e978..3dce9a7 100644 --- a/tests/DbConnectionPlus.UnitTests/DbConnectionExtensions.ConfigurationTests.cs +++ b/tests/DbConnectionPlus.UnitTests/DbConnectionExtensions.ConfigurationTests.cs @@ -16,28 +16,42 @@ public void Configure_ShouldConfigureDbConnectionPlus() .ToTable("MappingTestEntity"); config.Entity() - .Property(a => a.KeyColumn1_) - .HasColumnName("KeyColumn1") - .IsKey(); + .Property(a => a.Computed_) + .HasColumnName("Computed") + .IsComputed(); + + config.Entity() + .Property(a => a.ConcurrencyToken_) + .HasColumnName("ConcurrencyToken") + .IsConcurrencyToken(); + + config.Entity() + .Property(a => a.Identity_) + .HasColumnName("Identity") + .IsIdentity(); config.Entity() - .Property(a => a.KeyColumn2_) - .HasColumnName("KeyColumn2") + .Property(a => a.Key1_) + .HasColumnName("Key1") .IsKey(); config.Entity() - .Property(a => a.ComputedColumn_) - .HasColumnName("ComputedColumn") - .IsComputed(); + .Property(a => a.Key2_) + .HasColumnName("Key2") + .IsKey(); config.Entity() - .Property(a => a.IdentityColumn_) - .HasColumnName("IdentityColumn") - .IsIdentity(); + .Property(a => a.Value_) + .HasColumnName("Value"); config.Entity() - .Property(a => a.NotMappedColumn) + .Property(a => a.NotMapped) .IsIgnored(); + + config.Entity() + .Property(a => a.RowVersion_) + .HasColumnName("RowVersion") + .IsRowVersion(); } ); @@ -60,25 +74,46 @@ public void Configure_ShouldConfigureDbConnectionPlus() entityTypeBuilders[typeof(MappingTestEntityFluentApi)].TableName .Should().Be("MappingTestEntity"); - entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["KeyColumn1_"].ColumnName - .Should().Be("KeyColumn1"); + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Computed_"].ColumnName + .Should().Be("Computed"); - entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["KeyColumn2_"].ColumnName - .Should().Be("KeyColumn2"); + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Computed_"].IsComputed + .Should().BeTrue(); + + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["ConcurrencyToken_"].ColumnName + .Should().Be("ConcurrencyToken"); + + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["ConcurrencyToken_"].IsConcurrencyToken + .Should().BeTrue(); - entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["ComputedColumn_"].ColumnName - .Should().Be("ComputedColumn"); + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Identity_"].ColumnName + .Should().Be("Identity"); - entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["ComputedColumn_"].IsComputed + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Identity_"].IsIdentity .Should().BeTrue(); - entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["IdentityColumn_"].IsIdentity + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Key1_"].ColumnName + .Should().Be("Key1"); + + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Key1_"].IsKey + .Should().BeTrue(); + + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Key2_"].ColumnName + .Should().Be("Key2"); + + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Key2_"].IsKey + .Should().BeTrue(); + + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["Value_"].ColumnName + .Should().Be("Value"); + + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["NotMapped"].IsIgnored .Should().BeTrue(); - entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["IdentityColumn_"].ColumnName - .Should().Be("IdentityColumn"); + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["RowVersion_"].ColumnName + .Should().Be("RowVersion"); - entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["NotMappedColumn"].IsIgnored + entityTypeBuilders[typeof(MappingTestEntityFluentApi)].PropertyBuilders["RowVersion_"].IsRowVersion .Should().BeTrue(); } diff --git a/tests/DbConnectionPlus.UnitTests/Entities/EntityHelperTests.cs b/tests/DbConnectionPlus.UnitTests/Entities/EntityHelperTests.cs index 095c9e3..f565c88 100644 --- a/tests/DbConnectionPlus.UnitTests/Entities/EntityHelperTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Entities/EntityHelperTests.cs @@ -152,106 +152,7 @@ public void FindParameterlessConstructor_PublicParameterlessConstructor_ShouldRe } [Fact] - public void GetEntityTypeMetadata_FluentApiMapping_ShouldGetMetadataBasedOnFluentApiMapping() - { - var tableName = Generate.Single(); - var columnName = Generate.Single(); - - Configure(config => - { - config.Entity() - .ToTable(tableName); - - config.Entity() - .Property(a => a.Id).IsKey(); - - config.Entity() - .Property(a => a.BooleanValue).HasColumnName(columnName); - - config.Entity() - .Property(a => a.Int16Value).IsComputed(); - - config.Entity() - .Property(a => a.Int32Value).IsIdentity(); - - config.Entity() - .Property(a => a.Int64Value).IsIgnored(); - } - ); - - var metadata = EntityHelper.GetEntityTypeMetadata(typeof(Entity)); - - metadata - .Should().NotBeNull(); - - metadata.EntityType - .Should().Be(typeof(Entity)); - - metadata.TableName - .Should().Be(tableName); - - var idProperty = metadata.AllPropertiesByPropertyName["Id"]; - - idProperty.IsKey - .Should().BeTrue(); - - var booleanValueProperty = metadata.AllPropertiesByPropertyName["BooleanValue"]; - - booleanValueProperty.ColumnName - .Should().Be(columnName); - - var int16ValueProperty = metadata.AllPropertiesByPropertyName["Int16Value"]; - - int16ValueProperty.IsComputed - .Should().BeTrue(); - - var int32ValueProperty = metadata.AllPropertiesByPropertyName["Int32Value"]; - - int32ValueProperty.IsIdentity - .Should().BeTrue(); - - var int64ValueProperty = metadata.AllPropertiesByPropertyName["Int64Value"]; - - int64ValueProperty.IsIgnored - .Should().BeTrue(); - - metadata.MappedProperties - .Should() - .NotContain(int64ValueProperty); - - metadata.KeyProperties - .Should() - .Contain(idProperty); - - metadata.ComputedProperties - .Should().Contain(int16ValueProperty); - - metadata.IdentityProperty - .Should().Be(int32ValueProperty); - - metadata.InsertProperties - .Should().Contain([idProperty, booleanValueProperty]); - - metadata.UpdateProperties - .Should().Contain([booleanValueProperty]); - } - - [Fact] - public void GetEntityTypeMetadata_MoreThanOneIdentityProperty_ShouldThrow() => - Invoking(() => EntityHelper.GetEntityTypeMetadata(typeof(EntityWithMultipleIdentityProperties))) - .Should().Throw() - .WithMessage( - "There are multiple identity properties defined for the entity type " + - $"{typeof(EntityWithMultipleIdentityProperties)}. Only one property can be marked as an identity " + - "property per entity type." - ); - - [Theory] - [InlineData(typeof(MappingTestEntity))] - [InlineData(typeof(MappingTestEntityAttributes))] - public void GetEntityTypeMetadata_NoFluentApiMapping_ShouldGetMetadataBasedOnDataAnnotationAttributes( - Type entityType - ) + public void GetEntityTypeMetadata_Mapping_Attributes_ShouldGetMetadataBasedOnAttributes() { var faker = new Faker(); @@ -259,6 +160,8 @@ Type entityType fixture.Register(() => faker.Date.PastDateOnly()); fixture.Register(() => faker.Date.RecentTimeOnly()); + var entityType = typeof(MappingTestEntityAttributes); + var entity = specimenFactoryCreateMethod .MakeGenericMethod(entityType) .Invoke(null, [fixture]); @@ -281,45 +184,56 @@ Type entityType allPropertiesMetadata .Should().HaveSameCount(entityProperties); - metadata.AllPropertiesByPropertyName .Should().BeEquivalentTo(allPropertiesMetadata.ToDictionary(a => a.PropertyName)); - metadata.MappedProperties + metadata.ComputedProperties .Should() - .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsIgnored: false })); + .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsIgnored: false, IsComputed: true })); - metadata.KeyProperties + metadata.ConcurrencyTokenProperties .Should() - .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsIgnored: false, IsKey: true })); + .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsIgnored: false, IsConcurrencyToken: true })); - metadata.ComputedProperties + metadata.DatabaseGeneratedProperties .Should() - .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsIgnored: false, IsComputed: true })); + .BeEquivalentTo( + allPropertiesMetadata.Where(a => !a.IsIgnored && (a.IsComputed || a.IsIdentity || a.IsRowVersion)) + ); metadata.IdentityProperty .Should() .Be(allPropertiesMetadata.FirstOrDefault(a => a is { IsIgnored: false, IsIdentity: true })); - metadata.DatabaseGeneratedProperties - .Should() - .BeEquivalentTo(allPropertiesMetadata.Where(a => !a.IsIgnored && (a.IsComputed || a.IsIdentity))); - metadata.InsertProperties .Should().BeEquivalentTo( allPropertiesMetadata.Where(a => a is - { IsIgnored: false, IsComputed: false, IsIdentity: false } + { IsIgnored: false, IsComputed: false, IsIdentity: false, IsRowVersion: false } ) ); + metadata.KeyProperties + .Should() + .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsIgnored: false, IsKey: true })); + + metadata.MappedProperties + .Should() + .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsIgnored: false })); + + metadata.RowVersionProperties + .Should() + .BeEquivalentTo(allPropertiesMetadata.Where(a => a is { IsRowVersion: true })); + metadata.UpdateProperties .Should().BeEquivalentTo( allPropertiesMetadata.Where(a => a is { + IsComputed: false, + IsConcurrencyToken: false, IsIgnored: false, + IsIdentity: false, IsKey: false, - IsComputed: false, - IsIdentity: false + IsRowVersion: false } ) ); @@ -331,23 +245,14 @@ Type entityType propertyMetadata .Should().NotBeNull(); - propertyMetadata.ColumnName - .Should().Be(property.GetCustomAttribute()?.Name ?? property.Name); - - propertyMetadata.PropertyName - .Should().Be(property.Name); - - propertyMetadata.PropertyType - .Should().Be(property.PropertyType); - - propertyMetadata.PropertyInfo - .Should().BeSameAs(property); + propertyMetadata.CanRead + .Should().Be(property.CanRead); - propertyMetadata.IsIgnored - .Should().Be(property.GetCustomAttribute() is not null); + propertyMetadata.CanWrite + .Should().Be(property.CanWrite); - propertyMetadata.IsKey - .Should().Be(property.GetCustomAttribute() is not null); + propertyMetadata.ColumnName + .Should().Be(property.GetCustomAttribute()?.Name ?? property.Name); propertyMetadata.IsComputed .Should().Be( @@ -355,17 +260,23 @@ Type entityType DatabaseGeneratedOption.Computed ); + propertyMetadata.IsConcurrencyToken + .Should().Be(property.GetCustomAttribute() is not null); + propertyMetadata.IsIdentity .Should().Be( property.GetCustomAttribute()?.DatabaseGeneratedOption is DatabaseGeneratedOption.Identity ); - propertyMetadata.CanRead - .Should().Be(property.CanRead); + propertyMetadata.IsIgnored + .Should().Be(property.GetCustomAttribute() is not null); - propertyMetadata.CanWrite - .Should().Be(property.CanWrite); + propertyMetadata.IsKey + .Should().Be(property.GetCustomAttribute() is not null); + + propertyMetadata.IsRowVersion + .Should().Be(property.GetCustomAttribute() is not null); if (propertyMetadata.CanRead) { @@ -381,6 +292,12 @@ Type entityType .Should().BeNull(); } + propertyMetadata.PropertyInfo + .Should().BeSameAs(property); + + propertyMetadata.PropertyName + .Should().Be(property.Name); + if (propertyMetadata.CanWrite) { propertyMetadata.PropertySetter @@ -400,9 +317,134 @@ Type entityType propertyMetadata.PropertySetter .Should().BeNull(); } + + propertyMetadata.PropertyType + .Should().Be(property.PropertyType); } } + [Fact] + public void GetEntityTypeMetadata_Mapping_FluentApi_ShouldGetMetadataBasedOnFluentApiMapping() + { + MappingTestEntityFluentApi.Configure(); + + var metadata = EntityHelper.GetEntityTypeMetadata(typeof(MappingTestEntityFluentApi)); + + metadata + .Should().NotBeNull(); + + metadata.EntityType + .Should().Be(typeof(MappingTestEntityFluentApi)); + + metadata.TableName + .Should().Be("MappingTestEntity"); + + metadata.AllProperties + .Should().HaveCount(8); + + metadata.AllPropertiesByPropertyName + .Should().BeEquivalentTo(metadata.AllProperties.ToDictionary(a => a.PropertyName)); + + var computedProperty = metadata.AllPropertiesByPropertyName["Computed_"]; + + computedProperty.ColumnName + .Should().Be("Computed"); + + computedProperty.IsComputed + .Should().BeTrue(); + + var concurrencyTokenProperty = metadata.AllPropertiesByPropertyName["ConcurrencyToken_"]; + + concurrencyTokenProperty.ColumnName + .Should().Be("ConcurrencyToken"); + + concurrencyTokenProperty.IsConcurrencyToken + .Should().BeTrue(); + + var identityProperty = metadata.AllPropertiesByPropertyName["Identity_"]; + + identityProperty.ColumnName + .Should().Be("Identity"); + + identityProperty.IsIdentity + .Should().BeTrue(); + + var key1Property = metadata.AllPropertiesByPropertyName["Key1_"]; + + key1Property.ColumnName + .Should().Be("Key1"); + + key1Property.IsKey + .Should().BeTrue(); + + var key2Property = metadata.AllPropertiesByPropertyName["Key2_"]; + + key2Property.ColumnName + .Should().Be("Key2"); + + key2Property.IsKey + .Should().BeTrue(); + + var nameProperty = metadata.AllPropertiesByPropertyName["Value_"]; + + nameProperty.ColumnName + .Should().Be("Value"); + + var notMappedProperty = metadata.AllPropertiesByPropertyName["NotMapped"]; + + notMappedProperty.IsIgnored + .Should().BeTrue(); + + var rowVersionProperty = metadata.AllPropertiesByPropertyName["RowVersion_"]; + + rowVersionProperty.IsRowVersion + .Should().BeTrue(); + + metadata.ComputedProperties + .Should().BeEquivalentTo([computedProperty]); + + metadata.ConcurrencyTokenProperties + .Should().BeEquivalentTo([concurrencyTokenProperty]); + + metadata.DatabaseGeneratedProperties + .Should().BeEquivalentTo([computedProperty, identityProperty, rowVersionProperty]); + + metadata.IdentityProperty + .Should().Be(identityProperty); + + metadata.InsertProperties + .Should().BeEquivalentTo([concurrencyTokenProperty, key1Property, key2Property, nameProperty]); + + metadata.KeyProperties + .Should().BeEquivalentTo([key1Property, key2Property]); + + metadata.MappedProperties + .Should().BeEquivalentTo( + [ + computedProperty, concurrencyTokenProperty, identityProperty, key1Property, key2Property, + nameProperty, rowVersionProperty + ] + ); + + metadata.RowVersionProperties + .Should().BeEquivalentTo( + [rowVersionProperty] + ); + + metadata.UpdateProperties + .Should().BeEquivalentTo([nameProperty]); + } + + [Fact] + public void GetEntityTypeMetadata_MoreThanOneIdentityProperty_ShouldThrow() => + Invoking(() => EntityHelper.GetEntityTypeMetadata(typeof(EntityWithMultipleIdentityProperties))) + .Should().Throw() + .WithMessage( + "There are multiple identity properties defined for the entity type " + + $"{typeof(EntityWithMultipleIdentityProperties)}. Only one property can be marked as an identity " + + "property per entity type." + ); + [Fact] public void ShouldGuardAgainstNullArguments() { diff --git a/tests/DbConnectionPlus.UnitTests/Extensions/ObjectExtensionsTests.cs b/tests/DbConnectionPlus.UnitTests/Extensions/ObjectExtensionsTests.cs index b499614..193f410 100644 --- a/tests/DbConnectionPlus.UnitTests/Extensions/ObjectExtensionsTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Extensions/ObjectExtensionsTests.cs @@ -109,9 +109,9 @@ public void ToDebugString_ShouldReturnStringRepresentationOfValue() new Object().ToDebugString() .Should().Be("'{}' (System.Object)"); - new EntityWithStringProperty { String = "A String" }.ToDebugString() + new EntityWithEnumStoredAsString { Enum = TestEnum.Value3, Id = 1 }.ToDebugString() .Should().Be( - """'{"String":"A String"}' (RentADeveloper.DbConnectionPlus.UnitTests.TestData.EntityWithStringProperty)""" + """'{"Enum":3,"Id":1}' (RentADeveloper.DbConnectionPlus.UnitTests.TestData.EntityWithEnumStoredAsString)""" ); } diff --git a/tests/DbConnectionPlus.UnitTests/Helpers/NameHelperTests.cs b/tests/DbConnectionPlus.UnitTests/Helpers/NameHelperTests.cs index ab709eb..4985951 100644 --- a/tests/DbConnectionPlus.UnitTests/Helpers/NameHelperTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Helpers/NameHelperTests.cs @@ -14,6 +14,7 @@ public class NameHelperTests : UnitTestsBase [InlineData("abc[]-=/<>", 5, "Abc")] [InlineData("this.GetId()", 2, "Id")] [InlineData("[productId]", 10, "ProductId")] + [InlineData("entityIds.Where(a => a > 5).ToArray()[0]", 60, "EntityIdsWhereaa5ToArray0")] [InlineData("", 10, "")] public void CreateNameFromCallerArgumentExpression_ShouldCreateName( String expression, diff --git a/tests/DbConnectionPlus.UnitTests/Materializers/EntityMaterializerFactoryTests.cs b/tests/DbConnectionPlus.UnitTests/Materializers/EntityMaterializerFactoryTests.cs index fc97129..40acbc6 100644 --- a/tests/DbConnectionPlus.UnitTests/Materializers/EntityMaterializerFactoryTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Materializers/EntityMaterializerFactoryTests.cs @@ -33,15 +33,15 @@ public void GetMaterializer_DataReaderFieldTypeNotCompatibleWithEntityPropertyTy dataReader.FieldCount.Returns(1); - dataReader.GetName(0).Returns("Char"); + dataReader.GetName(0).Returns("CharValue"); dataReader.GetFieldType(0).Returns(typeof(Guid)); - Invoking(() => EntityMaterializerFactory.GetMaterializer(dataReader)) + Invoking(() => EntityMaterializerFactory.GetMaterializer(dataReader)) .Should().Throw() .WithMessage( - $"The data type {typeof(Guid)} of the column 'Char' returned by the SQL statement is not compatible " + - $"with the property type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}.*" + $"The data type {typeof(Guid)} of the column 'CharValue' returned by the SQL statement is not " + + $"compatible with the property type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}.*" ); } @@ -64,19 +64,85 @@ public void GetMaterializer_DataReaderHasUnsupportedFieldType_ShouldThrow() dataReader.FieldCount.Returns(1); - dataReader.GetName(0).Returns("Id"); + dataReader.GetName(0).Returns("Value"); dataReader.GetFieldType(0).Returns(typeof(BigInteger)); Invoking(() => - EntityMaterializerFactory.GetMaterializer(dataReader) + EntityMaterializerFactory.GetMaterializer(dataReader) ) .Should().Throw() .WithMessage( - $"The data type {typeof(BigInteger)} of the column 'Id' returned by the SQL statement is not " + + $"The data type {typeof(BigInteger)} of the column 'Value' returned by the SQL statement is not " + "supported.*" ); } + [Fact] + public void + Materializer_CharEntityProperty_DataReaderFieldContainsStringWithLengthNotOne_ShouldThrow() + { + var dataReader = Substitute.For(); + + dataReader.FieldCount.Returns(1); + + dataReader.GetName(0).Returns("CharValue"); + dataReader.GetFieldType(0).Returns(typeof(String)); + dataReader.IsDBNull(0).Returns(false); + dataReader.GetString(0).Returns(String.Empty); + + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); + + Invoking(() => materializer(dataReader)) + .Should().Throw() + .WithMessage( + "The column 'CharValue' returned by the SQL statement contains a value that could not be converted " + + $"to the type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}. See inner exception for details.*" + ) + .WithInnerException() + .WithMessage( + $"Could not convert the string '' to the type {typeof(Char)}. The string must be exactly one " + + "character long." + ); + + dataReader.GetString(0).Returns("ab"); + + Invoking(() => materializer(dataReader)) + .Should().Throw() + .WithMessage( + "The column 'CharValue' returned by the SQL statement contains a value that could not be converted " + + $"to the type {typeof(Char)} of the corresponding property of the type " + + $"{typeof(Entity)}. See inner exception for details.*" + ) + .WithInnerException() + .WithMessage( + $"Could not convert the string 'ab' to the type {typeof(Char)}. The string must be exactly " + + "one character long." + ); + } + + [Fact] + public void + Materializer_CharEntityProperty_DataReaderFieldContainsStringWithLengthOne_ShouldGetFirstCharacter() + { + var dataReader = Substitute.For(); + + dataReader.FieldCount.Returns(1); + + var character = Generate.Single(); + + dataReader.GetName(0).Returns("CharValue"); + dataReader.GetFieldType(0).Returns(typeof(String)); + dataReader.IsDBNull(0).Returns(false); + dataReader.GetString(0).Returns(character.ToString()); + + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); + + var entity = materializer(dataReader); + + entity.CharValue + .Should().Be(character); + } [Fact] public void Materializer_CompatiblePrivateConstructor_ShouldUsePrivateConstructor() @@ -169,7 +235,7 @@ public void Materializer_EntityHasNoCorrespondingPropertyForDataReaderField_Shou var dataReader = Substitute.For(); var id = Generate.Id(); - var value = Generate.Id(); + var value = Generate.Single(); dataReader.FieldCount.Returns(3); @@ -178,10 +244,10 @@ public void Materializer_EntityHasNoCorrespondingPropertyForDataReaderField_Shou dataReader.IsDBNull(0).Returns(false); dataReader.GetInt64(0).Returns(id); - dataReader.GetFieldType(1).Returns(typeof(Int64)); - dataReader.GetName(1).Returns("Value"); + dataReader.GetFieldType(1).Returns(typeof(Int32)); + dataReader.GetName(1).Returns("Int32Value"); dataReader.IsDBNull(1).Returns(false); - dataReader.GetInt64(1).Returns(value); + dataReader.GetInt32(1).Returns(value); dataReader.GetFieldType(2).Returns(typeof(Int32)); dataReader.GetName(2).Returns("NonExistent"); @@ -189,7 +255,7 @@ public void Materializer_EntityHasNoCorrespondingPropertyForDataReaderField_Shou dataReader.GetInt64(2).Returns(Generate.SmallNumber()); var materializer = Invoking(() => - EntityMaterializerFactory.GetMaterializer(dataReader) + EntityMaterializerFactory.GetMaterializer(dataReader) ) .Should().NotThrow().Subject; @@ -198,7 +264,7 @@ public void Materializer_EntityHasNoCorrespondingPropertyForDataReaderField_Shou entity.Id .Should().Be(id); - entity.Value + entity.Int32Value .Should().Be(value); } @@ -216,7 +282,7 @@ public void Materializer_EnumEntityProperty_DataReaderFieldContainsInteger_Shoul dataReader.IsDBNull(0).Returns(false); dataReader.GetInt32(0).Returns((Int32)enumValue); - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); var entity = materializer(dataReader); @@ -237,14 +303,14 @@ public void dataReader.IsDBNull(0).Returns(false); dataReader.GetInt32(0).Returns(999); - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); Invoking(() => materializer(dataReader)) .Should().Throw() .WithMessage( "The column 'Enum' returned by the SQL statement contains a value that could not be converted to " + $"the type {typeof(TestEnum)} of the corresponding property of the type " + - $"{typeof(EntityWithEnumProperty)}. See inner exception for details.*" + $"{typeof(EntityWithEnumStoredAsInteger)}. See inner exception for details.*" ) .WithInnerException() .WithMessage( @@ -267,7 +333,7 @@ public void Materializer_EnumEntityProperty_DataReaderFieldContainsString_Should dataReader.IsDBNull(0).Returns(false); dataReader.GetString(0).Returns(enumValue.ToString()); - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); var entity = materializer(dataReader); @@ -287,14 +353,14 @@ public void Materializer_EnumEntityProperty_DataReaderFieldContainsStringNotMatc dataReader.IsDBNull(0).Returns(false); dataReader.GetString(0).Returns("NonExistent"); - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); Invoking(() => materializer(dataReader)) .Should().Throw() .WithMessage( "The column 'Enum' returned by the SQL statement contains a value that could not be converted to " + $"the type {typeof(TestEnum)} of the corresponding property of the type " + - $"{typeof(EntityWithEnumProperty)}. See inner exception for details.*" + $"{typeof(EntityWithEnumStoredAsString)}. See inner exception for details.*" ) .WithInnerException() .WithMessage( @@ -310,43 +376,55 @@ public void Materializer_Mapping_Attributes_ShouldUseAttributesMapping() var dataReader = Substitute.For(); - dataReader.FieldCount.Returns(6); + dataReader.FieldCount.Returns(8); var ordinal = 0; - dataReader.GetName(ordinal).Returns("KeyColumn1"); - dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); + dataReader.GetName(ordinal).Returns("Computed"); + dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt64(ordinal).Returns(entity.KeyColumn1_); + dataReader.GetInt32(ordinal).Returns(entity.Computed_); ordinal++; - dataReader.GetName(ordinal).Returns("KeyColumn2"); - dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); + dataReader.GetName(ordinal).Returns("ConcurrencyToken"); + dataReader.GetFieldType(ordinal).Returns(typeof(Byte[])); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt64(ordinal).Returns(entity.KeyColumn2_); + dataReader.GetValue(ordinal).Returns(entity.ConcurrencyToken_); ordinal++; - dataReader.GetName(ordinal).Returns("ValueColumn"); + dataReader.GetName(ordinal).Returns("Identity"); dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt32(ordinal).Returns(entity.ValueColumn_); + dataReader.GetInt32(ordinal).Returns(entity.Identity_); ordinal++; - dataReader.GetName(ordinal).Returns("ComputedColumn"); - dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); + dataReader.GetName(ordinal).Returns("Key1"); + dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); + dataReader.IsDBNull(ordinal).Returns(false); + dataReader.GetInt64(ordinal).Returns(entity.Key1_); + + ordinal++; + dataReader.GetName(ordinal).Returns("Key2"); + dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt32(ordinal).Returns(entity.ComputedColumn_); + dataReader.GetInt64(ordinal).Returns(entity.Key2_); ordinal++; - dataReader.GetName(ordinal).Returns("IdentityColumn"); + dataReader.GetName(ordinal).Returns("Value"); dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt32(ordinal).Returns(entity.IdentityColumn_); + dataReader.GetInt32(ordinal).Returns(entity.Value_); ordinal++; var notMappedColumnOrdinal = ordinal; - dataReader.GetName(notMappedColumnOrdinal).Returns("NotMappedColumn"); + dataReader.GetName(notMappedColumnOrdinal).Returns("NotMapped"); dataReader.GetFieldType(notMappedColumnOrdinal).Returns(typeof(String)); + ordinal++; + dataReader.GetName(ordinal).Returns("RowVersion"); + dataReader.GetFieldType(ordinal).Returns(typeof(Byte[])); + dataReader.IsDBNull(ordinal).Returns(false); + dataReader.GetValue(ordinal).Returns(entity.RowVersion_); + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); var materializedEntity = materializer(dataReader); @@ -354,23 +432,29 @@ public void Materializer_Mapping_Attributes_ShouldUseAttributesMapping() _ = dataReader.DidNotReceive().IsDBNull(notMappedColumnOrdinal); _ = dataReader.DidNotReceive().GetString(notMappedColumnOrdinal); - materializedEntity.KeyColumn1_ - .Should().Be(entity.KeyColumn1_); + materializedEntity.Computed_ + .Should().Be(entity.Computed_); + + materializedEntity.ConcurrencyToken_ + .Should().BeEquivalentTo(entity.ConcurrencyToken_); - materializedEntity.KeyColumn2_ - .Should().Be(entity.KeyColumn2_); + materializedEntity.Identity_ + .Should().Be(entity.Identity_); - materializedEntity.ValueColumn_ - .Should().Be(entity.ValueColumn_); + materializedEntity.Key1_ + .Should().Be(entity.Key1_); - materializedEntity.ComputedColumn_ - .Should().Be(entity.ComputedColumn_); + materializedEntity.Key2_ + .Should().Be(entity.Key2_); - materializedEntity.IdentityColumn_ - .Should().Be(entity.IdentityColumn_); + materializedEntity.Value_ + .Should().Be(entity.Value_); - materializedEntity.NotMappedColumn + materializedEntity.NotMapped .Should().BeNull(); + + materializedEntity.RowVersion_ + .Should().BeEquivalentTo(entity.RowVersion_); } [Fact] @@ -382,43 +466,55 @@ public void Materializer_Mapping_FluentApi_ShouldUseFluentApiMapping() var dataReader = Substitute.For(); - dataReader.FieldCount.Returns(6); + dataReader.FieldCount.Returns(8); var ordinal = 0; - dataReader.GetName(ordinal).Returns("KeyColumn1"); - dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); + dataReader.GetName(ordinal).Returns("Computed"); + dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt64(ordinal).Returns(entity.KeyColumn1_); + dataReader.GetInt32(ordinal).Returns(entity.Computed_); ordinal++; - dataReader.GetName(ordinal).Returns("KeyColumn2"); - dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); + dataReader.GetName(ordinal).Returns("ConcurrencyToken"); + dataReader.GetFieldType(ordinal).Returns(typeof(Byte[])); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt64(ordinal).Returns(entity.KeyColumn2_); + dataReader.GetValue(ordinal).Returns(entity.ConcurrencyToken_); ordinal++; - dataReader.GetName(ordinal).Returns("ValueColumn"); + dataReader.GetName(ordinal).Returns("Identity"); dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt32(ordinal).Returns(entity.ValueColumn_); + dataReader.GetInt32(ordinal).Returns(entity.Identity_); ordinal++; - dataReader.GetName(ordinal).Returns("ComputedColumn"); - dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); + dataReader.GetName(ordinal).Returns("Key1"); + dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt32(ordinal).Returns(entity.ComputedColumn_); + dataReader.GetInt64(ordinal).Returns(entity.Key1_); ordinal++; - dataReader.GetName(ordinal).Returns("IdentityColumn"); + dataReader.GetName(ordinal).Returns("Key2"); + dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); + dataReader.IsDBNull(ordinal).Returns(false); + dataReader.GetInt64(ordinal).Returns(entity.Key2_); + + ordinal++; + dataReader.GetName(ordinal).Returns("Value"); dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt32(ordinal).Returns(entity.IdentityColumn_); + dataReader.GetInt32(ordinal).Returns(entity.Value_); ordinal++; var notMappedColumnOrdinal = ordinal; - dataReader.GetName(notMappedColumnOrdinal).Returns("NotMappedColumn"); + dataReader.GetName(notMappedColumnOrdinal).Returns("NotMapped"); dataReader.GetFieldType(notMappedColumnOrdinal).Returns(typeof(String)); + ordinal++; + dataReader.GetName(ordinal).Returns("RowVersion"); + dataReader.GetFieldType(ordinal).Returns(typeof(Byte[])); + dataReader.IsDBNull(ordinal).Returns(false); + dataReader.GetValue(ordinal).Returns(entity.RowVersion_); + var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); var materializedEntity = materializer(dataReader); @@ -426,23 +522,29 @@ public void Materializer_Mapping_FluentApi_ShouldUseFluentApiMapping() _ = dataReader.DidNotReceive().IsDBNull(notMappedColumnOrdinal); _ = dataReader.DidNotReceive().GetString(notMappedColumnOrdinal); - materializedEntity.KeyColumn1_ - .Should().Be(entity.KeyColumn1_); + materializedEntity.Computed_ + .Should().Be(entity.Computed_); - materializedEntity.KeyColumn2_ - .Should().Be(entity.KeyColumn2_); + materializedEntity.ConcurrencyToken_ + .Should().BeEquivalentTo(entity.ConcurrencyToken_); - materializedEntity.ValueColumn_ - .Should().Be(entity.ValueColumn_); + materializedEntity.Identity_ + .Should().Be(entity.Identity_); - materializedEntity.ComputedColumn_ - .Should().Be(entity.ComputedColumn_); + materializedEntity.Key1_ + .Should().Be(entity.Key1_); - materializedEntity.IdentityColumn_ - .Should().Be(entity.IdentityColumn_); + materializedEntity.Key2_ + .Should().Be(entity.Key2_); - materializedEntity.NotMappedColumn + materializedEntity.Value_ + .Should().Be(entity.Value_); + + materializedEntity.NotMapped .Should().BeNull(); + + materializedEntity.RowVersion_ + .Should().BeEquivalentTo(entity.RowVersion_); } [Fact] @@ -455,35 +557,35 @@ public void Materializer_Mapping_NoMapping_ShouldUseEntityTypeNameAndPropertyNam dataReader.FieldCount.Returns(3); var ordinal = 0; - dataReader.GetName(ordinal).Returns("KeyColumn1"); + dataReader.GetName(ordinal).Returns("Key1"); dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt64(ordinal).Returns(entity.KeyColumn1); + dataReader.GetInt64(ordinal).Returns(entity.Key1); ordinal++; - dataReader.GetName(ordinal).Returns("KeyColumn2"); + dataReader.GetName(ordinal).Returns("Key2"); dataReader.GetFieldType(ordinal).Returns(typeof(Int64)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt64(ordinal).Returns(entity.KeyColumn2); + dataReader.GetInt64(ordinal).Returns(entity.Key2); ordinal++; - dataReader.GetName(ordinal).Returns("ValueColumn"); + dataReader.GetName(ordinal).Returns("Value"); dataReader.GetFieldType(ordinal).Returns(typeof(Int32)); dataReader.IsDBNull(ordinal).Returns(false); - dataReader.GetInt32(ordinal).Returns(entity.ValueColumn); + dataReader.GetInt32(ordinal).Returns(entity.Value); var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); var materializedEntity = materializer(dataReader); - materializedEntity.KeyColumn1 - .Should().Be(entity.KeyColumn1); + materializedEntity.Key1 + .Should().Be(entity.Key1); - materializedEntity.KeyColumn2 - .Should().Be(entity.KeyColumn2); + materializedEntity.Key2 + .Should().Be(entity.Key2); - materializedEntity.ValueColumn - .Should().Be(entity.ValueColumn); + materializedEntity.Value + .Should().Be(entity.Value); } [Fact] @@ -544,73 +646,6 @@ public void .Should().BeEquivalentTo(entities[0]); } - [Fact] - public void - Materializer_NonNullableCharEntityProperty_DataReaderFieldContainsStringWithLengthNotOne_ShouldThrow() - { - var dataReader = Substitute.For(); - - dataReader.FieldCount.Returns(1); - - dataReader.GetName(0).Returns("Char"); - dataReader.GetFieldType(0).Returns(typeof(String)); - dataReader.IsDBNull(0).Returns(false); - dataReader.GetString(0).Returns(String.Empty); - - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); - - Invoking(() => materializer(dataReader)) - .Should().Throw() - .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted to " + - $"the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" - ) - .WithInnerException() - .WithMessage( - $"Could not convert the string '' to the type {typeof(Char)}. The string must be exactly one " + - "character long." - ); - - dataReader.GetString(0).Returns("ab"); - - Invoking(() => materializer(dataReader)) - .Should().Throw() - .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted to " + - $"the type {typeof(Char)} of the corresponding property of the type " + - $"{typeof(EntityWithCharProperty)}. See inner exception for details.*" - ) - .WithInnerException() - .WithMessage( - $"Could not convert the string 'ab' to the type {typeof(Char)}. The string must be exactly " + - "one character long." - ); - } - - [Fact] - public void - Materializer_NonNullableCharEntityProperty_DataReaderFieldContainsStringWithLengthOne_ShouldGetFirstCharacter() - { - var dataReader = Substitute.For(); - - dataReader.FieldCount.Returns(1); - - var character = Generate.Single(); - - dataReader.GetName(0).Returns("Char"); - dataReader.GetFieldType(0).Returns(typeof(String)); - dataReader.IsDBNull(0).Returns(false); - dataReader.GetString(0).Returns(character.ToString()); - - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); - - var entity = materializer(dataReader); - - entity.Char - .Should().Be(character); - } - [Fact] public void Materializer_NonNullableEntityProperty_DataReaderFieldContainsNull_ShouldThrow() @@ -633,74 +668,6 @@ public void ); } - [Fact] - public void - Materializer_NullableCharEntityProperty_DataReaderFieldContainsStringWithLengthNotOne_ShouldThrow() - { - var dataReader = Substitute.For(); - - dataReader.FieldCount.Returns(1); - - dataReader.GetName(0).Returns("Char"); - dataReader.GetFieldType(0).Returns(typeof(String)); - dataReader.IsDBNull(0).Returns(false); - dataReader.GetString(0).Returns(String.Empty); - - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); - - Invoking(() => materializer(dataReader)) - .Should().Throw() - .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted to " + - $"the type {typeof(Char?)} of the corresponding property of the type " + - $"{typeof(EntityWithNullableCharProperty)}. See inner exception for details.*" - ) - .WithInnerException() - .WithMessage( - $"Could not convert the string '' to the type {typeof(Char?)}. " + - "The string must be exactly one character long." - ); - - dataReader.GetString(0).Returns("ab"); - - Invoking(() => materializer(dataReader)) - .Should().Throw() - .WithMessage( - "The column 'Char' returned by the SQL statement contains a value that could not be converted to " + - $"the type {typeof(Char?)} of the corresponding property of the type " + - $"{typeof(EntityWithNullableCharProperty)}. See inner exception for details.*" - ) - .WithInnerException() - .WithMessage( - $"Could not convert the string 'ab' to the type {typeof(Char?)}. " + - "The string must be exactly one character long." - ); - } - - [Fact] - public void - Materializer_NullableCharEntityProperty_DataReaderFieldContainsStringWithLengthOne_ShouldGetFirstCharacter() - { - var dataReader = Substitute.For(); - - dataReader.FieldCount.Returns(1); - - var character = Generate.Single(); - - dataReader.GetName(0).Returns("Char"); - dataReader.GetFieldType(0).Returns(typeof(String)); - dataReader.IsDBNull(0).Returns(false); - dataReader.GetString(0).Returns(character.ToString()); - - var materializer = EntityMaterializerFactory - .GetMaterializer(dataReader); - - var entity = materializer(dataReader); - - entity.Char - .Should().Be(character); - } - [Fact] public void Materializer_NullableEntityProperty_DataReaderFieldContainsNull_ShouldMaterializeNull() { @@ -708,18 +675,18 @@ public void Materializer_NullableEntityProperty_DataReaderFieldContainsNull_Shou dataReader.FieldCount.Returns(1); - dataReader.GetName(0).Returns("Value"); - dataReader.GetFieldType(0).Returns(typeof(Int32)); + dataReader.GetName(0).Returns("NullableBooleanValue"); + dataReader.GetFieldType(0).Returns(typeof(Boolean)); dataReader.IsDBNull(0).Returns(true); - dataReader.GetInt32(0).Throws(new SqlNullValueException()); + dataReader.GetBoolean(0).Throws(new SqlNullValueException()); var materializer = EntityMaterializerFactory - .GetMaterializer(dataReader); + .GetMaterializer(dataReader); var entity = Invoking(() => materializer(dataReader)) .Should().NotThrow().Subject; - entity.Value + entity.NullableBooleanValue .Should().BeNull(); } @@ -741,28 +708,6 @@ public void Materializer_PropertiesWithDifferentCasing_ShouldMatchPropertiesCase .Should().BeEquivalentTo(entityWithDifferentCasingProperties); } - [Fact] - public void Materializer_ShouldMaterializeBinaryData() - { - var dataReader = Substitute.For(); - - dataReader.FieldCount.Returns(1); - - var bytes = Generate.Single(); - - dataReader.GetName(0).Returns("BinaryData"); - dataReader.GetFieldType(0).Returns(typeof(Byte[])); - dataReader.IsDBNull(0).Returns(false); - dataReader.GetValue(0).Returns(bytes); - - var materializer = EntityMaterializerFactory.GetMaterializer(dataReader); - - var entity = materializer(dataReader); - - entity.BinaryData - .Should().BeEquivalentTo(bytes); - } - [Fact] public void Materializer_ShouldMaterializeDateTimeOffsetValue() { diff --git a/tests/DbConnectionPlus.UnitTests/Mocks/MockDbParameterCollection.cs b/tests/DbConnectionPlus.UnitTests/Mocks/MockDbParameterCollection.cs new file mode 100644 index 0000000..a0f5c0f --- /dev/null +++ b/tests/DbConnectionPlus.UnitTests/Mocks/MockDbParameterCollection.cs @@ -0,0 +1,91 @@ +namespace RentADeveloper.DbConnectionPlus.UnitTests.Mocks; + +/// +/// A simple mock implementation of . +/// +public class MockDbParameterCollection : DbParameterCollection +{ + /// + public override Int32 Count => this.parameters.Count; + + /// + public override Object SyncRoot => ((ICollection)this.parameters).SyncRoot; + + /// + public override Int32 Add(Object value) + { + this.parameters.Add((DbParameter)value); + return this.Count - 1; + } + + /// + public override void AddRange(Array values) => this.parameters.AddRange(values.Cast()); + + /// + public override void Clear() => this.parameters.Clear(); + + /// + public override Boolean Contains(Object value) => this.parameters.Contains(value); + + /// + public override Boolean Contains(String value) => this.IndexOf(value) != -1; + + /// + public override void CopyTo(Array array, Int32 index) => + this.parameters.CopyTo((DbParameter[])array, index); + + /// + public override IEnumerator GetEnumerator() => this.parameters.GetEnumerator(); + + /// + public override Int32 IndexOf(Object value) => this.parameters.IndexOf((DbParameter)value); + + /// + public override Int32 IndexOf(String parameterName) + { + for (var index = 0; index < this.parameters.Count; ++index) + { + if (this.parameters[index].ParameterName == parameterName) + return index; + } + + return -1; + } + + /// + public override void Insert(Int32 index, Object value) => + this.parameters.Insert(index, (DbParameter)value); + + /// + public override void Remove(Object value) => this.parameters.Remove((DbParameter)value); + + /// + public override void RemoveAt(Int32 index) => this.parameters.RemoveAt(index); + + /// + public override void RemoveAt(String parameterName) => + this.RemoveAt(this.IndexOfChecked(parameterName)); + + /// + protected override DbParameter GetParameter(Int32 index) => this.parameters[index]; + + /// + protected override DbParameter GetParameter(String parameterName) => + this.GetParameter(this.IndexOfChecked(parameterName)); + + /// + protected override void SetParameter(Int32 index, DbParameter value) => + this.parameters[index] = value; + + /// + protected override void SetParameter(String parameterName, DbParameter value) => + this.SetParameter(this.IndexOfChecked(parameterName), value); + + private Int32 IndexOfChecked(String parameterName) + { + var index = this.IndexOf(parameterName); + return index != -1 ? index : throw new IndexOutOfRangeException(); + } + + private readonly List parameters = []; +} diff --git a/tests/DbConnectionPlus.UnitTests/PublicApiTest.PublicApiHasNotChanged.verified.txt b/tests/DbConnectionPlus.UnitTests/PublicApiTest.PublicApiHasNotChanged.verified.txt index 08c9ac6..f551f46 100644 --- a/tests/DbConnectionPlus.UnitTests/PublicApiTest.PublicApiHasNotChanged.verified.txt +++ b/tests/DbConnectionPlus.UnitTests/PublicApiTest.PublicApiHasNotChanged.verified.txt @@ -14,9 +14,11 @@ namespace RentADeveloper.DbConnectionPlus.Configuration { public RentADeveloper.DbConnectionPlus.Configuration.EntityPropertyBuilder HasColumnName(string columnName) { } public RentADeveloper.DbConnectionPlus.Configuration.EntityPropertyBuilder IsComputed() { } + public RentADeveloper.DbConnectionPlus.Configuration.EntityPropertyBuilder IsConcurrencyToken() { } public RentADeveloper.DbConnectionPlus.Configuration.EntityPropertyBuilder IsIdentity() { } public RentADeveloper.DbConnectionPlus.Configuration.EntityPropertyBuilder IsIgnored() { } public RentADeveloper.DbConnectionPlus.Configuration.EntityPropertyBuilder IsKey() { } + public RentADeveloper.DbConnectionPlus.Configuration.EntityPropertyBuilder IsRowVersion() { } } public sealed class EntityTypeBuilder : RentADeveloper.DbConnectionPlus.Configuration.IFreezable { @@ -76,13 +78,6 @@ namespace RentADeveloper.DbConnectionPlus.DatabaseAdapters public System.Threading.Tasks.ValueTask DisposeAsync() { } } } -namespace RentADeveloper.DbConnectionPlus.DbCommands -{ - public interface IDbCommandFactory - { - System.Data.Common.DbCommand CreateDbCommand(System.Data.Common.DbConnection connection, string commandText, System.Data.Common.DbTransaction? transaction = null, System.TimeSpan? commandTimeout = default, System.Data.CommandType commandType = 1); - } -} namespace RentADeveloper.DbConnectionPlus { public static class DbConnectionExtensions @@ -155,6 +150,8 @@ namespace RentADeveloper.DbConnectionPlus [System.Diagnostics.CodeAnalysis.DoesNotReturn] public static void ThrowDatabaseAdapterDoesNotSupportTemporaryTablesException(RentADeveloper.DbConnectionPlus.DatabaseAdapters.IDatabaseAdapter databaseAdapter) { } [System.Diagnostics.CodeAnalysis.DoesNotReturn] + public static void ThrowDatabaseOperationAffectedUnexpectedNumberOfRowsException(int expectedNumberOfAffectedRows, int actualNumberOfAffectedRows, object entity) { } + [System.Diagnostics.CodeAnalysis.DoesNotReturn] public static void ThrowEntityTypeHasNoKeyPropertyException(System.Type entityType) { } [System.Diagnostics.CodeAnalysis.DoesNotReturn] public static T ThrowInvalidEnumSerializationModeException(RentADeveloper.DbConnectionPlus.EnumSerializationMode enumSerializationMode) { } @@ -207,14 +204,16 @@ namespace RentADeveloper.DbConnectionPlus.Entities } public sealed record EntityPropertyMetadata : System.IEquatable { - public EntityPropertyMetadata(string ColumnName, string PropertyName, System.Type PropertyType, System.Reflection.PropertyInfo PropertyInfo, bool IsIgnored, bool IsKey, bool IsComputed, bool IsIdentity, bool CanRead, bool CanWrite, Fasterflect.MemberGetter? PropertyGetter, Fasterflect.MemberSetter? PropertySetter) { } + public EntityPropertyMetadata(bool CanRead, bool CanWrite, string ColumnName, bool IsComputed, bool IsConcurrencyToken, bool IsIdentity, bool IsIgnored, bool IsKey, bool IsRowVersion, Fasterflect.MemberGetter? PropertyGetter, System.Reflection.PropertyInfo PropertyInfo, string PropertyName, Fasterflect.MemberSetter? PropertySetter, System.Type PropertyType) { } public bool CanRead { get; init; } public bool CanWrite { get; init; } public string ColumnName { get; init; } public bool IsComputed { get; init; } + public bool IsConcurrencyToken { get; init; } public bool IsIdentity { get; init; } public bool IsIgnored { get; init; } public bool IsKey { get; init; } + public bool IsRowVersion { get; init; } public Fasterflect.MemberGetter? PropertyGetter { get; init; } public System.Reflection.PropertyInfo PropertyInfo { get; init; } public string PropertyName { get; init; } @@ -223,23 +222,36 @@ namespace RentADeveloper.DbConnectionPlus.Entities } public sealed record EntityTypeMetadata : System.IEquatable { - public EntityTypeMetadata(System.Type EntityType, string TableName, System.Collections.Generic.IReadOnlyList AllProperties, System.Collections.Generic.IReadOnlyDictionary AllPropertiesByPropertyName, System.Collections.Generic.IReadOnlyList MappedProperties, System.Collections.Generic.IReadOnlyList KeyProperties, System.Collections.Generic.IReadOnlyList ComputedProperties, RentADeveloper.DbConnectionPlus.Entities.EntityPropertyMetadata? IdentityProperty, System.Collections.Generic.IReadOnlyList DatabaseGeneratedProperties, System.Collections.Generic.IReadOnlyList InsertProperties, System.Collections.Generic.IReadOnlyList UpdateProperties) { } + public EntityTypeMetadata(System.Type EntityType, string TableName, System.Collections.Generic.IReadOnlyList AllProperties, System.Collections.Generic.IReadOnlyDictionary AllPropertiesByPropertyName, System.Collections.Generic.IReadOnlyList ComputedProperties, System.Collections.Generic.IReadOnlyList ConcurrencyTokenProperties, System.Collections.Generic.IReadOnlyList DatabaseGeneratedProperties, RentADeveloper.DbConnectionPlus.Entities.EntityPropertyMetadata? IdentityProperty, System.Collections.Generic.IReadOnlyList InsertProperties, System.Collections.Generic.IReadOnlyList KeyProperties, System.Collections.Generic.IReadOnlyList MappedProperties, System.Collections.Generic.IReadOnlyList RowVersionProperties, System.Collections.Generic.IReadOnlyList UpdateProperties) { } public System.Collections.Generic.IReadOnlyList AllProperties { get; init; } public System.Collections.Generic.IReadOnlyDictionary AllPropertiesByPropertyName { get; init; } public System.Collections.Generic.IReadOnlyList ComputedProperties { get; init; } + public System.Collections.Generic.IReadOnlyList ConcurrencyTokenProperties { get; init; } public System.Collections.Generic.IReadOnlyList DatabaseGeneratedProperties { get; init; } public System.Type EntityType { get; init; } public RentADeveloper.DbConnectionPlus.Entities.EntityPropertyMetadata? IdentityProperty { get; init; } public System.Collections.Generic.IReadOnlyList InsertProperties { get; init; } public System.Collections.Generic.IReadOnlyList KeyProperties { get; init; } public System.Collections.Generic.IReadOnlyList MappedProperties { get; init; } + public System.Collections.Generic.IReadOnlyList RowVersionProperties { get; init; } public string TableName { get; init; } public System.Collections.Generic.IReadOnlyList UpdateProperties { get; init; } } } +namespace RentADeveloper.DbConnectionPlus.Exceptions +{ + public class DbUpdateConcurrencyException : System.Exception + { + public DbUpdateConcurrencyException() { } + public DbUpdateConcurrencyException(string message) { } + public DbUpdateConcurrencyException(string message, System.Exception innerException) { } + public DbUpdateConcurrencyException(string message, object entity) { } + public object? Entity { get; set; } + } +} namespace RentADeveloper.DbConnectionPlus.SqlStatements { - public readonly struct InterpolatedParameter : System.IEquatable + public record InterpolatedParameter : System.IEquatable { public InterpolatedParameter(string? InferredName, object? Value) { } public string? InferredName { get; init; } @@ -264,7 +276,7 @@ namespace RentADeveloper.DbConnectionPlus.SqlStatements public static bool operator !=(RentADeveloper.DbConnectionPlus.SqlStatements.InterpolatedSqlStatement left, RentADeveloper.DbConnectionPlus.SqlStatements.InterpolatedSqlStatement right) { } public static bool operator ==(RentADeveloper.DbConnectionPlus.SqlStatements.InterpolatedSqlStatement left, RentADeveloper.DbConnectionPlus.SqlStatements.InterpolatedSqlStatement right) { } } - public readonly struct InterpolatedTemporaryTable : System.IEquatable + public record InterpolatedTemporaryTable : System.IEquatable { public InterpolatedTemporaryTable(string Name, System.Collections.IEnumerable Values, System.Type ValuesType) { } public string Name { get; init; } diff --git a/tests/DbConnectionPlus.UnitTests/Readers/DisposeSignalingDataReaderDecoratorTests.cs b/tests/DbConnectionPlus.UnitTests/Readers/CommandDisposingDataReaderDecoratorTests.cs similarity index 61% rename from tests/DbConnectionPlus.UnitTests/Readers/DisposeSignalingDataReaderDecoratorTests.cs rename to tests/DbConnectionPlus.UnitTests/Readers/CommandDisposingDataReaderDecoratorTests.cs index 608c92a..0ed80bc 100644 --- a/tests/DbConnectionPlus.UnitTests/Readers/DisposeSignalingDataReaderDecoratorTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Readers/CommandDisposingDataReaderDecoratorTests.cs @@ -1,39 +1,47 @@ +#pragma warning disable NS1001 + using AutoFixture; using AutoFixture.AutoNSubstitute; +using RentADeveloper.DbConnectionPlus.DatabaseAdapters; +using RentADeveloper.DbConnectionPlus.DbCommands; using RentADeveloper.DbConnectionPlus.Readers; using RentADeveloper.DbConnectionPlus.UnitTests.Assertions; namespace RentADeveloper.DbConnectionPlus.UnitTests.Readers; -public class DisposeSignalingDataReaderDecoratorTests : UnitTestsBase +public class CommandDisposingDataReaderDecoratorTests : UnitTestsBase { /// - public DisposeSignalingDataReaderDecoratorTests() + public CommandDisposingDataReaderDecoratorTests() { this.decoratedReader = Substitute.For(); - this.decorator = new(this.decoratedReader, this.MockDatabaseAdapter, CancellationToken.None); + this.commandDisposer = Substitute.For( + Substitute.For(), + Array.Empty(), + default(CancellationTokenRegistration) + ); + this.decorator = new( + this.decoratedReader, + this.MockDatabaseAdapter, + this.commandDisposer, + CancellationToken.None + ); } [Fact] - public void Dispose_ShouldInvokeOnDisposingFunction() + public void Dispose_ShouldDisposeCommandDisposer() { - var onDisposingFunction = Substitute.For(); - this.decorator.OnDisposing = onDisposingFunction; - this.decorator.Dispose(); - onDisposingFunction.Received()(); + this.commandDisposer.Received().Dispose(); } [Fact] - public async Task DisposeAsync_ShouldInvokeOnDisposingAsyncFunction() + public async Task DisposeAsync_ShouldDisposeCommandDisposer() { - var onDisposingAsyncFunction = Substitute.For>(); - this.decorator.OnDisposingAsync = onDisposingAsyncFunction; - await this.decorator.DisposeAsync(); - await onDisposingAsyncFunction.Received()(); + await this.commandDisposer.Received().DisposeAsync(); } [Fact] @@ -70,11 +78,11 @@ public void ShouldForwardAllMethodCallsToDecoratedReader() { var exceptions = new HashSet { - nameof(DisposeSignalingDataReaderDecorator.Dispose), - nameof(DisposeSignalingDataReaderDecorator.DisposeAsync), - nameof(DisposeSignalingDataReaderDecorator.GetData), - nameof(DisposeSignalingDataReaderDecorator.GetFieldValue), - nameof(DisposeSignalingDataReaderDecorator.GetFieldValueAsync) + nameof(CommandDisposingDataReaderDecorator.Dispose), + nameof(CommandDisposingDataReaderDecorator.DisposeAsync), + nameof(CommandDisposingDataReaderDecorator.GetData), + nameof(CommandDisposingDataReaderDecorator.GetFieldValue), + nameof(CommandDisposingDataReaderDecorator.GetFieldValueAsync) }; var fixture = new Fixture(); @@ -92,13 +100,16 @@ public void ShouldForwardAllMethodCallsToDecoratedReader() [Fact] public void ShouldGuardAgainstNullArguments() => ArgumentNullGuardVerifier.Verify(() => - new DisposeSignalingDataReaderDecorator( + new CommandDisposingDataReaderDecorator( this.decoratedReader, this.MockDatabaseAdapter, + this.commandDisposer, CancellationToken.None ) ); + private readonly DbCommandDisposer commandDisposer; + private readonly DbDataReader decoratedReader; - private readonly DisposeSignalingDataReaderDecorator decorator; + private readonly CommandDisposingDataReaderDecorator decorator; } diff --git a/tests/DbConnectionPlus.UnitTests/Readers/EnumHandlingObjectReaderTests.cs b/tests/DbConnectionPlus.UnitTests/Readers/EnumHandlingObjectReaderTests.cs index 7e7d49e..ad86432 100644 --- a/tests/DbConnectionPlus.UnitTests/Readers/EnumHandlingObjectReaderTests.cs +++ b/tests/DbConnectionPlus.UnitTests/Readers/EnumHandlingObjectReaderTests.cs @@ -7,11 +7,11 @@ public class EnumHandlingObjectReaderTests : UnitTestsBase [Fact] public void GetFieldType_CharProperty_ShouldReturnString() { - EntityWithCharProperty[] entities = [new()]; + Entity[] entities = [new()]; - var reader = new EnumHandlingObjectReader(typeof(EntityWithCharProperty), entities); + var reader = new EnumHandlingObjectReader(typeof(Entity), entities); - reader.GetFieldType(0) + reader.GetFieldType(reader.GetOrdinal("CharValue")) .Should().Be(typeof(String)); } @@ -61,14 +61,14 @@ public void GetInt32_EnumValues_ShouldReturnEnumAsInt32() [Fact] public void GetString_CharProperty_ShouldConvertToString() { - EntityWithCharProperty[] entities = [new() { Char = Generate.Single() }]; + Entity[] entities = [new() { CharValue = Generate.Single() }]; - var reader = new EnumHandlingObjectReader(typeof(EntityWithCharProperty), entities); + var reader = new EnumHandlingObjectReader(typeof(Entity), entities); reader.Read(); - reader.GetString(0) - .Should().Be(entities[0].Char.ToString()); + reader.GetString(reader.GetOrdinal("CharValue")) + .Should().Be(entities[0].CharValue.ToString()); } [Fact] @@ -91,18 +91,18 @@ public void GetString_EnumValues_ShouldReturnEnumAsString() [Fact] public void GetValues_CharProperty_ShouldConvertToString() { - EntityWithCharProperty[] entities = [new() { Char = Generate.Single() }]; + Entity[] entities = [new() { CharValue = Generate.Single() }]; - var reader = new EnumHandlingObjectReader(typeof(EntityWithCharProperty), entities); + var reader = new EnumHandlingObjectReader(typeof(Entity), entities); reader.Read(); - var values = new Object[1]; + var values = new Object[18]; reader.GetValues(values); - values[0] - .Should().Be(entities[0].Char.ToString()); + values[reader.GetOrdinal("CharValue")] + .Should().Be(entities[0].CharValue.ToString()); } [Fact] diff --git a/tests/DbConnectionPlus.UnitTests/SqlStatements/InterpolatedSqlStatementTests.cs b/tests/DbConnectionPlus.UnitTests/SqlStatements/InterpolatedSqlStatementTests.cs index 1477025..967d9fe 100644 --- a/tests/DbConnectionPlus.UnitTests/SqlStatements/InterpolatedSqlStatementTests.cs +++ b/tests/DbConnectionPlus.UnitTests/SqlStatements/InterpolatedSqlStatementTests.cs @@ -30,7 +30,7 @@ public void AppendFormatted_InterpolatedParameter_ShouldSupportComplexExpression InterpolatedSqlStatement statement = $""" SELECT {Parameter(baseDiscount * 5 / 3)}, - {Parameter(entityIds.Where(a => a > 5).Select(a => a.ToString()).ToArray()[0])} + {Parameter(entityIds.Where(a => a > 5).ToArray()[0])} """; statement.Fragments @@ -46,10 +46,10 @@ public void AppendFormatted_InterpolatedParameter_ShouldSupportComplexExpression .Should().Be(new Literal($",{Environment.NewLine} ")); statement.Fragments[3] - .Should().Be( + .Should().BeEquivalentTo( new InterpolatedParameter( - "EntityIdsWhereaa5SelectaaToStringToArray0", - entityIds.Where(a => a > 5).Select(a => a.ToString()).ToArray()[0] + "EntityIdsWhereaa5ToArray0", + entityIds.Where(a => a > 5).ToArray()[0] ) ); } diff --git a/tests/DbConnectionPlus.UnitTests/StatementMethodTestsBase.cs b/tests/DbConnectionPlus.UnitTests/StatementMethodTestsBase.cs index 8c2fcd0..d83d1a0 100644 --- a/tests/DbConnectionPlus.UnitTests/StatementMethodTestsBase.cs +++ b/tests/DbConnectionPlus.UnitTests/StatementMethodTestsBase.cs @@ -1,4 +1,5 @@ -using NSubstitute.DbConnection; +// ReSharper disable AccessToDisposedClosure + using RentADeveloper.DbConnectionPlus.SqlStatements; namespace RentADeveloper.DbConnectionPlus.UnitTests; @@ -22,8 +23,6 @@ protected StatementMethodTestsBase( { this.asyncTestMethod = asyncTestMethod; this.syncTestMethod = syncTestMethod; - - this.MockDbConnection.SetupQuery(_ => true).Returns(new { Id = 1 }); } [Fact] @@ -40,11 +39,9 @@ await this.asyncTestMethod( TestContext.Current.CancellationToken ); - this.MockCommandFactory.Received().CreateDbCommand( - this.MockDbConnection, - Arg.Any(), - Arg.Any(), - timeout + this.MockInterceptDbCommand.Received().Invoke( + Arg.Is(cmd => cmd.CommandTimeout == (Int32)timeout.TotalSeconds), + Arg.Any>() ); } @@ -60,12 +57,9 @@ await this.asyncTestMethod( TestContext.Current.CancellationToken ); - this.MockCommandFactory.Received().CreateDbCommand( - this.MockDbConnection, - Arg.Any(), - Arg.Any(), - Arg.Any(), - CommandType.StoredProcedure + this.MockInterceptDbCommand.Received().Invoke( + Arg.Is(cmd => cmd.CommandType == CommandType.StoredProcedure), + Arg.Any>() ); } @@ -83,10 +77,9 @@ await this.asyncTestMethod( TestContext.Current.CancellationToken ); - this.MockCommandFactory.Received().CreateDbCommand( - this.MockDbConnection, - Arg.Any(), - transaction + this.MockInterceptDbCommand.Received().Invoke( + Arg.Is(cmd => cmd.Transaction == transaction), + Arg.Any>() ); } @@ -104,11 +97,9 @@ public void SyncMethod_ShouldUseCommandTimeout() TestContext.Current.CancellationToken ); - this.MockCommandFactory.Received().CreateDbCommand( - this.MockDbConnection, - Arg.Any(), - Arg.Any(), - timeout + this.MockInterceptDbCommand.Received().Invoke( + Arg.Is(cmd => cmd.CommandTimeout == (Int32)timeout.TotalSeconds), + Arg.Any>() ); } @@ -124,12 +115,9 @@ public void SyncMethod_ShouldUseCommandType() TestContext.Current.CancellationToken ); - this.MockCommandFactory.Received().CreateDbCommand( - this.MockDbConnection, - Arg.Any(), - Arg.Any(), - Arg.Any(), - CommandType.StoredProcedure + this.MockInterceptDbCommand.Received().Invoke( + Arg.Is(cmd => cmd.CommandType == CommandType.StoredProcedure), + Arg.Any>() ); } @@ -147,10 +135,9 @@ public void SyncMethod_ShouldUseTransaction() TestContext.Current.CancellationToken ); - this.MockCommandFactory.Received().CreateDbCommand( - this.MockDbConnection, - Arg.Any(), - transaction + this.MockInterceptDbCommand.Received().Invoke( + Arg.Is(cmd => cmd.Transaction == transaction), + Arg.Any>() ); } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/Entity.cs b/tests/DbConnectionPlus.UnitTests/TestData/Entity.cs index 3f06d4c..212eff0 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/Entity.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/Entity.cs @@ -3,6 +3,7 @@ public record Entity { public Boolean BooleanValue { get; set; } + public Byte[] BytesValue { get; set; } = null!; public Byte ByteValue { get; set; } public Char CharValue { get; set; } public DateOnly DateOnlyValue { get; set; } @@ -18,6 +19,8 @@ public record Entity public Int16 Int16Value { get; set; } public Int32 Int32Value { get; set; } public Int64 Int64Value { get; set; } + + public Boolean? NullableBooleanValue { get; set; } public Single SingleValue { get; set; } public String StringValue { get; set; } = null!; public TimeOnly TimeOnlyValue { get; set; } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithBinaryProperty.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithBinaryProperty.cs deleted file mode 100644 index a34de28..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithBinaryProperty.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public class EntityWithBinaryProperty -{ - public Byte[] BinaryData { get; set; } = null!; -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithCharProperty.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithCharProperty.cs deleted file mode 100644 index 496a3dc..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithCharProperty.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public record EntityWithCharProperty -{ - public Char Char { get; set; } -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithDifferentCasingProperties.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithDifferentCasingProperties.cs index 1ad0353..86a2f9b 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithDifferentCasingProperties.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithDifferentCasingProperties.cs @@ -5,6 +5,7 @@ namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; public record EntityWithDifferentCasingProperties { public Boolean BooleanVALUE { get; set; } + public Byte[] BytesVALUE { get; set; } = null!; public Byte ByteVALUE { get; set; } public Char CharVALUE { get; set; } public DateOnly DateOnlyVALUE { get; set; } @@ -24,6 +25,7 @@ public record EntityWithDifferentCasingProperties [NotMapped] public String? NotMappedProperty { get; set; } + public Boolean? NullableBooleanVALUE { get; set; } public Single SingleVALUE { get; set; } public String StringVALUE { get; set; } = null!; public TimeOnly TimeOnlyVALUE { get; set; } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithEnumProperty.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithEnumProperty.cs deleted file mode 100644 index 4315e59..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithEnumProperty.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public class EntityWithEnumProperty -{ - public TestEnum Enum { get; set; } -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNonNullableProperty.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNonNullableProperty.cs deleted file mode 100644 index f1937ef..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNonNullableProperty.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public record EntityWithNonNullableProperty -{ - [Key] - public Int64 Id { get; set; } - - public Int64 Value { get; set; } -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNullableCharProperty.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNullableCharProperty.cs deleted file mode 100644 index b65cff5..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNullableCharProperty.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public class EntityWithNullableCharProperty -{ - public Char? Char { get; set; } -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNullableProperty.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNullableProperty.cs deleted file mode 100644 index 4cb1705..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithNullableProperty.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public record EntityWithNullableProperty -{ - [Key] - public Int64 Id { get; set; } - - public Int32? Value { get; set; } -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateConstructor.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateConstructor.cs index 90417f6..7542fbf 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateConstructor.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateConstructor.cs @@ -1,8 +1,9 @@ namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; -public record EntityWithPrivateConstructor +public record EntityWithPrivateConstructor : Entity { private EntityWithPrivateConstructor( + Byte[] bytesValue, Boolean booleanValue, Byte byteValue, Char charValue, @@ -16,12 +17,14 @@ private EntityWithPrivateConstructor( Int16 int16Value, Int32 int32Value, Int64 int64Value, + Boolean? nullableBooleanValue, Single singleValue, String stringValue, TimeOnly timeOnlyValue, TimeSpan timeSpanValue ) { + this.BytesValue = bytesValue; this.BooleanValue = booleanValue; this.ByteValue = byteValue; this.CharValue = charValue; @@ -35,30 +38,10 @@ TimeSpan timeSpanValue this.Int16Value = int16Value; this.Int32Value = int32Value; this.Int64Value = int64Value; + this.NullableBooleanValue = nullableBooleanValue; this.SingleValue = singleValue; this.StringValue = stringValue; this.TimeOnlyValue = timeOnlyValue; this.TimeSpanValue = timeSpanValue; } - - public Boolean BooleanValue { get; } - public Byte ByteValue { get; } - public Char CharValue { get; } - public DateOnly DateOnlyValue { get; } - public DateTime DateTimeValue { get; } - public Decimal DecimalValue { get; } - public Double DoubleValue { get; } - public TestEnum EnumValue { get; } - public Guid GuidValue { get; } - - [Key] - public Int64 Id { get; } - - public Int16 Int16Value { get; } - public Int32 Int32Value { get; } - public Int64 Int64Value { get; } - public Single SingleValue { get; } - public String StringValue { get; } - public TimeOnly TimeOnlyValue { get; } - public TimeSpan TimeSpanValue { get; } } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateParameterlessConstructor.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateParameterlessConstructor.cs index b95575a..94b700a 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateParameterlessConstructor.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPrivateParameterlessConstructor.cs @@ -1,29 +1,8 @@ namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; -public record EntityWithPrivateParameterlessConstructor +public record EntityWithPrivateParameterlessConstructor : Entity { private EntityWithPrivateParameterlessConstructor() { } - - public Boolean BooleanValue { get; set; } - public Byte ByteValue { get; set; } - public Char CharValue { get; set; } - public DateOnly DateOnlyValue { get; set; } - public DateTime DateTimeValue { get; set; } - public Decimal DecimalValue { get; set; } - public Double DoubleValue { get; set; } - public TestEnum EnumValue { get; set; } - public Guid GuidValue { get; set; } - - [Key] - public Int64 Id { get; set; } - - public Int16 Int16Value { get; set; } - public Int32 Int32Value { get; set; } - public Int64 Int64Value { get; set; } - public Single SingleValue { get; set; } - public String StringValue { get; set; } = null!; - public TimeOnly TimeOnlyValue { get; set; } - public TimeSpan TimeSpanValue { get; set; } } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPublicConstructor.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPublicConstructor.cs index f2ddfec..6113a44 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPublicConstructor.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithPublicConstructor.cs @@ -4,9 +4,10 @@ namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; -public record EntityWithPublicConstructor +public record EntityWithPublicConstructor : Entity { public EntityWithPublicConstructor( + Byte[] bytesValue, Boolean booleanValue, Byte byteValue, Char charValue, @@ -20,12 +21,14 @@ public EntityWithPublicConstructor( Int16 int16Value, Int32 int32Value, Int64 int64Value, + Boolean? nullableBooleanValue, Single singleValue, String stringValue, TimeOnly timeOnlyValue, TimeSpan timeSpanValue ) { + this.BytesValue = bytesValue; this.BooleanValue = booleanValue; this.ByteValue = byteValue; this.CharValue = charValue; @@ -39,30 +42,10 @@ TimeSpan timeSpanValue this.Int16Value = int16Value; this.Int32Value = int32Value; this.Int64Value = int64Value; + this.NullableBooleanValue = nullableBooleanValue; this.SingleValue = singleValue; this.StringValue = stringValue; this.TimeOnlyValue = timeOnlyValue; this.TimeSpanValue = timeSpanValue; } - - public Boolean BooleanValue { get; } - public Byte ByteValue { get; } - public Char CharValue { get; } - public DateOnly DateOnlyValue { get; } - public DateTime DateTimeValue { get; } - public Decimal DecimalValue { get; } - public Double DoubleValue { get; } - public TestEnum EnumValue { get; } - public Guid GuidValue { get; } - - [Key] - public Int64 Id { get; } - - public Int16 Int16Value { get; } - public Int32 Int32Value { get; } - public Int64 Int64Value { get; } - public Single SingleValue { get; } - public String StringValue { get; } - public TimeOnly TimeOnlyValue { get; } - public TimeSpan TimeSpanValue { get; } } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithStringProperty.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithStringProperty.cs deleted file mode 100644 index 4203553..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithStringProperty.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public class EntityWithStringProperty -{ - public String String { get; set; } = null!; -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithUnsupportedPropertyType.cs b/tests/DbConnectionPlus.UnitTests/TestData/EntityWithUnsupportedPropertyType.cs deleted file mode 100644 index f7750eb..0000000 --- a/tests/DbConnectionPlus.UnitTests/TestData/EntityWithUnsupportedPropertyType.cs +++ /dev/null @@ -1,8 +0,0 @@ -using System.Numerics; - -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; - -public class EntityWithUnsupportedPropertyType -{ - public BigInteger Id { get; set; } -} diff --git a/tests/DbConnectionPlus.UnitTests/TestData/Generate.cs b/tests/DbConnectionPlus.UnitTests/TestData/Generate.cs index d445102..f70f344 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/Generate.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/Generate.cs @@ -3,7 +3,9 @@ #pragma warning disable IDE0053 +using System.Reflection; using AutoFixture; +using AutoFixture.Kernel; using Bogus; using Mapster; using RentADeveloper.DbConnectionPlus.Entities; @@ -23,6 +25,8 @@ static Generate() faker = new(); fixture = new(); + fixture.Customize(new OmitIgnoredPropertiesCustomization()); + fixture.Register(() => faker.Random.Bool()); fixture.Register(() => faker.Random.Byte()); fixture.Register(() => faker.Random.Bytes(SmallNumber())); @@ -245,56 +249,63 @@ public static Int32 SmallNumber() => faker.Random.Int(5, 15); /// - /// Creates a copy of where all properties except the key property / properties have new - /// values. + /// Creates a copy of where all properties except the key and concurrency token + /// properties have new values. /// /// The type of entity to create an updated copy of. /// The entity for which to create an updated copy. /// - /// A copy of where all properties except the key property / properties have new values. + /// A copy of where all properties except key and concurrency token properties have new + /// values. /// public static T UpdateFor(T entity) { var updatedEntity = Single(); - CopyKeys(entity, updatedEntity); + CopyKeysAndConcurrencyTokens(entity, updatedEntity); // For the rare case that all generated values are the same as in the original entity, // regenerate until at least one value is different. while (entity!.Equals(updatedEntity)) { updatedEntity = Single(); - CopyKeys(entity, updatedEntity); + CopyKeysAndConcurrencyTokens(entity, updatedEntity); } return updatedEntity; } /// - /// Creates a list with copies of where all properties except the key property / - /// properties have new values. + /// Creates a list with copies of where all properties except key and concurrency + /// token properties have new values. /// /// The type of entities to create updated copies of. /// The entities for which to create updated copies. /// - /// A list with copies of where all properties except the key property / properties - /// have new values. + /// A list with copies of where all properties except key and concurrency token + /// properties have new values. /// public static List UpdateFor(List entities) => [.. entities.Select(UpdateFor)]; /// - /// Copies the values of all key properties (properties denoted with a ) from + /// Copies the values of all key and concurrency token properties from /// to . /// - /// The type of the entities to copy keys from and to. - /// The source entity to copy keys from. - /// The target entity to copy keys to. - private static void CopyKeys(T sourceEntity, T targetEntity) + /// The type of the entities to copy keys and concurrency tokens from and to. + /// The source entity to copy keys and concurrency tokens from. + /// The target entity to copy keys and concurrency tokens to. + private static void CopyKeysAndConcurrencyTokens(T sourceEntity, T targetEntity) { - foreach (var keyProperty in EntityHelper.GetEntityTypeMetadata(typeof(T)).KeyProperties) + var metadata = EntityHelper.GetEntityTypeMetadata(typeof(T)); + + var propertiesToCopy = + metadata.KeyProperties + .Concat(metadata.ConcurrencyTokenProperties) + .Concat(metadata.RowVersionProperties); + + foreach (var property in propertiesToCopy) { - var keyPropertyValue = keyProperty.PropertyGetter!(sourceEntity); - keyProperty.PropertySetter!(targetEntity, keyPropertyValue); + property.PropertySetter!(targetEntity, property.PropertyGetter!(sourceEntity)); } } @@ -308,4 +319,39 @@ private static void CopyKeys(T sourceEntity, T targetEntity) private static readonly Faker faker; private static readonly Fixture fixture; private static Int64 entityId = 1; + + /// + /// An AutoFixture customization that excludes properties that are ignored in the entity model from being populated + /// with test data. + /// + public class OmitIgnoredPropertiesCustomization : ICustomization + { + public void Customize(IFixture fixture) => + fixture.Customizations.Add(new OmitNotMappedPropertySpecimenBuilder()); + + private class OmitNotMappedPropertySpecimenBuilder : ISpecimenBuilder + { + public Object Create(Object request, ISpecimenContext context) + { + if (request is PropertyInfo propertyInfo) + { + var entityTypeMetadata = EntityHelper.GetEntityTypeMetadata(propertyInfo.DeclaringType!); + + if ( + entityTypeMetadata.AllPropertiesByPropertyName.TryGetValue( + propertyInfo.Name, + out var propertyMetadata + ) + && + propertyMetadata.IsIgnored + ) + { + return new OmitSpecimen(); + } + } + + return new NoSpecimen(); + } + } + } } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntity.cs b/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntity.cs index 5288b27..85d4bc7 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntity.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntity.cs @@ -2,11 +2,13 @@ public record MappingTestEntity { + public Byte[]? ConcurrencyToken { get; set; } + [Key] - public Int64 KeyColumn1 { get; set; } + public Int64 Key1 { get; set; } [Key] - public Int64 KeyColumn2 { get; set; } + public Int64 Key2 { get; set; } - public Int32 ValueColumn { get; set; } + public Int32 Value { get; set; } } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityAttributes.cs b/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityAttributes.cs index 358d0d8..8f4279a 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityAttributes.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityAttributes.cs @@ -5,25 +5,33 @@ namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; [Table("MappingTestEntity")] public record MappingTestEntityAttributes { - [Column("ComputedColumn")] + [Column("Computed")] [DatabaseGenerated(DatabaseGeneratedOption.Computed)] - public Int32 ComputedColumn_ { get; set; } + public Int32 Computed_ { get; set; } - [Column("IdentityColumn")] + [Column("ConcurrencyToken")] + [ConcurrencyCheck] + public Byte[]? ConcurrencyToken_ { get; set; } + + [Column("Identity")] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public Int32 IdentityColumn_ { get; set; } + public Int32 Identity_ { get; set; } [Key] - [Column("KeyColumn1")] - public Int64 KeyColumn1_ { get; set; } + [Column("Key1")] + public Int64 Key1_ { get; set; } [Key] - [Column("KeyColumn2")] - public Int64 KeyColumn2_ { get; set; } + [Column("Key2")] + public Int64 Key2_ { get; set; } [NotMapped] - public String? NotMappedColumn { get; set; } + public String? NotMapped { get; set; } + + [Column("RowVersion")] + [Timestamp] + public Byte[]? RowVersion_ { get; set; } - [Column("ValueColumn")] - public Int32 ValueColumn_ { get; set; } + [Column("Value")] + public Int32 Value_ { get; set; } } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityFluentApi.cs b/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityFluentApi.cs index d4cb012..a887844 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityFluentApi.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/MappingTestEntityFluentApi.cs @@ -4,12 +4,14 @@ namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; public record MappingTestEntityFluentApi { - public Int32 ComputedColumn_ { get; set; } - public Int32 IdentityColumn_ { get; set; } - public Int64 KeyColumn1_ { get; set; } - public Int64 KeyColumn2_ { get; set; } - public String? NotMappedColumn { get; set; } - public Int32 ValueColumn_ { get; set; } + public Int32 Computed_ { get; set; } + public Byte[]? ConcurrencyToken_ { get; set; } + public Int32 Identity_ { get; set; } + public Int64 Key1_ { get; set; } + public Int64 Key2_ { get; set; } + public String? NotMapped { get; set; } + public Byte[]? RowVersion_ { get; set; } + public Int32 Value_ { get; set; } /// /// Configures the mapping for this entity using the Fluent API. @@ -21,32 +23,42 @@ public static void Configure() => .ToTable("MappingTestEntity"); config.Entity() - .Property(a => a.KeyColumn1_) - .HasColumnName("KeyColumn1") - .IsKey(); + .Property(a => a.Computed_) + .HasColumnName("Computed") + .IsComputed(); config.Entity() - .Property(a => a.KeyColumn2_) - .HasColumnName("KeyColumn2") - .IsKey(); + .Property(a => a.ConcurrencyToken_) + .HasColumnName("ConcurrencyToken") + .IsConcurrencyToken(); config.Entity() - .Property(a => a.ValueColumn_) - .HasColumnName("ValueColumn"); + .Property(a => a.Identity_) + .HasColumnName("Identity") + .IsIdentity(); config.Entity() - .Property(a => a.ComputedColumn_) - .HasColumnName("ComputedColumn") - .IsComputed(); + .Property(a => a.Key1_) + .HasColumnName("Key1") + .IsKey(); config.Entity() - .Property(a => a.IdentityColumn_) - .HasColumnName("IdentityColumn") - .IsIdentity(); + .Property(a => a.Key2_) + .HasColumnName("Key2") + .IsKey(); + + config.Entity() + .Property(a => a.Value_) + .HasColumnName("Value"); config.Entity() - .Property(a => a.NotMappedColumn) + .Property(a => a.NotMapped) .IsIgnored(); + + config.Entity() + .Property(a => a.RowVersion_) + .HasColumnName("RowVersion") + .IsRowVersion(); } ); } diff --git a/tests/DbConnectionPlus.UnitTests/TestData/TestEnum.cs b/tests/DbConnectionPlus.UnitTests/TestData/TestEnum.cs index 959f6c1..89501f2 100644 --- a/tests/DbConnectionPlus.UnitTests/TestData/TestEnum.cs +++ b/tests/DbConnectionPlus.UnitTests/TestData/TestEnum.cs @@ -1,8 +1,8 @@ -namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; +#pragma warning disable RCS1042 + +namespace RentADeveloper.DbConnectionPlus.UnitTests.TestData; -#pragma warning disable RCS1042 // Remove enum default underlying type public enum TestEnum : Int32 -#pragma warning restore RCS1042 // Remove enum default underlying type { Value1 = 1, Value2 = 2, diff --git a/tests/DbConnectionPlus.UnitTests/UnitTestsBase.cs b/tests/DbConnectionPlus.UnitTests/UnitTestsBase.cs index dfcd812..d1dd551 100644 --- a/tests/DbConnectionPlus.UnitTests/UnitTestsBase.cs +++ b/tests/DbConnectionPlus.UnitTests/UnitTestsBase.cs @@ -1,13 +1,14 @@ -#pragma warning disable NS1004 +#pragma warning disable NS1004, NS1000 using System.Globalization; +using NSubstitute.ClearExtensions; using NSubstitute.DbConnection; using RentADeveloper.DbConnectionPlus.Converters; using RentADeveloper.DbConnectionPlus.DatabaseAdapters; using RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle; -using RentADeveloper.DbConnectionPlus.DbCommands; using RentADeveloper.DbConnectionPlus.Entities; using RentADeveloper.DbConnectionPlus.Extensions; +using RentADeveloper.DbConnectionPlus.UnitTests.Mocks; namespace RentADeveloper.DbConnectionPlus.UnitTests; @@ -25,49 +26,26 @@ public UnitTestsBase() Thread.CurrentThread.CurrentUICulture = new("en-US"); + this.MockDatabaseAdapter = Substitute.For(); + this.MockEntityManipulator = Substitute.For(); - // Reset all settings to defaults before each test. - DbConnectionPlusConfiguration.Instance = new() - { - EnumSerializationMode = EnumSerializationMode.Strings, - InterceptDbCommand = null - }; - EntityHelper.ResetEntityTypeMetadataCache(); - OracleDatabaseAdapter.AllowTemporaryTables = false; + this.MockDbConnection = Substitute.For(); - this.MockDbConnection = Substitute.For().SetupCommands(); - this.MockCommandFactory = Substitute.For(); + this.MockDbCommand = Substitute.For().SetupCommands().CreateCommand(); + this.MockDbCommand.ClearSubstitute(); - this.MockDatabaseAdapter = Substitute.For(); - this.MockEntityManipulator = Substitute.For(); + this.MockDbCommand.CreateParameter().Returns(_ => Substitute.For()); - typeof(DbConnectionPlusConfiguration).GetMethod(nameof(DbConnectionPlusConfiguration.RegisterDatabaseAdapter))! - .MakeGenericMethod(this.MockDbConnection.GetType()) - .Invoke(DbConnectionPlusConfiguration.Instance, [this.MockDatabaseAdapter]); + var dbParameterCollection = new MockDbParameterCollection(); + this.MockDbCommand.Parameters.Returns(dbParameterCollection); - DbCommandFactory = this.MockCommandFactory; + this.MockDbDataReader = Substitute.For(); - this.MockDbCommand = this.MockDbConnection.CreateCommand(); + this.MockDbCommand.ExecuteReader(Arg.Any()).Returns(this.MockDbDataReader); + this.MockDbCommand.ExecuteReaderAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(this.MockDbDataReader)); - this.MockCommandFactory - .CreateDbCommand( - this.MockDbConnection, - Arg.Any(), - Arg.Any(), - Arg.Any(), - Arg.Any() - ) - .Returns(info => - { - // ReSharper disable once InlineTemporaryVariable - var command = this.MockDbCommand; - command.CommandText = info.ArgAt(1); - command.Transaction = info.ArgAt(2); - command.CommandTimeout = (Int32)(info.ArgAt(3)?.TotalSeconds ?? 30); - command.CommandType = info.ArgAt(4); - return command; - } - ); + this.MockDbConnection.CreateCommand().Returns(this.MockDbCommand); this.MockTemporaryTableBuilder = Substitute.For(); @@ -141,12 +119,22 @@ public UnitTestsBase() ); this.MockDatabaseAdapter.EntityManipulator.Returns(this.MockEntityManipulator); - } - /// - /// The mocked to use in tests. - /// - protected IDbCommandFactory MockCommandFactory { get; } + this.MockInterceptDbCommand = Substitute.For(); + + // Reset all settings to defaults before each test. + DbConnectionPlusConfiguration.Instance = new() + { + EnumSerializationMode = EnumSerializationMode.Strings, + InterceptDbCommand = this.MockInterceptDbCommand + }; + EntityHelper.ResetEntityTypeMetadataCache(); + OracleDatabaseAdapter.AllowTemporaryTables = false; + + typeof(DbConnectionPlusConfiguration).GetMethod(nameof(DbConnectionPlusConfiguration.RegisterDatabaseAdapter))! + .MakeGenericMethod(this.MockDbConnection.GetType()) + .Invoke(DbConnectionPlusConfiguration.Instance, [this.MockDatabaseAdapter]); + } /// /// The mocked to use in tests. @@ -163,11 +151,21 @@ public UnitTestsBase() /// protected DbConnection MockDbConnection { get; } + /// + /// The mocked to use in tests. + /// + protected DbDataReader MockDbDataReader { get; } + /// /// The mocked to use in tests. /// protected IEntityManipulator MockEntityManipulator { get; } + /// + /// The mocked delegate to use in tests. + /// + protected InterceptDbCommand MockInterceptDbCommand { get; } + /// /// The mocked to use in tests. ///