From 2a6eee441082a3c115580883ffdec93889764953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Thu, 29 Oct 2026 14:42:42 +0100 Subject: [PATCH] test: the EF satellite's queries, keys and round trips run on a real SQL Server The query catalogue (QueryShapes) and the key catalogue (KeyShapes) run on SQL Server 2022 in a container, as do the round trips the PostgreSQL class makes. Each query over a value object is held to the same query over the wrapped type there, and then to memory: a difference from memory passes only where SQLite answers the same (a relational trait, such as a null .Value that is a NULL in SQL), where the same query through ValueOrNull() answers it, or where it is a fact of SQL Server's pinned in design/knowledge with a test of plain EF Core (KB-048 to KB-054): text compared ignoring case and trailing spaces, a uniqueidentifier ordered by its last group, decimal(18,2) by default, time under a day (refused in a column, misread from JSON), no MAX of a bit, Greatest and Least only from compatibility level 160, datetime2 reading a DateTime back as Unspecified. No difference called for a change in src/: a value object does on SQL Server what its wrapped type does. The EF README states the ones a consumer meets. The catalogue's text, char and Guid values now mix case and ascend by the Guid's first group, so a collation or a uniqueidentifier order cannot match memory by accident. That showed the SQLite test's stand-in for Greatest and Least comparing strings by the culture, where the value object's generated comparison and SQLite's max are ordinal; it compares text ordinally now. The container is a plain Testcontainers one, started by its log line, so TYPEKIT_MSSQL_IMAGE can name Azure SQL Edge, which runs natively on an arm64 Mac where 2022 does not start under QEMU. The classes that need Docker carry the trait Needs=Docker, which CI's Windows and macOS legs leave out, the PostgreSQL round trips included. SqlServerQueryVariantTests and SqlServerKeyVariantTests are oracles, caught by a new seed (a column cast instead of re-typed) and by the ushort key seed. Closes #81 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/dotnet.yml | 8 +- CHANGELOG.md | 1 + CONTRIBUTING.md | 10 +- Directory.Packages.props | 5 +- ...-sql-server-compares-text-ignoring-case.md | 21 ++ ...rs-a-uniqueidentifier-by-its-last-group.md | 16 ++ ...a-decimal-is-decimal-18-2-on-sql-server.md | 15 ++ ...sql-server-s-time-holds-less-than-a-day.md | 16 ++ ...KB-052-sql-server-takes-no-max-of-a-bit.md | 14 ++ ...-and-least-need-compatibility-level-160.md | 15 ++ ...2-keeps-every-tick-and-forgets-the-kind.md | 15 ++ design/knowledge/README.md | 7 + docs/ef-core.md | 4 +- .../README.md | 31 ++- ...s.TypeKit.EntityFrameworkCore.Tests.csproj | 6 +- .../PostgreSqlRoundTripTests.cs | 1 + .../QueryTranslationVariantTests.cs | 95 +++++---- .../SqlServer.cs | 91 ++++++++ .../SqlServerKeyVariantTests.cs | 188 +++++++++++++++++ .../SqlServerQueryVariantTests.cs | 197 ++++++++++++++++++ .../SqlServerRoundTripTests.cs | 176 ++++++++++++++++ .../Variants/KeyShapes.cs | 25 ++- .../Variants/QueryShapes.cs | 19 +- .../Variants/WrappedKinds.cs | 13 +- .../CodoMetis.TypeKit.Facts.csproj | 7 + test/CodoMetis.TypeKit.Facts/KB047.cs | 2 +- test/CodoMetis.TypeKit.Facts/KB048.cs | 38 ++++ test/CodoMetis.TypeKit.Facts/KB049.cs | 34 +++ test/CodoMetis.TypeKit.Facts/KB050.cs | 34 +++ test/CodoMetis.TypeKit.Facts/KB051.cs | 56 +++++ test/CodoMetis.TypeKit.Facts/KB052.cs | 31 +++ test/CodoMetis.TypeKit.Facts/KB053.cs | 55 +++++ test/CodoMetis.TypeKit.Facts/KB054.cs | 41 ++++ .../SqlServerFactHost.cs | 84 ++++++++ test/oracles/claims.json | 98 +++++++++ test/oracles/oracles.json | 12 ++ .../seeds/column-cast-not-re-typed.patch | 19 ++ .../seeds/ushort-key-not-generated.patch | 4 +- 38 files changed, 1439 insertions(+), 65 deletions(-) create mode 100644 design/knowledge/KB-048-sql-server-compares-text-ignoring-case.md create mode 100644 design/knowledge/KB-049-sql-server-orders-a-uniqueidentifier-by-its-last-group.md create mode 100644 design/knowledge/KB-050-a-decimal-is-decimal-18-2-on-sql-server.md create mode 100644 design/knowledge/KB-051-sql-server-s-time-holds-less-than-a-day.md create mode 100644 design/knowledge/KB-052-sql-server-takes-no-max-of-a-bit.md create mode 100644 design/knowledge/KB-053-greatest-and-least-need-compatibility-level-160.md create mode 100644 design/knowledge/KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerKeyVariantTests.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerQueryVariantTests.cs create mode 100644 test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB048.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB049.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB050.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB051.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB052.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB053.cs create mode 100644 test/CodoMetis.TypeKit.Facts/KB054.cs create mode 100644 test/CodoMetis.TypeKit.Facts/SqlServerFactHost.cs create mode 100644 test/oracles/seeds/column-cast-not-re-typed.patch diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 35ad7135..ea2bc800 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -123,14 +123,14 @@ jobs: - name: Build run: dotnet build "$SOLUTION" -c Release --no-restore - # The EF Core round trips start a PostgreSQL container. Ubuntu runners have Docker; macOS - # runners have none and Windows runners run only Windows containers, so those legs leave the - # one class that needs it out. + # The EF Core tests on PostgreSQL and SQL Server, and the SQL Server facts, start a Linux container. + # Ubuntu runners have Docker; macOS runners have none and Windows runners run only Windows + # containers, so those legs leave out the classes that need it, the trait Needs=Docker. - name: Test run: > dotnet test --solution "$SOLUTION" -c Release --no-build --report-xunit-trx --results-directory TestResults - ${{ runner.os != 'Linux' && '--filter-not-class "*PostgreSqlRoundTripTests"' || '' }} + ${{ runner.os != 'Linux' && '--filter-not-trait "Needs=Docker"' || '' }} - name: Upload test results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index d33373ac..237375f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ section is the GitHub release's notes. - `Fault.Cause`: `Fault.Of(reason, detail, exception)` keeps the exception underneath for your log; equality and `ToString()` leave it out, and `ToHttpResult()` hands it to the host as `IFaultFeature.Cause` and `FaultProblem.Cause`, never into the problem ([What failed underneath](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.AspNetCore/README.md#what-failed-underneath)). - Testing: every assertion works on a `Task` or `ValueTask` of what it checks, `await service.FindAsync(id).ShouldBeSome()`; a task that throws fails the test with its own exception, and an assertion left without `await` is reported (CMTK0003) ([CodoMetis.TypeKit.Testing](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Testing/README.md)). - Analyzers: a validated value object without `Create` gets a code fix, **Add Create**, that writes a `Create` accepting every value, ready for your rules ([Add Create](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#add-create)). +- EF Core: queries, keys and round trips are tested on a real SQL Server, and the README says what differs there from memory: text compared ignoring case, Guids sorted by their last group, `decimal(18,2)` by default, a `TimeSpan` under a day, `Greatest` from compatibility level 160 ([On SQL Server](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server)). ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ecac8de..5360f11f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,9 +20,13 @@ a file-based app (`dotnet run tools/x.cs`) or a tool project under `tools/`, nev ([decision 92](design/adr/0092-repository-tooling-is-dotnet-and-ci-needs-no-ai.md)). Two tasks need one thing more each: -- **Docker, running** (Docker Desktop, OrbStack, Colima), for the PostgreSQL round-trip tests, which - start a Linux container through Testcontainers. Without it those fail with - `DockerUnavailableException`, and every other test passes. +- **Docker, running** (Docker Desktop, OrbStack, Colima), for the EF Core tests on PostgreSQL and SQL + Server and the SQL Server facts, which start a Linux container through Testcontainers and carry the + trait `Needs=Docker`. Without it those fail with `DockerUnavailableException`, and every other test + passes; `--filter-not-trait "Needs=Docker"` leaves them out. SQL Server's image is amd64 only: on an + arm64 Mac it starts under Rosetta, not under QEMU (OrbStack's default without Rosetta), so set + `TYPEKIT_MSSQL_IMAGE=mcr.microsoft.com/azure-sql-edge:latest` there, whose older engine runs natively; + CI runs SQL Server 2022. - **The platform's native toolchain** for the smoke test, which publishes two consumers with Native AOT, a web host and a console application with EF Core: Xcode's command-line tools on macOS (`xcode-select --install`), `clang` and `zlib1g-dev` on Debian and Ubuntu, MSVC's C++ build tools diff --git a/Directory.Packages.props b/Directory.Packages.props index bcebbd99..699da861 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -50,14 +50,15 @@ + diff --git a/design/knowledge/KB-048-sql-server-compares-text-ignoring-case.md b/design/knowledge/KB-048-sql-server-compares-text-ignoring-case.md new file mode 100644 index 00000000..9e001cae --- /dev/null +++ b/design/knowledge/KB-048-sql-server-compares-text-ignoring-case.md @@ -0,0 +1,21 @@ +--- +verified: 2026-10-29 +pins: + - external SQL-Server 2022 + - Microsoft.EntityFrameworkCore.SqlServer 10.0.12 +source: https://learn.microsoft.com/sql/t-sql/language-elements/string-comparison-assignment ("Transact-SQL considers the strings 'abc' and 'abc ' to be equivalent for most comparison operations") and EF Core's Entity Properties page, whose example configures "a SQL Server column to be case-insensitive" with SQL_Latin1_General_CP1_CI_AS; measured with plain EF Core on the mssql/server 2022 image, whose databases take that collation +turned-on: the SQL Server query catalogue's text cases (SqlServerQueryVariantTests, issue 81), where a text or char value object compared, ordered or aggregated answers in another order than memory's ordinal one, and the EF README's "Ordering in SQL follows the database's collation", extended to comparison +test: test/CodoMetis.TypeKit.Facts/KB048.cs +--- + +# KB-048. SQL Server compares and orders text ignoring case, and trailing spaces + +A `nvarchar` column holding `alpha`, `Bravo` and `charlie`, under the default collation +`SQL_Latin1_General_CP1_CI_AS`: + +- `Where(n => n.Name == "BRAVO")` finds `Bravo`, and `n.Name == "alpha "` finds `alpha`; +- `upper.Contains(n.Name)` over `["ALPHA"]` finds `alpha`, and `StartsWith("b")` finds `Bravo`; +- `OrderBy(n => n.Name)` gives `["alpha", "Bravo", "charlie"]`, where ordinal order is `["Bravo", "alpha", "charlie"]`; +- `OrderBy(n => EF.Functions.Collate(n.Name, "Latin1_General_BIN2"))` gives the ordinal order. + +So a query over text matches and orders rows that ordinal comparison in memory keeps apart. diff --git a/design/knowledge/KB-049-sql-server-orders-a-uniqueidentifier-by-its-last-group.md b/design/knowledge/KB-049-sql-server-orders-a-uniqueidentifier-by-its-last-group.md new file mode 100644 index 00000000..a070c3e1 --- /dev/null +++ b/design/knowledge/KB-049-sql-server-orders-a-uniqueidentifier-by-its-last-group.md @@ -0,0 +1,16 @@ +--- +verified: 2026-10-29 +pins: + - external SQL-Server 2022 + - Microsoft.EntityFrameworkCore.SqlServer 10.0.12 +source: https://learn.microsoft.com/sql/connect/ado-net/sql/compare-guid-uniqueidentifier-values (SqlGuid "implements CompareTo using SQL Server behavior, in the last six bytes of a value are most significant", where Guid "evaluates all 16 bytes"), measured with plain EF Core on the mssql/server 2022 image +turned-on: the SQL Server query catalogue's Guid cases (SqlServerQueryVariantTests, issue 81), where a Guid value object compared, ordered or aggregated answers in another order than memory's, and the catalogue's Guid values, which ascend by their first group and not by their last +test: test/CodoMetis.TypeKit.Facts/KB049.cs +--- + +# KB-049. SQL Server orders a `uniqueidentifier` by its last group first + +Three Guids that ascend in memory by their first group, `00000001-…-000000000003`, `00000002-…-000000000001` and +`00000003-…-000000000002`, order on SQL Server by their last group: `OrderBy` answers the second, the third, then the +first, `Min` the second, and `< first` finds the other two. `Guid.CompareTo` compares the first group first, so +an ordering, a comparison or a `Min` or `Max` over Guids answers otherwise in memory. diff --git a/design/knowledge/KB-050-a-decimal-is-decimal-18-2-on-sql-server.md b/design/knowledge/KB-050-a-decimal-is-decimal-18-2-on-sql-server.md new file mode 100644 index 00000000..82f5e266 --- /dev/null +++ b/design/knowledge/KB-050-a-decimal-is-decimal-18-2-on-sql-server.md @@ -0,0 +1,15 @@ +--- +verified: 2026-10-29 +pins: + - external SQL-Server 2022 + - Microsoft.EntityFrameworkCore.SqlServer 10.0.12 +source: measured with plain EF Core on the mssql/server 2022 image, the model's column type and the values read back. EF's documentation names no default precision, and SQL Server's precision page no rounding on write +turned-on: the SQL Server query catalogue's decimal cases and the key catalogue's decimal keys (SqlServerQueryVariantTests, SqlServerKeyVariantTests, issue 81), which read 10.00 where memory holds 10, and the EF README's HavePrecision guidance for SQL Server +test: test/CodoMetis.TypeKit.Facts/KB050.cs +--- + +# KB-050. A `decimal` is a `decimal(18,2)` on SQL Server, read back at scale 2 and rounded on write + +A `decimal` property without `HasPrecision` is a `decimal(18,2)` column. Written as `10m`, `1.005m` and +`-0.004m`, the values read back as `10.00m`, `1.01m` and `0.00m`: every value at scale 2, which is equal to +`10m` but prints `"10.00"`, and more fractional digits rounded half away from zero, without an error. diff --git a/design/knowledge/KB-051-sql-server-s-time-holds-less-than-a-day.md b/design/knowledge/KB-051-sql-server-s-time-holds-less-than-a-day.md new file mode 100644 index 00000000..503a0f9b --- /dev/null +++ b/design/knowledge/KB-051-sql-server-s-time-holds-less-than-a-day.md @@ -0,0 +1,16 @@ +--- +verified: 2026-10-29 +pins: + - external SQL-Server 2022 + - Microsoft.EntityFrameworkCore.SqlServer 10.0.12 +source: https://learn.microsoft.com/sql/t-sql/data-types/time-transact-sql (its range, 00:00:00.0000000 through 23:59:59.9999999), measured with plain EF Core on the mssql/server 2022 image, the column type, the refusal and the JSON read +turned-on: the SQL Server query catalogue's TimeSpan cases (SqlServerQueryVariantTests, issue 81), whose 1.02:00:00 is refused in a column and read as 01:02:00 from a JSON column, and the EF README's TimeSpan warning for SQL Server +test: test/CodoMetis.TypeKit.Facts/KB051.cs +--- + +# KB-051. SQL Server's `time` holds less than a day, so a longer `TimeSpan` is refused in a column and misread in JSON + +EF maps a `TimeSpan` to `time`. `SaveChanges` of `TimeSpan.FromHours(26)` into such a column throws a +`DbUpdateException`. In a JSON column (`OwnsOne(…).ToJson()`) the same value is written as its text, `"1.02:00:00"`; +an entity read whole has `1.02:00:00`, but a query that reads the property (`Select(h => h.Doc.Length)`) converts +the text to `time` and answers `01:02:00`, without an error. diff --git a/design/knowledge/KB-052-sql-server-takes-no-max-of-a-bit.md b/design/knowledge/KB-052-sql-server-takes-no-max-of-a-bit.md new file mode 100644 index 00000000..9f36d912 --- /dev/null +++ b/design/knowledge/KB-052-sql-server-takes-no-max-of-a-bit.md @@ -0,0 +1,14 @@ +--- +verified: 2026-10-29 +pins: + - external SQL-Server 2022 + - Microsoft.EntityFrameworkCore.SqlServer 10.0.12 +source: https://learn.microsoft.com/sql/t-sql/functions/max-transact-sql (MAX "can be used with numeric, char, nchar, varchar, nvarchar, uniqueidentifier, or datetime columns, but not with bit columns"), measured with plain EF Core on the mssql/server 2022 image +turned-on: the SQL Server query catalogue's bool cases (SqlServerQueryVariantTests, issue 81), where a Max or Min of a bool value object is refused for the wrapped type too +test: test/CodoMetis.TypeKit.Facts/KB052.cs +--- + +# KB-052. SQL Server takes neither `MAX` nor `MIN` of a `bit` + +EF translates `Max(f => f.Flag)` and `Min` of a `bool` column to `MAX` and `MIN` of a `bit`, and SQL Server refuses +them: a `SqlException`, "Operand data type bit is invalid for max operator" (`min` for `MIN`). diff --git a/design/knowledge/KB-053-greatest-and-least-need-compatibility-level-160.md b/design/knowledge/KB-053-greatest-and-least-need-compatibility-level-160.md new file mode 100644 index 00000000..b698279d --- /dev/null +++ b/design/knowledge/KB-053-greatest-and-least-need-compatibility-level-160.md @@ -0,0 +1,15 @@ +--- +verified: 2026-10-29 +pins: + - Microsoft.EntityFrameworkCore.SqlServer 10.0.12 +source: measured with plain EF Core, no server opened, ToQueryString at EF's default level, at 150 and at 160. EF's SQL Server function-mappings page (learn.microsoft.com/ef/core/providers/sql-server/functions) lists Math.Max as GREATEST from EF Core 9.0 and names no level +turned-on: the SQL Server query catalogue's Greatest and Least cases (SqlServerQueryVariantTests, issue 81), refused at EF's default level and translated, as the wrapped type's are, at 160 +test: test/CodoMetis.TypeKit.Facts/KB053.cs +--- + +# KB-053. EF translates `Greatest` and `Least` on SQL Server only from compatibility level 160 + +Under `UseSqlServer` at EF's default compatibility level, or at 150, `Where(p => EF.Functions.Greatest(p.Left, p.Right) > 2)` +is refused, "could not be translated", and `Select(p => EF.Functions.Least(p.Left, p.Right))` reads the two columns and +calls `Least` in memory, where it throws "switched to client-evaluation" when the query runs. With +`UseCompatibilityLevel(160)` both translate to `GREATEST` and `LEAST`. diff --git a/design/knowledge/KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md b/design/knowledge/KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md new file mode 100644 index 00000000..00aaf090 --- /dev/null +++ b/design/knowledge/KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md @@ -0,0 +1,15 @@ +--- +verified: 2026-10-29 +pins: + - external SQL-Server 2022 + - Microsoft.EntityFrameworkCore.SqlServer 10.0.12 +source: EF Core's Entity Properties page ("the SQL Server provider maps DateTime properties to datetime2(7) columns"), measured with plain EF Core on the mssql/server 2022 image +turned-on: the SQL Server round trips (SqlServerRoundTripTests, issue 81), where a DateTime value object written as UTC reads back equal, with every tick and without its kind, as a plain DateTime does +test: test/CodoMetis.TypeKit.Facts/KB054.cs +--- + +# KB-054. `datetime2` keeps every tick of a `DateTime`, and reads it back without its kind + +EF maps a `DateTime` to `datetime2`, precision 7 unless configured, so nothing is rounded: a value with 1234567 extra +ticks reads back with all of them. A `DateTime` written as UTC reads back with `Kind` Unspecified, and equal, since +`DateTime` equality ignores the kind. A `DateTimeOffset` (`datetimeoffset`) keeps its ticks and its offset. diff --git a/design/knowledge/README.md b/design/knowledge/README.md index 83ff6683..329832dc 100644 --- a/design/knowledge/README.md +++ b/design/knowledge/README.md @@ -76,3 +76,10 @@ test and later for the harness's `Baseline.Fact`. | [KB-045](KB-045-first-releases-of-the-compared-libraries.md) | The compared libraries were first published on NuGet between 2015 and 2026 | NuGet, 2026-10-29 | prose | | [KB-046](KB-046-csharp-15-unions-ship-with-the-net-11-sdk.md) | C# 15's union types and closed hierarchies need no preview flag in the .NET 11 SDK, which is still prerelease | .NET 11 SDK RC 2 | prose | | [KB-047](KB-047-a-converter-s-hints-factory-comes-after-hilo.md) | EF Core consults a converter's mapping-hints value generator after a property's generator and after HiLo, and gives a key that is no number the temporary number of its column | EF Core 10.0.12, Npgsql 10.0.3 | `KB047` | +| [KB-048](KB-048-sql-server-compares-text-ignoring-case.md) | Under SQL Server's default collation text compares and orders ignoring case and trailing spaces: `==`, `IN` and `LIKE` match rows that differ in case | SQL Server 2022, EF SqlServer 10.0.12 | `KB048`, with Docker | +| [KB-049](KB-049-sql-server-orders-a-uniqueidentifier-by-its-last-group.md) | SQL Server orders a `uniqueidentifier` by its last group first, where `Guid.CompareTo` starts at the first | SQL Server 2022, EF SqlServer 10.0.12 | `KB049`, with Docker | +| [KB-050](KB-050-a-decimal-is-decimal-18-2-on-sql-server.md) | A `decimal` is a `decimal(18,2)` on SQL Server: every value reads back at scale 2, more digits rounded on write | SQL Server 2022, EF SqlServer 10.0.12 | `KB050`, with Docker | +| [KB-051](KB-051-sql-server-s-time-holds-less-than-a-day.md) | SQL Server's `time` holds less than a day: a longer `TimeSpan` is refused in a column, and read by a query as another value from JSON | SQL Server 2022, EF SqlServer 10.0.12 | `KB051`, with Docker | +| [KB-052](KB-052-sql-server-takes-no-max-of-a-bit.md) | SQL Server takes neither `MAX` nor `MIN` of a `bit` | SQL Server 2022, EF SqlServer 10.0.12 | `KB052`, with Docker | +| [KB-053](KB-053-greatest-and-least-need-compatibility-level-160.md) | EF translates `Greatest` and `Least` on SQL Server only from compatibility level 160 | EF SqlServer 10.0.12 | `KB053` | +| [KB-054](KB-054-datetime2-keeps-every-tick-and-forgets-the-kind.md) | `datetime2` keeps every tick of a `DateTime` and reads it back with `Kind` Unspecified | SQL Server 2022, EF SqlServer 10.0.12 | `KB054`, with Docker | diff --git a/docs/ef-core.md b/docs/ef-core.md index cf842433..166ca5da 100644 --- a/docs/ef-core.md +++ b/docs/ef-core.md @@ -266,8 +266,8 @@ The block under [Keys](#keys) is the whole setup: EF's in-memory provider won't work. It has no relational type mapping, so nothing is mapped there. -This repository tests the mapping on SQLite and PostgreSQL, and every family of underlying types makes -a round trip through a real PostgreSQL. +This repository tests the mapping on SQLite, PostgreSQL and SQL Server, and every family of underlying +types makes a round trip through a real PostgreSQL and a real SQL Server. ## If something does not map diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md index d259fec8..77ae8362 100644 --- a/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md +++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/README.md @@ -216,9 +216,34 @@ instead, which works everywhere and may add a cast: and stays allowed. A query filter (`HasQueryFilter`, named or not) that does the same fails when the model is built, naming the entity type, since it applies to every query of that type. -The mapping is tested on SQLite and PostgreSQL. The SQL of each translation is pinned on PostgreSQL, -every family of underlying types makes a round trip through a real PostgreSQL, and key generation is -also checked in SQL Server's model. +### On SQL Server + +A value object behaves on SQL Server as the type inside it does there. That isn't always how it +behaves in memory: + +- **Text compares ignoring case.** Under SQL Server's default collation, `o.Note == note`, + `notes.Contains(o.Note)` and `StartsWith` also match text that differs in case or in trailing + spaces. `OrderBy` sorts ignoring case too. In memory, a value object over text equals only the same + text, exactly. Where case matters, give the column a binary collation: + `UseCollation("Latin1_General_BIN2")`. +- **Guids sort by their last group.** SQL Server orders a `uniqueidentifier` by its last six bytes + first. So `OrderBy`, `<` and `Min` over Guids come out in another order than in memory. +- **A `decimal` without a precision is a `decimal(18,2)`.** It reads back with two decimal places: + `10m` comes back as `10.00m`, which is equal but prints `10.00`. More decimal places are rounded + when the value is written. Configure the precision you need, on the property with `HasPrecision` + or for every such value object with `configurationBuilder.Properties().HavePrecision(18, 4)`. +- **A `TimeSpan` must be shorter than a day.** It's stored as `time`. A longer one fails + `SaveChanges` in a column. In a JSON column it's stored, but a query that reads it gets another + value, `01:02:00` for `1.02:00:00`, without an error. Store a longer duration as a number instead, + such as a value object over the seconds as a `long`. +- **`EF.Functions.Greatest` and `Least` need compatibility level 160.** Configure it with + `UseSqlServer(connectionString, sql => sql.UseCompatibilityLevel(160))`. Below it, EF refuses them. +- **A `DateTime` comes back without its kind.** `datetime2` keeps every tick, and a UTC value reads + back with `Kind` `Unspecified`. It's still equal to the value you wrote. + +The mapping is tested on SQLite, PostgreSQL and SQL Server. The SQL of each translation is pinned on +PostgreSQL. Every family of underlying types makes a round trip through a real PostgreSQL and a real +SQL Server, and every query and key shape of the test catalogue runs on the real SQL Server too. ## An Option for nothing found diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj index 7782386b..ce8a11d3 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/CodoMetis.TypeKit.EntityFrameworkCore.Tests.csproj @@ -2,8 +2,8 @@ @@ -20,6 +20,8 @@ + + diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs index d65a26b5..562d7368 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/PostgreSqlRoundTripTests.cs @@ -9,6 +9,7 @@ namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; /// A real PostgreSQL: every wrapped-type family survives a round trip, stored values are read back /// without the rules, and the translated queries return the rows they should. /// +[Trait("Needs", "Docker")] public sealed class PostgreSqlRoundTripTests(PostgreSqlRoundTripTests.Database database) : IClassFixture { [Fact] diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs index fdfa4e7a..9bbbf54c 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/QueryTranslationVariantTests.cs @@ -68,32 +68,59 @@ public void A_query_over_a_value_object_answers_as_the_same_query_over_the_wrapp if (expectation is Unclaimed unclaimed) Assert.Skip(unclaimed.Reason); if (expectation is OpenFinding finding) Assert.Skip($"open finding: {finding.Reason}"); - var valueObject = Run(@case, valueObject: true); + if (!AnswersAsTheWrappedType(@case, expectation, Run, out _, out var plain)) return; + + // PostgreSQL translates where it would refuse what SQLite runs: an element column re-typed to the + // wrapped type, for one, threw there alone. + if (plain is null || PostgreSql(@case, valueObject: false) is not { Refusal: null } wrapped) return; + var translated = PostgreSql(@case, valueObject: true)!; + if (translated.Refusal is not null) + throw new ShouldAssertException($"PostgreSQL refuses what it translates over the wrapped type: {translated.Refusal}"); + if (ClaimsTheColumn(@case)) + translated.Sql.ShouldBe(wrapped.Sql, "PostgreSQL's SQL of the same query over the wrapped type"); + } + + /// + /// The case's query over the value object, run by , against its expectation: refused as the + /// docs say, or answering the rows (and, on a table's column, the SQL) of the same query over the wrapped type, or + /// memory's rows where that query is refused or not written. False where a refusal settles the case. + /// + /// + /// names the cases a database is known to answer differently from memory, a fact of + /// design/knowledge: where memory is the oracle, such a case is left to the caller. + /// + internal static bool AnswersAsTheWrappedType(QueryCase @case, Expectation expectation, Func run, out Answer valueObject, out Answer? plain, + Func? differs = null) + { + valueObject = run(@case, true); + plain = null; switch (expectation) { case RefusedOrdering: Refusal(valueObject, "its ordering is its own CompareTo").ShouldContain(@case.Kind.ValueObject.Name); - return; + return false; // The same last step through EF.Functions, which EF takes in memory too, where a database's function cannot run. case RefusedForeign when @case is { Op: Op.Greatest or Op.Least, Site: Site.Column or Site.Nullable }: Refusal(valueObject, "is not supported because the query has switched to client-evaluation"); - return; + return false; case RefusedForeign when @case.Op is Op.Select or Op.Distinct: var memory = Memory(@case); if (memory.Refusal is { } thrown) { // r.N!.Value over the null row, read through the converter and then unwrapped in memory, as memory does. valueObject.Refusal.ShouldBeOfType(thrown.GetType(), "the query's last step, which throws in memory too"); - return; + return false; } valueObject.Refusal.ShouldBeNull("the query's last step, which EF evaluates in memory after reading the value object through its converter"); valueObject.Rows.ShouldBe(memory.Rows, "the query's last step, evaluated in memory"); - return; + return false; case RefusedForeign: Refusal(valueObject, "could not be translated"); - return; + return false; + case NullsLeftOut when differs?.Invoke(@case, valueObject, Memory(@case)) == true: + return false; case NullsLeftOut: valueObject.Refusal.ShouldBeNull(); var nulls = NullRows(@case); @@ -102,16 +129,17 @@ public void A_query_over_a_value_object_answers_as_the_same_query_over_the_wrapp "the rows memory keeps are the query's and the null rows, which the generated operators sort first"); // A string has no < to write the wrapped type's query with; memory is no oracle here. - if (!new QueryBuilder(@case, valueObject: false).CanBuild) return; + if (!new QueryBuilder(@case, valueObject: false).CanBuild) return false; break; } - var plain = new QueryBuilder(@case, valueObject: false).CanBuild ? Run(@case, valueObject: false) : null; + plain = new QueryBuilder(@case, valueObject: false).CanBuild ? run(@case, false) : null; + if (plain is not { Refusal: null } && differs?.Invoke(@case, valueObject, Memory(@case)) == true) return false; if (valueObject.Refusal is not null) { if (plain is { Refusal: null }) throw new ShouldAssertException($"refused where the same query over the wrapped type answers [{string.Join(", ", plain.Rows!)}]: {valueObject.Refusal}"); if (plain is null) throw new ShouldAssertException($"refused, where memory answers {Shown(Memory(@case))}: {valueObject.Refusal}"); - return; + return false; } if (plain is { Refusal: null }) @@ -125,14 +153,7 @@ public void A_query_over_a_value_object_answers_as_the_same_query_over_the_wrapp valueObject.Rows.ShouldBe(Memory(@case).Rows, $"the rows memory answers, where the wrapped type's query is {(plain is null ? "not written" : "refused")}"); } - // PostgreSQL translates where it would refuse what SQLite runs: an element column re-typed to the - // wrapped type, for one, threw there alone. - if (plain is null || PostgreSql(@case, valueObject: false) is not { Refusal: null } wrapped) return; - var translated = PostgreSql(@case, valueObject: true)!; - if (translated.Refusal is not null) - throw new ShouldAssertException($"PostgreSQL refuses what it translates over the wrapped type: {translated.Refusal}"); - if (ClaimsTheColumn(@case)) - translated.Sql.ShouldBe(wrapped.Sql, "PostgreSQL's SQL of the same query over the wrapped type"); + return true; } /// A guard against a vacuous run: the catalogue's floor, every kind, site and accessor in it, and the refusals it expects. @@ -151,34 +172,34 @@ public void The_catalogue_reaches_every_kind_site_accessor_and_converter() all.Count(@case => Expect(@case) is NullsLeftOut).ShouldBeGreaterThan(5); } - private abstract record Expectation; + internal abstract record Expectation; /// Answers as the same query over the wrapped type. - private sealed record Same : Expectation; + internal sealed record Same : Expectation; /// Refused, naming the value object whose own CompareTo the database cannot run. - private sealed record RefusedOrdering : Expectation; + internal sealed record RefusedOrdering : Expectation; /// /// Refused by EF, "could not be translated": the column holds what a converter of the application's own /// stores. A final Select or Distinct, which EF evaluates in memory after reading the value /// object through that converter, answers as memory does. /// - private sealed record RefusedForeign : Expectation; + internal sealed record RefusedForeign : Expectation; /// /// Answers as the same query over the wrapped type, where a null passes no comparison, which is fewer rows /// than memory's: a record class's generated operators sort null first (decision 98). /// - private sealed record NullsLeftOut : Expectation; + internal sealed record NullsLeftOut : Expectation; /// A divergence the catalogue found and no decision settles yet: named here, never dropped from the catalogue. - private sealed record OpenFinding(string Reason) : Expectation; + internal sealed record OpenFinding(string Reason) : Expectation; - /// No sentence of the docs says what the case answers. - private sealed record Unclaimed(string Reason) : Expectation; + /// No sentence of the docs says what the case answers; where that is SQLite's doing alone. + internal sealed record Unclaimed(string Reason, bool SqliteOnly = false) : Expectation; - private static Expectation Expect(QueryCase @case) + internal static Expectation Expect(QueryCase @case) { if (@case.IsForeign) { @@ -194,7 +215,7 @@ private static Expectation Expect(QueryCase @case) // JSON text EF stores: "1:2:00:00" before "2:00:00", "09:30:00.0000000" against a parameter '09:30:00'. The // wrapped type's own max over a TimeSpan column answers the same text order (measured on 2026-10-16). if (@case is { Site: Site.CollectionElement, Op: Op.Greatest or Op.Least, ReadsWrapped: true } && (@case.Kind.Wrapped == typeof(TimeSpan) || @case.Kind.Wrapped == typeof(TimeOnly))) - return new Unclaimed("SQLite orders a primitive collection's TimeSpan and TimeOnly elements as the text EF stores, and the wrapped type's query is refused there"); + return new Unclaimed("SQLite orders a primitive collection's TimeSpan and TimeOnly elements as the text EF stores, and the wrapped type's query is refused there", SqliteOnly: true); // The generated operators of a record class sort null first, so in memory null < x is true; SQL's // "N" < @x is unknown for a null N, as it is for the wrapped type's lifted operator. @@ -226,12 +247,12 @@ private static string Refusal(Answer answer, string expected) return answer.Refusal.Message; } - private sealed record Answer(string[]? Rows, string? Sql, Exception? Refusal); + internal sealed record Answer(string[]? Rows, string? Sql, Exception? Refusal); /// One in-memory SQLite database per model, seeded once and only read. private static readonly ConcurrentDictionary> Databases = new(); - private static Answer Run(QueryCase @case, bool valueObject) + internal static Answer Run(QueryCase @case, bool valueObject) { var builder = new QueryBuilder(@case, valueObject); var key = @case.IsFilter @@ -281,7 +302,7 @@ private static Answer Run(QueryCase @case, bool valueObject) /// The same query over the value objects in memory, with the query filter applied as EF would, and /// EF.Functions answered as does. /// - private static Answer Memory(QueryCase @case) + internal static Answer Memory(QueryCase @case) { var builder = new QueryBuilder(@case, valueObject: true); var source = Queryable.AsQueryable(Seed.Entities(builder, @case.Kind, valueObject: true)); @@ -302,7 +323,9 @@ private static Answer Memory(QueryCase @case) /// /// EF.Functions.Greatest, Least and Npgsql's row-value comparisons, which throw outside a query, as /// SQLite answers them: by each type's default comparer, which is the wrapped type's order for a value object - /// that declares none of its own, and a null wherever Greatest or Least meets one. + /// that declares none of its own, and a null wherever Greatest or Least meets one. Text compares + /// ordinally, as SQLite's max and a value object's generated comparison do, where a string's default + /// comparer follows the culture. /// private sealed class InMemoryFunctions : ExpressionVisitor { @@ -322,7 +345,9 @@ protected override Expression VisitMethodCall(MethodCallExpression node) public static T Least(T[] values) => Pick(values, order => order < 0); private static T Pick(T[] values, Func keeps) => - values.Any(value => value is null) ? default! : values.Aggregate((kept, value) => keeps(Comparer.Default.Compare(value, kept)) ? value : kept); + values.Any(value => value is null) ? default! : values.Aggregate((kept, value) => keeps(Order().Compare(value, kept)) ? value : kept); + + private static IComparer Order() => typeof(T) == typeof(string) ? (IComparer)StringComparer.Ordinal : Comparer.Default; /// A tuple's own order is its elements', one after the other, each by its default comparer. public static bool RowValue(string comparison, ITuple left, ITuple right) @@ -339,7 +364,7 @@ public static bool RowValue(string comparison, ITuple left, ITuple right) } /// An answer as a message shows it: its rows, or its refusal. - private static string Shown(Answer answer) => + internal static string Shown(Answer answer) => answer.Refusal is { } refusal ? $"refused ({refusal.Message})" : $"[{string.Join(", ", answer.Rows!)}]"; private static (SqliteConnection, Exception?) Seeded(QueryModel model, QueryBuilder builder) @@ -360,7 +385,7 @@ private static (SqliteConnection, Exception?) Seeded(QueryModel model, QueryBuil } } - private static Answer Execute(IQueryProvider provider, Expression query, bool ordered, bool sql) + internal static Answer Execute(IQueryProvider provider, Expression query, bool ordered, bool sql) { if (!typeof(IQueryable).IsAssignableFrom(query.Type)) return new Answer([Text(provider.Execute(query))], null, null); @@ -379,5 +404,5 @@ private static string Text(object? value) => ? $"#{id.GetValue(value)}" : WrappedKind.Text(value); - private static Exception Unwrapped(Exception exception) => exception is TargetInvocationException { InnerException: { } inner } ? Unwrapped(inner) : exception; + internal static Exception Unwrapped(Exception exception) => exception is TargetInvocationException { InnerException: { } inner } ? Unwrapped(inner) : exception; } diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs new file mode 100644 index 00000000..f53ac770 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServer.cs @@ -0,0 +1,91 @@ +using System.Collections.Concurrent; +using Microsoft.Data.SqlClient; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// One SQL Server container for every class of the , started when the first of +/// them runs: the server takes seconds to start, and a class that needs it never starts one of its own. +/// +/// +/// SQL Server 2022's Linux image, which CI runs and the design/knowledge entries pin. It is amd64 only: on an +/// arm64 machine Docker must emulate it with Rosetta, since under QEMU it stops at start ("Unable to create a new +/// asynchronous I/O context"). TYPEKIT_MSSQL_IMAGE names another image, such as the native arm64 +/// mcr.microsoft.com/azure-sql-edge, whose older engine lacks some of 2022's functions. A database is made +/// for each class (), under the server's default collation, +/// SQL_Latin1_General_CP1_CI_AS, which is case-insensitive. +/// +public sealed class SqlServer : IAsyncLifetime +{ + public const string ImageVariable = "TYPEKIT_MSSQL_IMAGE"; + + private const string Password = "TypeKit-2026!"; + + /// Started by its log line: Testcontainers' module waits through sqlcmd, which Azure SQL Edge's image has not. + private readonly IContainer _container = + new ContainerBuilder(Environment.GetEnvironmentVariable(ImageVariable) is { Length: > 0 } image ? image : "mcr.microsoft.com/mssql/server:2022-latest") + .WithEnvironment("ACCEPT_EULA", "Y") + .WithEnvironment("MSSQL_SA_PASSWORD", Password) + .WithPortBinding(1433, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("SQL Server is now ready for client connections")) + .Build(); + + private readonly ConcurrentDictionary> _databases = new(); + + private string ConnectionString(string database) => new SqlConnectionStringBuilder + { + DataSource = $"{_container.Hostname},{_container.GetMappedPublicPort(1433)}", + UserID = "sa", + Password = Password, + InitialCatalog = database, + TrustServerCertificate = true, + }.ConnectionString; + + /// A database of its own on the server, created empty the first time it is asked for, and its connection string. + public Task DatabaseAsync(string name) => _databases.GetOrAdd(name, CreateAsync); + + private async Task CreateAsync(string name) + { + await using var connection = await OpenAsync(ConnectionString("master")); + await using var create = connection.CreateCommand(); + create.CommandText = $"IF DB_ID(N'{name}') IS NOT NULL DROP DATABASE [{name}]; CREATE DATABASE [{name}];"; + await create.ExecuteNonQueryAsync(); + + return ConnectionString(name); + } + + /// + /// The server logs that it is ready before the login works, the first time it starts (a "Login failed for user 'sa'" + /// for some seconds after), so the first connection is tried again for a minute. + /// + private static async Task OpenAsync(string connectionString) + { + for (var attempt = 1; ; attempt++) + { + var connection = new SqlConnection(connectionString); + try + { + await connection.OpenAsync(); + return connection; + } + catch (SqlException) when (attempt < 60) + { + await connection.DisposeAsync(); + await Task.Delay(TimeSpan.FromSeconds(1)); + } + } + } + + public async ValueTask InitializeAsync() => await _container.StartAsync(); + + public async ValueTask DisposeAsync() => await _container.DisposeAsync(); +} + +/// The classes that share the SQL Server container; CI's Windows and macOS legs, which cannot start it, leave them out. +[CollectionDefinition(Name)] +public sealed class SqlServerCollection : ICollectionFixture +{ + public const string Name = "SQL Server"; +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerKeyVariantTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerKeyVariantTests.cs new file mode 100644 index 00000000..ef42e4b1 --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerKeyVariantTests.cs @@ -0,0 +1,188 @@ +using System.Globalization; +using CodoMetis.TypeKit.EntityFrameworkCore.Tests.Variants; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// The key-generation catalogue () inserted into a real SQL Server and read back: every shape +/// keyed by every kind of , under UseTypeKit() and keyed by the wrapped type +/// under plain EF, each in a schema of its own. +/// +/// +/// holds the model: a value-object key is generated as a key of the wrapped +/// type is. Here the database answers. A key the model generates is left unset, one it does not is assigned, and +/// every key reads back as it was after SaveChanges. Where the database numbers the keys (an identity column, +/// TPC's sequence), the value object gets the numbers the wrapped type gets. +/// +[Collection(SqlServerCollection.Name)] +[Trait("Needs", "Docker")] +public sealed class SqlServerKeyVariantTests(SqlServer server) +{ + public static TheoryData Cases + { + get + { + var cases = new TheoryData(); + foreach (var (_, shape, kind) in KeyShapes.All().Where(@case => @case.Provider == "sqlserver")) cases.Add(shape, kind.Name); + return cases; + } + } + + [Theory] + [MemberData(nameof(Cases))] + public async Task A_value_object_key_is_stored_and_read_back_as_a_key_of_the_wrapped_type_is(KeyShape shape, string kind) + { + var wrapped = WrappedKinds.Named(kind); + var database = await server.DatabaseAsync("typekit_keys"); + + var plain = Inserted(shape, wrapped, typeKit: false, database); + var valueObject = Inserted(shape, wrapped, typeKit: true, database); + var keys = $"{shape} keyed by {kind}: value object [{string.Join(", ", valueObject.Saved)}], wrapped type [{string.Join(", ", plain.Saved)}]"; + + if (plain.Refusal is not null) + { + if (valueObject.Refusal is not null) Assert.Skip($"refused for the wrapped type and the value object alike: {plain.Refusal}"); + + // A key over char, which the application assigns (the README's exception), where EF generates the plain one as NULL. + wrapped.Wrapped.ShouldBe(typeof(char), $"stored where the wrapped type's keys are refused ({plain.Refusal}); {keys}"); + ReadBack(valueObject, wrapped).ShouldBe(Saved(valueObject, wrapped), ignoreOrder: true, $"the keys read back; {keys}"); + return; + } + + valueObject.Refusal.ShouldBeNull(keys); + ReadBack(valueObject, wrapped).ShouldBe(Saved(valueObject, wrapped), ignoreOrder: true, $"the keys read back; {keys}"); + ReadBack(plain, wrapped).ShouldBe(Saved(plain, wrapped), ignoreOrder: true, $"the wrapped type's keys read back; {keys}"); + + // The README's two exceptions: a key over char or Guid is the application's to assign unless configured. + var assigned = (wrapped.Wrapped == typeof(char) || wrapped.Wrapped == typeof(Guid)) && shape is not (KeyShape.ConfiguredOnAdd or KeyShape.ConfiguredNever); + valueObject.Generated.ShouldBe(plain.Generated && !assigned, keys); + valueObject.Numbered.ShouldBe(plain.Numbered && !assigned, keys); + if (plain.Numbered && IsInteger(wrapped.Wrapped)) + valueObject.Saved.ShouldBe(plain.Saved, $"the keys SQL Server numbered; {keys}"); + } + + /// A guard against a vacuous run: every shape crossed with every key kind, and the database numbering some. + [Fact] + public void The_catalogue_crosses_every_shape_with_every_key_kind() => + Cases.Count.ShouldBe(Enum.GetValues().Length * WrappedKinds.Keys.Length); + + /// + /// A decimal reads back at its column's scale, 1.50 for 1.5 in SQL Server's default decimal(18,2) + /// (KB-050), and is the same number: decimal keys are compared as numbers. + /// + private static string[] ReadBack(Keys keys, WrappedKind kind) => kind.Wrapped == typeof(decimal) ? [.. keys.Read.Select(Number)] : keys.Read; + + private static string[] Saved(Keys keys, WrappedKind kind) => kind.Wrapped == typeof(decimal) ? [.. keys.Saved.Select(Number)] : keys.Saved; + + private static string Number(string key) => + string.Join("/", key.Split('/').Select(part => decimal.Parse(part, CultureInfo.InvariantCulture).ToString("0.############", CultureInfo.InvariantCulture))); + + private static int _schemas; + + /// The keys saved and read back, whether the subject's key is generated, and whether the database left any key it saved to the database. + private sealed record Keys(string[] Saved, string[] Read, bool Generated, bool Numbered, string? Refusal); + + /// Two entities of the shape saved, the keys they have after SaveChanges, and the keys a fresh context reads. + private static Keys Inserted(KeyShape shape, WrappedKind kind, bool typeKit, string database) + { + var key = typeKit ? kind.ValueObject : kind.Wrapped; + var schema = $"k{Interlocked.Increment(ref _schemas)}"; + try + { + using (var create = KeyShapeDb.OnSqlServer(shape, key, typeKit, database, schema)) + create.GetService().CreateTables(); + + string[] saved; + bool generated, numbered; + using (var db = KeyShapeDb.OnSqlServer(shape, key, typeKit, database, schema)) + { + generated = KeyShapes.KeyOf(db, shape, key) is [{ ValueGenerated: ValueGenerated.OnAdd }]; + object Value(int index) => typeKit ? kind.Wrap(kind.Values[index]) : kind.Values[index]; + (saved, numbered) = Save(db, shape, key, Value); + } + + using var read = KeyShapeDb.OnSqlServer(shape, key, typeKit, database, schema); + return new Keys(saved, Read(read, shape, key), generated, numbered, null); + } + catch (Exception exception) when (exception is not ShouldAssertException) + { + return new Keys([], [], false, false, $"{exception.GetType().Name}: {exception.GetBaseException().Message}"); + } + } + + /// The shape's entities saved, each key left to the model's generation or assigned, and whether any was left. + private static (string[] Saved, bool Numbered) Save(KeyShapeDb db, KeyShape shape, Type key, Func value) + { + var numbered = false; + Type Closed(Type definition) => definition.MakeGenericType(key); + object New(Type type, int index) + { + var entity = Activator.CreateInstance(type)!; + if (db.Model.FindEntityType(type)!.FindPrimaryKey()!.Properties is [{ ValueGenerated: ValueGenerated.OnAdd }]) numbered = true; + else type.GetProperty("Id")!.SetValue(entity, value(index)); + return entity; + } + + object[] entities = shape switch + { + KeyShape.TphRoot or KeyShape.TptRoot or KeyShape.TpcRoot => [New(Closed(typeof(Root<>)), 0), New(Closed(typeof(Leaf<>)), 1)], + KeyShape.AbstractTphRoot or KeyShape.AbstractTptRoot or KeyShape.AbstractTpcRoot => [New(Closed(typeof(LeafA<>)), 0), New(Closed(typeof(LeafB<>)), 1)], + KeyShape.OwnedDependent => [New(Closed(typeof(Owner<>)), 0), New(Closed(typeof(Owner<>)), 1)], + KeyShape.SharedKeyDependent or KeyShape.TableSplitDependent => [New(Closed(typeof(Principal<>)), 0), New(Closed(typeof(Principal<>)), 1)], + KeyShape.Composite => [Composite(key, value(0), value(1)), Composite(key, value(1), value(0))], + _ => [New(Closed(typeof(Alone<>)), 0), New(Closed(typeof(Alone<>)), 1)], + }; + db.AddRange(entities); + db.SaveChanges(); + + // A dependent's key is its principal's, so the dependents follow the principals they share it with. + if (shape is KeyShape.SharedKeyDependent or KeyShape.TableSplitDependent) + { + object[] dependents = [.. entities.Select(principal => + { + var dependent = Activator.CreateInstance(Closed(typeof(Dependent<>)))!; + dependent.GetType().GetProperty("Id")!.SetValue(dependent, principal.GetType().GetProperty("Id")!.GetValue(principal)); + return dependent; + })]; + db.AddRange(dependents); + db.SaveChanges(); + entities = dependents; + } + + return ([.. entities.Select(Text)], numbered); + } + + private static object Composite(Type key, object id, object line) + { + var entity = Activator.CreateInstance(typeof(Composite<>).MakeGenericType(key))!; + entity.GetType().GetProperty("Id")!.SetValue(entity, id); + entity.GetType().GetProperty("Line")!.SetValue(entity, line); + return entity; + } + + /// The keys of the entities the shape is about, as a fresh context reads them. + private static string[] Read(KeyShapeDb db, KeyShape shape, Type key) + { + var type = shape switch + { + KeyShape.OwnedDependent => typeof(Owner<>).MakeGenericType(key), + _ => KeyShapes.Subject(shape, key), + }; + var set = (IQueryable)typeof(DbContext).GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!.MakeGenericMethod(type).Invoke(db, null)!; + return [.. set.Cast().AsEnumerable().Select(Text)]; + } + + /// An entity's key as text, both properties of a composite one. + private static string Text(object entity) => + entity.GetType().GetProperty("Line") is { } line + ? $"{WrappedKind.Text(entity.GetType().GetProperty("Id")!.GetValue(entity))}/{WrappedKind.Text(line.GetValue(entity))}" + : WrappedKind.Text(entity.GetType().GetProperty("Id")!.GetValue(entity)); + + private static bool IsInteger(Type type) => + type == typeof(int) || type == typeof(long) || type == typeof(short) || type == typeof(sbyte) || type == typeof(byte) + || type == typeof(uint) || type == typeof(ulong) || type == typeof(ushort); +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerQueryVariantTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerQueryVariantTests.cs new file mode 100644 index 00000000..aafe945f --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerQueryVariantTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Concurrent; +using System.Globalization; +using CodoMetis.TypeKit.EntityFrameworkCore.Tests.Variants; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using static CodoMetis.TypeKit.EntityFrameworkCore.Tests.QueryTranslationVariantTests; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// The query-translation catalogue () run on a real SQL Server: each query over a value +/// object against the same query over the wrapped type there, as runs +/// it on SQLite, and then against the same query over the objects in memory. +/// +/// +/// An answer that differs from memory's is SQL Server's own only where SQLite answers as memory does: a null +/// .Value that throws in memory and is a NULL in SQL, or a query EF translates for neither, is the relational +/// providers' alike. Each difference that is SQL Server's alone is a fact of design/knowledge, named in +/// ; any other fails. +/// +[Collection(SqlServerCollection.Name)] +[Trait("Needs", "Docker")] +public sealed class SqlServerQueryVariantTests(SqlServer server) +{ + public static TheoryData Cases => QueryTranslationVariantTests.Cases; + + [Theory] + [MemberData(nameof(Cases))] + public async Task On_SQL_Server_a_query_over_a_value_object_answers_as_the_same_query_over_the_wrapped_type_and_as_memory( + string kind, Conversion conversion, Site site, Accessor accessor, Op op) + { + var @case = QueryShapes.Find(new QueryCase(WrappedKinds.Named(kind), conversion, site, accessor, op).Id); + var expectation = Expect(@case); + if (expectation is Unclaimed { SqliteOnly: false } unclaimed) Assert.Skip(unclaimed.Reason); + if (expectation is OpenFinding finding) Assert.Skip($"open finding: {finding.Reason}"); + if (expectation is Unclaimed) expectation = new Same(); + + var database = await server.DatabaseAsync("typekit_queries"); + AnswersAsTheWrappedType(@case, expectation, (c, valueObject) => Run(c, valueObject, database), out var answer, out _, + differs: (c, onSqlServer, memory) => Known(c, onSqlServer, memory) is not null); + if (expectation is not (Same or NullsLeftOut)) return; + + // EF.Functions.Greatest and Least, which EF translates on SQL Server from compatibility level 160 alone (KB-053): + // there the SQL is the wrapped type's, translated without a server. + if (@case.Op is Op.Greatest or Op.Least && answer.Refusal is not null) + { + // Over a primitive collection's element EF refuses them on SQLite too, and a TimeSpan table is refused its rows (KB-051). + if (Refusal(answer) is not DbUpdateException) + answer.Refusal!.Message.ShouldMatch("is not supported because the query has switched to client-evaluation|could not be translated"); + AtLevel160(@case, database); + return; + } + + var memory = Memory(@case); + if (Agree(answer, memory)) return; + + var sqlite = QueryTranslationVariantTests.Run(@case, valueObject: true); + if (Agree(answer, sqlite)) return; + + // r.N!.Value.Value throws in memory on the row without one, where SQL reads a NULL, as ValueOrNull() does. + if (memory.Refusal is not null && @case is { Site: Site.Nullable, Accessor: Accessor.ValueThroughTypeParameter } + && QueryShapes.All().FirstOrDefault(other => other == @case with { Accessor = Accessor.ValueOrNull }) is { } orNull) + { + answer.Rows.ShouldBe(Run(orNull, valueObject: true, database).Rows, $"the same query through ValueOrNull(), where memory throws: {memory.Refusal.Message}"); + return; + } + + if (Known(@case, answer, memory) is null) + throw new ShouldAssertException($"SQL Server answers {Shown(answer)}, where memory answers {Shown(memory)} and SQLite {Shown(sqlite)}"); + } + + /// + /// A guard against a vacuous run: the catalogue holds the cases each fact is about, and the facts that a case's + /// answer decides (a decimal's scale, a refused aggregate) excuse no other answer. + /// + [Fact] + public void Every_known_difference_is_one_the_catalogue_reaches() + { + Answer Rows(params string[] rows) => new(rows, null, null); + + QueryShapes.All().Select(@case => Known(@case, Rows("0"), Rows("1"))).OfType().Distinct().ShouldBe([TextCollation, GuidOrder, TimeUnderADay], ignoreOrder: true); + var decimals = QueryShapes.All().Where(@case => @case.Kind == WrappedKinds.Decimal).ToList(); + decimals.Any(@case => Known(@case, Rows("10.00"), Rows("10")) == DecimalScale).ShouldBeTrue("a decimal read back at another scale"); + decimals.All(@case => Known(@case, Rows("10.01"), Rows("10")) is null).ShouldBeTrue("a decimal read back as another number is no difference of scale"); + var refused = new Answer(null, null, new InvalidOperationException("Operand data type bit is invalid for max operator.")); + QueryShapes.All().Where(@case => @case.Kind == WrappedKinds.Boolean).Any(@case => Known(@case, refused, Rows("1")) == BitAggregate).ShouldBeTrue("a refused MAX of a bit"); + } + + private const string TextCollation = "KB-048: SQL Server compares and orders text by the database's collation, case-insensitive by default"; + + private const string GuidOrder = "KB-049: SQL Server orders a uniqueidentifier by its last group first"; + + private const string DecimalScale = "KB-050: a decimal reads back at its column's scale, decimal(18,2) unless configured"; + + private const string TimeUnderADay = "KB-051: SQL Server's time holds less than a day, so a TimeSpan of a day or more is refused in a column and misread in JSON"; + + private const string BitAggregate = "KB-052: SQL Server takes neither MAX nor MIN of a bit"; + + /// + /// The differences from memory that are SQL Server's own, each pinned in design/knowledge with a test of plain + /// EF Core there (test/CodoMetis.TypeKit.Facts); null for any other. + /// + private static string? Known(QueryCase @case, Answer answer, Answer memory) + { + var wrapped = @case.Kind.Wrapped; + if ((wrapped == typeof(string) || wrapped == typeof(char)) && @case.Orders && answer.Refusal is null) return TextCollation; + if (wrapped == typeof(Guid) && @case.Orders && answer.Refusal is null) return GuidOrder; + if (wrapped == typeof(decimal) && answer.Rows is { } rows && memory.Rows is { } expected && rows.Length == expected.Length + && rows.Zip(expected).All(pair => pair.First == pair.Second || decimal.TryParse(pair.First, CultureInfo.InvariantCulture, out var read) && read == decimal.Parse(pair.Second, CultureInfo.InvariantCulture))) + return DecimalScale; + if (wrapped == typeof(TimeSpan) && (answer.Refusal is DbUpdateException || @case.Site is Site.JsonProperty or Site.CollectionElement)) return TimeUnderADay; + if (wrapped == typeof(bool) && answer.Refusal?.Message.Contains("Operand data type bit is invalid", StringComparison.Ordinal) == true) return BitAggregate; + return null; + } + + /// The SQL of the case at compatibility level 160, over the value object and over the wrapped type, written without a server. + private static void AtLevel160(QueryCase @case, string database) + { + Answer Translated(bool valueObject) + { + var builder = new QueryBuilder(@case, valueObject); + using var db = QueryDb.OnSqlServer(new QueryModel($"{@case.Id} | {valueObject} | 160", @case, valueObject), database, compatibilityLevel: 160); + try + { + var set = (IQueryable)typeof(DbContext).GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!.MakeGenericMethod(builder.Source).Invoke(db, null)!; + return new Answer(null, set.Provider.CreateQuery(builder.Build(set.Expression).Query).ToQueryString(), null); + } + catch (Exception exception) when (exception is not ShouldAssertException) + { + return new Answer(null, null, Unwrapped(exception)); + } + } + + if (!new QueryBuilder(@case, valueObject: false).CanBuild || Translated(valueObject: false) is not { Refusal: null } plain) return; + + var translated = Translated(valueObject: true); + translated.Refusal.ShouldBeNull("refused at compatibility level 160, where the same query over the wrapped type translates"); + translated.Sql.ShouldBe(plain.Sql, "the SQL of the same query over the wrapped type at compatibility level 160"); + } + + private static Exception Refusal(Answer answer) => answer.Refusal ?? throw new ShouldAssertException($"answered {Shown(answer)}"); + + /// The same rows, or both refused. + private static bool Agree(Answer left, Answer right) => + left.Refusal is not null && right.Refusal is not null || left.Rows is { } rows && right.Rows is { } other && rows.SequenceEqual(other); + + /// The schema each model was seeded into, all in one database, seeded once and only read. + private static readonly ConcurrentDictionary> Schemas = new(); + + private static int _schemas; + + private static Answer Run(QueryCase @case, bool valueObject, string database) + { + var builder = new QueryBuilder(@case, valueObject); + var key = @case.IsFilter + ? $"{@case.Id} | {valueObject}" + : $"{@case.Kind.Name} | {(valueObject ? @case.Conversion : Conversion.TypeKit)} | {builder.Source.Name} | {valueObject}"; + var (model, refusal) = Schemas.GetOrAdd(key, _ => new Lazy<(QueryModel, Exception?)>(() => + { + var seeded = new QueryModel(key, @case, valueObject, $"m{Interlocked.Increment(ref _schemas)}"); + return (seeded, Seeded(seeded, builder, database)); + })).Value; + if (refusal is not null) return new Answer(null, null, refusal); + + using var db = QueryDb.OnSqlServer(model, database); + try + { + var set = (IQueryable)typeof(DbContext).GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!.MakeGenericMethod(builder.Source).Invoke(db, null)!; + var (query, ordered) = builder.Build(set.Expression); + var answer = Execute(set.Provider, query, ordered, sql: true); + + // Each model has a schema of its own; the SQL compared is the rest. + return answer with { Sql = answer.Sql?.Replace($"[{model.Schema}].", "", StringComparison.Ordinal) }; + } + catch (Exception exception) when (exception is not ShouldAssertException) + { + return new Answer(null, null, Unwrapped(exception)); + } + } + + private static Exception? Seeded(QueryModel model, QueryBuilder builder, string database) + { + try + { + using var db = QueryDb.OnSqlServer(model, database); + db.GetService().CreateTables(); + db.AddRange(Seed.Stored(builder, model.Case.Kind, model.ValueObject)); + db.SaveChanges(); + return null; + } + catch (Exception exception) when (exception is not ShouldAssertException) + { + return Unwrapped(exception); + } + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs new file mode 100644 index 00000000..52cd744f --- /dev/null +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/SqlServerRoundTripTests.cs @@ -0,0 +1,176 @@ +using System.Globalization; +using CodoMetis.TypeKit.Generators.Probes; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace CodoMetis.TypeKit.EntityFrameworkCore.Tests; + +/// +/// A real SQL Server, as is a real PostgreSQL: every wrapped-type family survives +/// a round trip, stored values are read back without the rules, the translated queries return the rows they should, +/// and what SQL Server does differently from memory (design/knowledge KB-048 to KB-054) a value object does as the +/// type it wraps does. +/// +[Collection(SqlServerCollection.Name)] +[Trait("Needs", "Docker")] +public sealed class SqlServerRoundTripTests(SqlServer server) +{ + private async Task CreateAsync() + { + var options = new DbContextOptionsBuilder().UseSqlServer(await server.DatabaseAsync("typekit_round_trips")).UseTypeKit() + .ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) + .Options; + var db = new TestDb(options); + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + return db; + } + + private async Task<(Customer Customer, Order Order)> SeedAsync(Action? adjust = null) + { + var customer = new Customer { Id = CustomerId.New(), Name = $"customer {Guid.NewGuid():N}" }; + var order = Order.Sample(customer.Id); + adjust?.Invoke(order); + + await using var db = await CreateAsync(); + db.AddRange(customer, order); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + + return (customer, order); + } + + [Fact] + public async Task Every_value_object_round_trips_and_the_navigation_loads() + { + var (customer, saved) = await SeedAsync(); + + await using var db = await CreateAsync(); + var loaded = await db.Orders.Include(o => o.Customer).SingleAsync(o => o.Id == saved.Id, TestContext.Current.CancellationToken); + + loaded.CustomerId.ShouldBe(customer.Id); + loaded.Customer!.Name.ShouldBe(customer.Name); + loaded.Code.ShouldBe(saved.Code); + loaded.Discount.ShouldBe(saved.Discount); + loaded.Quantity.ShouldBe(saved.Quantity); + loaded.Amount.ShouldBe(saved.Amount); + loaded.Urgent.ShouldBe(saved.Urgent); + loaded.PlacedAt.ShouldBe(saved.PlacedAt); + loaded.DueOn.ShouldBe(saved.DueOn); + loaded.ConfirmedAt.ShouldBe(saved.ConfirmedAt); + loaded.Slot.ShouldBe(saved.Slot); + loaded.Note.ShouldBe(saved.Note); + loaded.Tags.ShouldBe(saved.Tags); + + // KB-054: datetime2 keeps the ticks, and reads a DateTime back without its kind, as a plain DateTime column does. + loaded.PlacedAt.Value.Ticks.ShouldBe(saved.PlacedAt.Value.Ticks); + loaded.PlacedAt.Value.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public async Task An_absent_optional_value_object_round_trips_as_null() + { + var (_, saved) = await SeedAsync(order => { order.Discount = null; order.Note = null; }); + + await using var db = await CreateAsync(); + var loaded = await db.Orders.SingleAsync(o => o.Id == saved.Id, TestContext.Current.CancellationToken); + + loaded.Discount.ShouldBeNull(); + loaded.Note.ShouldBeNull(); + } + + /// A rule added after a row was written must not make it unreadable: the column is read through the materializer. + [Fact] + public async Task A_stored_value_the_rules_now_refuse_is_read_back_as_stored() + { + var (_, saved) = await SeedAsync(); + + await using (var db = await CreateAsync()) + await db.Database.ExecuteSqlAsync($"UPDATE [Orders] SET [Code] = 'lower', [Discount] = 101 WHERE [Id] = {saved.Id.Value}", TestContext.Current.CancellationToken); + + await using var read = await CreateAsync(); + var loaded = await read.Orders.AsNoTracking().SingleAsync(o => o.Id == saved.Id, TestContext.Current.CancellationToken); + + loaded.Code.Value.ShouldBe("lower"); + loaded.Discount!.Value.Value.ShouldBe(101); + loaded.Code.Revalidate().TryGetValue(out _, out var fault).ShouldBeFalse(); + fault.ShouldBe(ProbeCodeFault.NotUpperCase); + } + + [Fact] + public async Task The_translated_queries_find_the_row() + { + var (_, saved) = await SeedAsync(order => order.Code = ProbeCode.FromKnownGood("QUERY")); + List ids = [saved.Id]; + var tag = saved.Tags[0]; + var cancellation = TestContext.Current.CancellationToken; + + await using var db = await CreateAsync(); + + (await db.Orders.CountAsync(o => ids.Contains(o.Id), cancellation)).ShouldBe(1); + (await db.Orders.CountAsync(o => o.Id == saved.Id && o.Code.Value.StartsWith("QUE"), cancellation)).ShouldBe(1); + (await db.Orders.CountAsync(o => o.Id == saved.Id && o.Discount.ValueOrNull() > 10, cancellation)).ShouldBe(1); + (await db.Orders.CountAsync(o => o.Id == saved.Id && o.Tags.Contains(tag), cancellation)).ShouldBe(1); + (await db.Orders.CountAsync(o => o.Id == saved.Id && o.Tags.Any(t => t.Value == "x"), cancellation)).ShouldBe(1); + (await db.Orders.CountAsync(o => ids.Any(id => id.Value == o.Id.Value), cancellation)).ShouldBe(1); + } + + /// A key over an integer is an identity column, as an int key is, and SQL Server numbers it. + [Fact] + public async Task An_integer_key_is_an_identity_column() + { + await using var db = await CreateAsync(); + + SqlServerPropertyExtensions.GetValueGenerationStrategy(db.Model.FindEntityType(typeof(Shipment))!.FindProperty(nameof(Shipment.Id))!) + .ShouldBe(SqlServerValueGenerationStrategy.IdentityColumn); + + var first = new Shipment { Carrier = "a" }; + var second = new Shipment { Carrier = "b" }; + db.AddRange(first, second); + await db.SaveChangesAsync(TestContext.Current.CancellationToken); + + first.Id.Value.ShouldBeGreaterThan(0); + second.Id.ShouldNotBe(first.Id); + } + + /// + /// KB-048: under the default collation a value object over text is compared and matched ignoring case and trailing + /// spaces, as the plain string column beside it is, where in memory its equality is ordinal. + /// + [Fact] + public async Task Text_is_compared_by_the_collation_as_a_plain_string_column_is() + { + var marker = Guid.NewGuid().ToString("N")[..8]; + var (customer, saved) = await SeedAsync(order => { order.Note = ProbeLabel.From($"Fragile {marker}"); order.Tags = [Tag.From($"Needle {marker}")]; }); + var cancellation = TestContext.Current.CancellationToken; + var shouted = ProbeLabel.From($"FRAGILE {marker}"); + List notes = [shouted]; + + shouted.ShouldNotBe(saved.Note, "in memory, a value object over text is equal ordinally"); + + await using var db = await CreateAsync(); + var mine = db.Orders.Where(o => o.Id == saved.Id); + + (await mine.CountAsync(o => o.Note == shouted, cancellation)).ShouldBe(1); + (await mine.CountAsync(o => o.Note == ProbeLabel.From($"Fragile {marker} "), cancellation)).ShouldBe(1, "trailing spaces"); + (await mine.CountAsync(o => notes.Contains(o.Note!), cancellation)).ShouldBe(1); + (await mine.CountAsync(o => o.Code.Value.StartsWith("ab"), cancellation)).ShouldBe(1); + (await mine.CountAsync(o => o.Tags.Any(t => t.Value == $"needle {marker}"), cancellation)).ShouldBe(1); + (await db.Customers.CountAsync(c => c.Id == customer.Id && c.Name == customer.Name.ToUpperInvariant(), cancellation)).ShouldBe(1, "the plain string column"); + } + + /// + /// KB-050: a decimal value object without a configured precision is a decimal(18,2) column, as a plain + /// decimal is: more fractional digits are rounded on write, and the value reads back at scale 2. + /// + [Fact] + public async Task A_decimal_value_object_is_rounded_to_the_column_s_scale_as_a_plain_decimal_is() + { + var (_, saved) = await SeedAsync(order => order.Amount = ProbeAmount.From(12.505m)); + + await using var db = await CreateAsync(); + db.Model.FindEntityType(typeof(Order))!.FindProperty(nameof(Order.Amount))!.GetColumnType().ShouldBe("decimal(18,2)"); + var loaded = await db.Orders.AsNoTracking().SingleAsync(o => o.Id == saved.Id, TestContext.Current.CancellationToken); + + loaded.Amount.Value.ToString(CultureInfo.InvariantCulture).ShouldBe("12.51"); + } +} diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/KeyShapes.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/KeyShapes.cs index 61135a9a..aa781900 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/KeyShapes.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/KeyShapes.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -118,14 +119,30 @@ public static IReadOnlyList KeyOf(DbContext db, KeyShape shape, Type db.GetService().Model.FindEntityType(Subject(shape, key))!.FindPrimaryKey()!.Properties; } -/// A model of one key shape, cached per shape, key type and side. -public sealed class KeyShapeDb(DbContextOptions options, KeyShape shape, Type key) : DbContext(options) +/// A model of one key shape, cached per shape, key type and side, in a schema of its own where many share one database. +public sealed class KeyShapeDb(DbContextOptions options, KeyShape shape, Type key, string? schema = null) : DbContext(options) { public KeyShape Shape => shape; public Type Key => key; - protected override void OnModelCreating(ModelBuilder modelBuilder) => KeyShapes.Configure(modelBuilder, shape, key); + public string? Schema => schema; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + if (schema is not null) modelBuilder.HasDefaultSchema(schema); + KeyShapes.Configure(modelBuilder, shape, key); + } + + /// On a real SQL Server, the shape's tables in . + public static KeyShapeDb OnSqlServer(KeyShape shape, Type key, bool typeKit, string connectionString, string schema) + { + var builder = new DbContextOptionsBuilder().ReplaceService().UseSqlServer(connectionString) + .ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)); + if (typeKit) builder = builder.UseTypeKit(); + + return new KeyShapeDb(builder.Options, shape, key, schema); + } public static KeyShapeDb Create(string provider, KeyShape shape, Type key, bool typeKit, Microsoft.Data.Sqlite.SqliteConnection connection) { @@ -146,7 +163,7 @@ public static KeyShapeDb Create(string provider, KeyShape shape, Type key, bool public sealed class KeyShapeModelCacheKey : IModelCacheKeyFactory { public object Create(DbContext context, bool designTime) => - context is KeyShapeDb db ? (db.GetType(), db.Shape, db.Key, designTime) : (context.GetType(), designTime); + context is KeyShapeDb db ? (db.GetType(), db.Shape, db.Key, db.Schema, designTime) : (context.GetType(), designTime); } public class Alone diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs index a99b6e6f..55c4fdd1 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/QueryShapes.cs @@ -3,6 +3,7 @@ using System.Runtime.CompilerServices; using CodoMetis.TypeKit.ValueObjects; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -657,8 +658,11 @@ private static List Rows(QueryBuilder builder, WrappedKind kind, bool va private static void Set(object target, string property, object? value) => target.GetType().GetProperty(property)!.SetValue(target, value); } -/// A model of one kind's entities for the catalogue, cached by . -public sealed record QueryModel(string Key, QueryCase Case, bool ValueObject); +/// +/// A model of one kind's entities for the catalogue, cached by , in a schema of its own where +/// many models share one database. +/// +public sealed record QueryModel(string Key, QueryCase Case, bool ValueObject, string? Schema = null); public sealed class QueryDb(DbContextOptions options, QueryModel model) : DbContext(options) { @@ -673,6 +677,7 @@ protected override void ConfigureConventions(ModelConfigurationBuilder configura protected override void OnModelCreating(ModelBuilder modelBuilder) { var builder = new QueryBuilder(model.Case, model.ValueObject); + if (model.Schema is { } schema) modelBuilder.HasDefaultSchema(schema); if (builder.Source == builder.ElementRow) { @@ -725,6 +730,16 @@ public static QueryDb Create(QueryModel model, Microsoft.Data.Sqlite.SqliteConne public static QueryDb OnPostgreSql(QueryModel model) => Create(model, new DbContextOptionsBuilder().UseNpgsql("Host=localhost;Database=none")); + /// + /// On SQL Server, at EF's default compatibility level or at . The suite's + /// models need more than the twenty internal service providers EF warns about, all in one process. + /// + public static QueryDb OnSqlServer(QueryModel model, string connectionString, int? compatibilityLevel = null) => + Create(model, new DbContextOptionsBuilder().UseSqlServer(connectionString, sql => + { + if (compatibilityLevel is { } level) sql.UseCompatibilityLevel(level); + }).ConfigureWarnings(warnings => warnings.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning))); + private static QueryDb Create(QueryModel model, DbContextOptionsBuilder provider) { var builder = provider.ReplaceService(); diff --git a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/WrappedKinds.cs b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/WrappedKinds.cs index 63583190..50795eaa 100644 --- a/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/WrappedKinds.cs +++ b/test/CodoMetis.TypeKit.EntityFrameworkCore.Tests/Variants/WrappedKinds.cs @@ -20,7 +20,10 @@ public enum Ordering /// /// The values are chosen so that a column read as text orders differently from the type (10 /// before 2.25, 1.02:00:00 before 10:00:00): a converter that stores another -/// representation, or a provider that orders the stored text, cannot answer like memory by accident. +/// representation, or a provider that orders the stored text, cannot answer like memory by accident. Text and +/// char mix case ("Bravo" before "alpha" ordinally), and the Guids ascend by their first group +/// and not by their last, so a database that orders text ignoring case, or a uniqueidentifier by its last +/// group (SQL Server), cannot either. /// public sealed record WrappedKind(string Name, Type ValueObject, Type Wrapped, Ordering Ordering, object[] Values) { @@ -83,11 +86,11 @@ public static class WrappedKinds public static readonly WrappedKind UInt16 = new("ushort", typeof(WrapsUInt16), typeof(ushort), Ordering.Wrapped, [(ushort)2, (ushort)7, (ushort)10]); public static readonly WrappedKind UInt32 = new("uint", typeof(WrapsUInt32), typeof(uint), Ordering.Wrapped, [2u, 7u, 10u]); public static readonly WrappedKind UInt64 = new("ulong", typeof(WrapsUInt64), typeof(ulong), Ordering.Wrapped, [2ul, 7ul, 10ul]); - public static readonly WrappedKind Char = new("char", typeof(WrapsChar), typeof(char), Ordering.Wrapped, ['a', 'b', 'c']); + public static readonly WrappedKind Char = new("char", typeof(WrapsChar), typeof(char), Ordering.Wrapped, ['B', 'a', 'c']); public static readonly WrappedKind Guid = new("Guid", typeof(WrapsGuid), typeof(Guid), Ordering.Wrapped, - [System.Guid.Parse("00000000-0000-0000-0000-000000000002"), System.Guid.Parse("00000000-0000-0000-0000-000000000007"), System.Guid.Parse("00000000-0000-0000-0000-000000000010")]); - public static readonly WrappedKind String = new("string", typeof(WrapsString), typeof(string), Ordering.Wrapped, ["alpha", "bravo", "charlie"]); - public static readonly WrappedKind StringRecordClass = new("string, record class", typeof(WrapsStringRecord), typeof(string), Ordering.Wrapped, ["alpha", "bravo", "charlie"]); + [System.Guid.Parse("00000001-0000-0000-0000-000000000010"), System.Guid.Parse("00000002-0000-0000-0000-000000000002"), System.Guid.Parse("00000003-0000-0000-0000-000000000007")]); + public static readonly WrappedKind String = new("string", typeof(WrapsString), typeof(string), Ordering.Wrapped, ["Bravo", "alpha", "charlie"]); + public static readonly WrappedKind StringRecordClass = new("string, record class", typeof(WrapsStringRecord), typeof(string), Ordering.Wrapped, ["Bravo", "alpha", "charlie"]); public static readonly WrappedKind Int32RecordClass = new("int, record class", typeof(WrapsInt32Record), typeof(int), Ordering.Wrapped, [2, 7, 10]); public static readonly WrappedKind Decimal = new("decimal", typeof(WrapsDecimal), typeof(decimal), Ordering.Wrapped, [1.5m, 2.25m, 10m]); public static readonly WrappedKind Double = new("double", typeof(WrapsDouble), typeof(double), Ordering.Wrapped, [1.5, 2.25, 10.0]); diff --git a/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj b/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj index 20de5a52..e725ba98 100644 --- a/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj +++ b/test/CodoMetis.TypeKit.Facts/CodoMetis.TypeKit.Facts.csproj @@ -25,6 +25,13 @@ + + + + diff --git a/test/CodoMetis.TypeKit.Facts/KB047.cs b/test/CodoMetis.TypeKit.Facts/KB047.cs index 3339e7f6..245d66d1 100644 --- a/test/CodoMetis.TypeKit.Facts/KB047.cs +++ b/test/CodoMetis.TypeKit.Facts/KB047.cs @@ -98,7 +98,7 @@ public void Npgsql_s_HiLo_comes_before_the_hints_factory() { var converter = new ValueConverter( number => number.Value, value => new Number(value), new ConverterMappingHints(valueGeneratorFactory: (_, _) => new Fixed(new Number(7)))); - using var db = Create(model => model.Entity().Property(e => e.Id).HasConversion(converter).UseHiLo()); + using var db = Create(model => NpgsqlPropertyBuilderExtensions.UseHiLo(model.Entity().Property(e => e.Id).HasConversion(converter))); var entityType = db.Model.FindEntityType(typeof(ByNumber))!; db.GetService().TrySelect(entityType.FindPrimaryKey()!.Properties[0], entityType, out var generator).ShouldBeTrue(); diff --git a/test/CodoMetis.TypeKit.Facts/KB048.cs b/test/CodoMetis.TypeKit.Facts/KB048.cs new file mode 100644 index 00000000..69efd812 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB048.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-048: under SQL Server's default collation, SQL_Latin1_General_CP1_CI_AS, text compares +/// ignoring case and trailing spaces: ==, Contains over a list (IN) and StartsWith +/// (LIKE) match rows that differ in case, and ORDER BY sorts ignoring case. A binary collation +/// (Latin1_General_BIN2) gives the ordinal order. +/// +[Trait("Needs", "Docker")] +public sealed class KB048 +{ + public sealed class Named + { + public int Id { get; set; } + + public string Name { get; set; } = ""; + } + + [Fact] + public async Task Text_compares_and_orders_ignoring_case() + { + var cancellation = TestContext.Current.CancellationToken; + await using var db = await SqlServer.ContextAsync(model => model.Entity()); + db.AddRange(new Named { Name = "alpha" }, new Named { Name = "Bravo" }, new Named { Name = "charlie" }); + await db.SaveChangesAsync(cancellation); + List upper = ["ALPHA"]; + + (await db.Set().Where(n => n.Name == "BRAVO").Select(n => n.Name).ToListAsync(cancellation)).ShouldBe(["Bravo"]); + (await db.Set().Where(n => n.Name == "alpha ").Select(n => n.Name).ToListAsync(cancellation)).ShouldBe(["alpha"]); + (await db.Set().Where(n => upper.Contains(n.Name)).Select(n => n.Name).ToListAsync(cancellation)).ShouldBe(["alpha"]); + (await db.Set().Where(n => n.Name.StartsWith("b")).Select(n => n.Name).ToListAsync(cancellation)).ShouldBe(["Bravo"]); + (await db.Set().OrderBy(n => n.Name).Select(n => n.Name).ToListAsync(cancellation)).ShouldBe(["alpha", "Bravo", "charlie"]); + (await db.Set().OrderBy(n => EF.Functions.Collate(n.Name, "Latin1_General_BIN2")).Select(n => n.Name).ToListAsync(cancellation)) + .ShouldBe(["Bravo", "alpha", "charlie"]); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB049.cs b/test/CodoMetis.TypeKit.Facts/KB049.cs new file mode 100644 index 00000000..9148e0db --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB049.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-049: SQL Server orders a uniqueidentifier by its last group first (bytes 10 to 15), then +/// the fourth, third, second and first, where starts at the first group. So +/// ORDER BY, < and MIN over Guids answer in another order than memory does. +/// +[Trait("Needs", "Docker")] +public sealed class KB049 +{ + public sealed class Keyed + { + public int Id { get; set; } + + public Guid Key { get; set; } + } + + [Fact] + public async Task A_uniqueidentifier_orders_by_its_last_group_first() + { + var cancellation = TestContext.Current.CancellationToken; + Guid first = Guid.Parse("00000001-0000-0000-0000-000000000003"), second = Guid.Parse("00000002-0000-0000-0000-000000000001"), third = Guid.Parse("00000003-0000-0000-0000-000000000002"); + await using var db = await SqlServer.ContextAsync(model => model.Entity()); + db.AddRange(new Keyed { Key = first }, new Keyed { Key = second }, new Keyed { Key = third }); + await db.SaveChangesAsync(cancellation); + + new[] { third, first, second }.Order().ShouldBe([first, second, third]); + (await db.Set().OrderBy(k => k.Key).Select(k => k.Key).ToListAsync(cancellation)).ShouldBe([second, third, first]); + (await db.Set().MinAsync(k => k.Key, cancellation)).ShouldBe(second); + (await db.Set().Where(k => k.Key < first).Select(k => k.Key).OrderBy(k => k).ToListAsync(cancellation)).ShouldBe([second, third]); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB050.cs b/test/CodoMetis.TypeKit.Facts/KB050.cs new file mode 100644 index 00000000..8f90703f --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB050.cs @@ -0,0 +1,34 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-050: a property without a configured precision is a decimal(18,2) +/// column on SQL Server. Every value reads back at scale 2 (10m as 10.00m, equal but printed +/// "10.00"), and a value with more fractional digits is rounded when it is written. +/// +[Trait("Needs", "Docker")] +public sealed class KB050 +{ + public sealed class Money + { + public int Id { get; set; } + + public decimal Amount { get; set; } + } + + [Fact] + public async Task A_decimal_reads_back_at_scale_2_rounded() + { + var cancellation = TestContext.Current.CancellationToken; + await using var db = await SqlServer.ContextAsync(model => model.Entity()); + db.Model.FindEntityType(typeof(Money))!.FindProperty(nameof(Money.Amount))!.GetColumnType().ShouldBe("decimal(18,2)"); + db.AddRange(new Money { Amount = 10m }, new Money { Amount = 1.005m }, new Money { Amount = -0.004m }); + await db.SaveChangesAsync(cancellation); + db.ChangeTracker.Clear(); + + var read = await db.Set().OrderBy(m => m.Id).Select(m => m.Amount).ToListAsync(cancellation); + read.Select(amount => amount.ToString(System.Globalization.CultureInfo.InvariantCulture)).ShouldBe(["10.00", "1.01", "0.00"]); + read[0].ShouldBe(10m); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB051.cs b/test/CodoMetis.TypeKit.Facts/KB051.cs new file mode 100644 index 00000000..defad0a0 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB051.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-051: EF maps a to SQL Server's time, which holds less than a day. +/// In a column, SaveChanges refuses a value of a day or more. In a JSON column the value is written as its text, +/// "1.02:00:00", and a query that reads the property converts that text to time: it answers +/// 01:02:00, without an error, where the entity materialized whole reads 1.02:00:00. +/// +[Trait("Needs", "Docker")] +public sealed class KB051 +{ + public sealed class Span + { + public int Id { get; set; } + + public TimeSpan Length { get; set; } + } + + public sealed class Holder + { + public int Id { get; set; } + + public Spans Doc { get; set; } = new(); + } + + public sealed class Spans + { + public TimeSpan Length { get; set; } + } + + [Fact] + public async Task A_time_column_refuses_a_day_or_more() + { + var cancellation = TestContext.Current.CancellationToken; + await using var db = await SqlServer.ContextAsync(model => model.Entity()); + db.Model.FindEntityType(typeof(Span))!.FindProperty(nameof(Span.Length))!.GetColumnType().ShouldBe("time"); + + db.Add(new Span { Length = TimeSpan.FromHours(26) }); + await Should.ThrowAsync(() => db.SaveChangesAsync(cancellation)); + } + + [Fact] + public async Task A_day_or_more_in_a_JSON_column_is_misread_by_a_query() + { + var cancellation = TestContext.Current.CancellationToken; + await using var db = await SqlServer.ContextAsync(model => model.Entity().OwnsOne(h => h.Doc, doc => doc.ToJson())); + db.Add(new Holder { Doc = new Spans { Length = TimeSpan.FromHours(26) } }); + await db.SaveChangesAsync(cancellation); + db.ChangeTracker.Clear(); + + (await db.Set().Select(h => h.Doc.Length).SingleAsync(cancellation)).ShouldBe(new TimeSpan(1, 2, 0)); + (await db.Set().SingleAsync(cancellation)).Doc.Length.ShouldBe(TimeSpan.FromHours(26)); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB052.cs b/test/CodoMetis.TypeKit.Facts/KB052.cs new file mode 100644 index 00000000..932f4991 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB052.cs @@ -0,0 +1,31 @@ +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-052: EF translates Max and Min of a column to MAX and +/// MIN of a bit, which SQL Server refuses: "Operand data type bit is invalid for max operator". +/// +[Trait("Needs", "Docker")] +public sealed class KB052 +{ + public sealed class Flagged + { + public int Id { get; set; } + + public bool Flag { get; set; } + } + + [Fact] + public async Task Max_and_min_of_a_bit_are_refused() + { + var cancellation = TestContext.Current.CancellationToken; + await using var db = await SqlServer.ContextAsync(model => model.Entity()); + db.AddRange(new Flagged { Flag = false }, new Flagged { Flag = true }); + await db.SaveChangesAsync(cancellation); + + (await Should.ThrowAsync(() => db.Set().MaxAsync(f => f.Flag, cancellation))).Message.ShouldContain("Operand data type bit is invalid for max operator"); + (await Should.ThrowAsync(() => db.Set().MinAsync(f => f.Flag, cancellation))).Message.ShouldContain("Operand data type bit is invalid for min operator"); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB053.cs b/test/CodoMetis.TypeKit.Facts/KB053.cs new file mode 100644 index 00000000..844f4db1 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB053.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-053: EF's SQL Server provider translates EF.Functions.Greatest and Least to +/// GREATEST and LEAST only at compatibility level 160 or later (UseCompatibilityLevel(160)), which +/// UseSqlServer does not assume. Below it a filter that calls one is refused ("could not be translated"), and a +/// final projection reads the arguments' columns and calls the function in memory, where it throws when the query runs. +/// No server is opened; ToQueryString translates without one. +/// +public sealed class KB053 +{ + public sealed class Pair + { + public int Id { get; set; } + + public int Left { get; set; } + + public int Right { get; set; } + } + + private sealed class Db(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity(); + } + + private static Db Create(int? level) => + new(new DbContextOptionsBuilder().UseSqlServer("Server=localhost;Database=none", sql => + { + if (level is { } compatibility) sql.UseCompatibilityLevel(compatibility); + }).Options); + + /// EF's default, and 150, the level below. + public static TheoryData Below160 => [0, 150]; + + [Theory, MemberData(nameof(Below160))] + public void Below_compatibility_level_160_Greatest_is_not_translated(int level) + { + using var db = Create(level == 0 ? null : level); + + Should.Throw(() => db.Set().Where(p => EF.Functions.Greatest(p.Left, p.Right) > 2).ToQueryString()).Message.ShouldContain("could not be translated"); + db.Set().Select(p => EF.Functions.Least(p.Left, p.Right)).ToQueryString().ShouldBe("SELECT [p].[Left], [p].[Right]" + Environment.NewLine + "FROM [Pair] AS [p]"); + Should.Throw(() => EF.Functions.Least(1, 2)).Message.ShouldContain("switched to client-evaluation"); + } + + [Fact] + public void At_compatibility_level_160_Greatest_and_Least_are_translated() + { + using var db = Create(160); + + db.Set().Where(p => EF.Functions.Greatest(p.Left, p.Right) > 2).ToQueryString().ShouldContain("WHERE GREATEST([p].[Left], [p].[Right]) > 2"); + db.Set().Select(p => EF.Functions.Least(p.Left, p.Right)).ToQueryString().ShouldContain("SELECT LEAST([p].[Left], [p].[Right])"); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/KB054.cs b/test/CodoMetis.TypeKit.Facts/KB054.cs new file mode 100644 index 00000000..1e526286 --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/KB054.cs @@ -0,0 +1,41 @@ +using Microsoft.EntityFrameworkCore; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// design/knowledge/KB-054: EF maps a to SQL Server's datetime2, of precision 7 unless +/// configured, which keeps every tick: nothing is rounded. A written as UTC reads back with +/// , and equal, since equality ignores the kind. A +/// keeps its offset. +/// +[Trait("Needs", "Docker")] +public sealed class KB054 +{ + public sealed class Stamped + { + public int Id { get; set; } + + public DateTime At { get; set; } + + public DateTimeOffset Offset { get; set; } + } + + [Fact] + public async Task Datetime2_keeps_every_tick_and_reads_back_unspecified() + { + var cancellation = TestContext.Current.CancellationToken; + var at = new DateTime(2026, 10, 29, 12, 0, 0, DateTimeKind.Utc).AddTicks(1234567); + var offset = new DateTimeOffset(2026, 10, 29, 12, 0, 0, TimeSpan.FromHours(2)).AddTicks(7654321); + await using var db = await SqlServer.ContextAsync(model => model.Entity()); + db.Model.FindEntityType(typeof(Stamped))!.FindProperty(nameof(Stamped.At))!.GetColumnType().ShouldBe("datetime2"); + db.Add(new Stamped { At = at, Offset = offset }); + await db.SaveChangesAsync(cancellation); + db.ChangeTracker.Clear(); + + var read = await db.Set().SingleAsync(cancellation); + read.At.Ticks.ShouldBe(at.Ticks); + read.At.Kind.ShouldBe(DateTimeKind.Unspecified); + read.Offset.ShouldBe(offset); + read.Offset.Offset.ShouldBe(TimeSpan.FromHours(2)); + } +} diff --git a/test/CodoMetis.TypeKit.Facts/SqlServerFactHost.cs b/test/CodoMetis.TypeKit.Facts/SqlServerFactHost.cs new file mode 100644 index 00000000..cebb39ef --- /dev/null +++ b/test/CodoMetis.TypeKit.Facts/SqlServerFactHost.cs @@ -0,0 +1,84 @@ +using System.Runtime.CompilerServices; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; + +namespace CodoMetis.TypeKit.Facts; + +/// +/// A SQL Server database of its own for a fact, with plain EF Core and nothing of TypeKit's, in one container started +/// by the first fact that needs it: SQL Server 2022's Linux image, which CI's Linux leg runs and the entries pin, or the +/// image TYPEKIT_MSSQL_IMAGE names (the native arm64 mcr.microsoft.com/azure-sql-edge, on a machine that +/// would emulate 2022 under QEMU, where it does not start). +/// +/// The facts that need it carry the trait Needs=Docker, which CI's Windows and macOS legs leave out. +internal static class SqlServer +{ + private const string Password = "TypeKit-2026!"; + + private static readonly Lazy> Container = new(async () => + { + var container = new ContainerBuilder(Environment.GetEnvironmentVariable("TYPEKIT_MSSQL_IMAGE") is { Length: > 0 } image ? image : "mcr.microsoft.com/mssql/server:2022-latest") + .WithEnvironment("ACCEPT_EULA", "Y") + .WithEnvironment("MSSQL_SA_PASSWORD", Password) + .WithPortBinding(1433, assignRandomHostPort: true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("SQL Server is now ready for client connections")) + .Build(); + await container.StartAsync(); + return container; + }); + + /// A fresh database for , created, and dropped when disposed; the model is cached per fact and test. + public static async Task ContextAsync(Action model, Action? sql = null, + [CallerFilePath] string fact = "", [CallerMemberName] string test = "") + { + var container = await Container.Value; + var connection = new SqlConnectionStringBuilder + { + DataSource = $"{container.Hostname},{container.GetMappedPublicPort(1433)}", + UserID = "sa", + Password = Password, + InitialCatalog = $"kb_{Guid.NewGuid():N}", + TrustServerCertificate = true, + }.ConnectionString; + var options = new DbContextOptionsBuilder().UseSqlServer(connection, sql) + .ReplaceService() + .Options; + var db = new SqlServerDb(options, model, $"{Path.GetFileNameWithoutExtension(fact)}.{test}"); + + // The server logs that it is ready before its login works, the first time it starts. + for (var attempt = 1; ; attempt++) + { + try + { + await db.Database.EnsureCreatedAsync(TestContext.Current.CancellationToken); + return db; + } + catch (SqlException) when (attempt < 60) + { + await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + } + } + } +} + +/// A context whose model is the fact's, cached per fact. +internal sealed class SqlServerDb(DbContextOptions options, Action model, string fact) : DbContext(options) +{ + protected override void OnModelCreating(ModelBuilder modelBuilder) => model(modelBuilder); + + public override async ValueTask DisposeAsync() + { + await Database.EnsureDeletedAsync(); + await base.DisposeAsync(); + } + + public sealed class CacheKey : IModelCacheKeyFactory + { + public object Create(DbContext context, bool designTime) => context is SqlServerDb db ? (typeof(SqlServerDb), db.FactName, designTime) : (context.GetType(), designTime); + } + + private string FactName => fact; +} diff --git a/test/oracles/claims.json b/test/oracles/claims.json index 83cb6a4d..a0676636 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -2004,6 +2004,104 @@ "KeyGenerationTests" ] }, + { + "id": "E17-as-the-underlying-type", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server", + "quote": "A value object behaves on SQL Server as the type inside it does there.", + "claim": "A value object behaves on SQL Server as the type inside it does there.", + "oracle": "differential", + "surfaces": [ + "TranslatedAsWrappedValueAttribute.*", + "IValueObject.Value" + ], + "heldBy": [ + "SqlServerQueryVariantTests", + "SqlServerKeyVariantTests", + "SqlServerRoundTripTests" + ] + }, + { + "id": "E17-datetime-kind", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server", + "quote": "`datetime2` keeps every tick, and a UTC value reads back with `Kind` `Unspecified`.", + "claim": "`datetime2` keeps every tick, and a UTC value reads back with `Kind` `Unspecified`.", + "oracle": "metamorphic", + "surfaces": [ + "ValueObjectConverter.*" + ], + "heldBy": [ + "SqlServerRoundTripTests" + ] + }, + { + "id": "E17-decimal-scale", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server", + "quote": "`10m` comes back as `10.00m`, which is equal but prints `10.00`.", + "claim": "`10m` comes back as `10.00m`, which is equal but prints `10.00`.", + "oracle": "metamorphic", + "surfaces": [ + "ValueObjectConverter.*" + ], + "heldBy": [ + "SqlServerRoundTripTests", + "SqlServerQueryVariantTests", + "SqlServerKeyVariantTests" + ] + }, + { + "id": "E17-greatest-level-160", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server", + "quote": "`EF.Functions.Greatest` and `Least` need compatibility level 160.", + "claim": "`EF.Functions.Greatest` and `Least` need compatibility level 160.", + "oracle": "differential", + "surfaces": [ + "TranslatedAsWrappedValueAttribute.*", + "IValueObject.Value" + ], + "heldBy": [ + "SqlServerQueryVariantTests" + ] + }, + { + "id": "E17-guid-order", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server", + "quote": "SQL Server orders a `uniqueidentifier` by its last six bytes first.", + "claim": "SQL Server orders a `uniqueidentifier` by its last six bytes first.", + "oracle": "metamorphic", + "surfaces": [ + "ValueObjectConverter.*" + ], + "heldBy": [ + "SqlServerQueryVariantTests" + ] + }, + { + "id": "E17-text-ignores-case", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server", + "quote": "Under SQL Server's default collation, `o.Note == note`, `notes.Contains(o.Note)` and `StartsWith` also match text that differs in case or in trailing spaces.", + "claim": "Under SQL Server's default collation, `o.Note == note`, `notes.Contains(o.Note)` and `StartsWith` also match text that differs in case or in trailing spaces.", + "oracle": "metamorphic", + "surfaces": [ + "ValueObjectConverter.*" + ], + "heldBy": [ + "SqlServerRoundTripTests", + "SqlServerQueryVariantTests" + ] + }, + { + "id": "E17-timespan-under-a-day", + "source": "src/CodoMetis.TypeKit.EntityFrameworkCore/README.md#on-sql-server", + "quote": "A longer one fails `SaveChanges` in a column.", + "claim": "A longer one fails `SaveChanges` in a column.", + "oracle": "metamorphic", + "surfaces": [ + "ValueObjectConverter.*" + ], + "heldBy": [ + "SqlServerQueryVariantTests" + ] + }, { "id": "L01-read-as-the-underlying-type-reads", "source": "README.md#what-typekit-guarantees", diff --git a/test/oracles/oracles.json b/test/oracles/oracles.json index e631ccad..949d55d8 100644 --- a/test/oracles/oracles.json +++ b/test/oracles/oracles.json @@ -17,6 +17,18 @@ "kind": "differential", "against": "the same key shape keyed by the wrapped type, on SQLite, Npgsql and SQL Server" }, + { + "class": "SqlServerKeyVariantTests", + "project": "CodoMetis.TypeKit.EntityFrameworkCore.Tests", + "kind": "differential", + "against": "the same key shape keyed by the wrapped type, inserted into a real SQL Server and read back" + }, + { + "class": "SqlServerQueryVariantTests", + "project": "CodoMetis.TypeKit.EntityFrameworkCore.Tests", + "kind": "differential", + "against": "the same query over the wrapped type on a real SQL Server, the objects in memory and SQLite, each difference from memory that is SQL Server's own a pinned fact" + }, { "class": "NotWireTypeTests", "project": "CodoMetis.TypeKit.Tests", diff --git a/test/oracles/seeds/column-cast-not-re-typed.patch b/test/oracles/seeds/column-cast-not-re-typed.patch new file mode 100644 index 00000000..1b0c3dc7 --- /dev/null +++ b/test/oracles/seeds/column-cast-not-re-typed.patch @@ -0,0 +1,19 @@ +Seed: A table column read through .Value is cast to the wrapped type, not re-typed +Oracles: SqlServerQueryVariantTests +Fails: the SQL of every query that reads a table column through an accessor, which is no longer the wrapped type's + +diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs +index 4cbf797..074e67d 100644 +--- a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs ++++ b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectTranslators.cs +@@ -120,9 +120,7 @@ internal static class WrappedValue + && (operandMapping.Converter is not { } converter || !ValueObjectMapping.For(valueObject).StoresTheWrappedValue(converter, mapping?.Converter))) + return null; + +- return operand is ColumnExpression column && !IsCollectionElement(column) +- ? new ColumnExpression(column.Name, column.TableAlias, valueType, mapping, column.IsNullable) +- : sqlExpressionFactory.Convert(operand, valueType, mapping); ++ return sqlExpressionFactory.Convert(operand, valueType, mapping); + } + + /// diff --git a/test/oracles/seeds/ushort-key-not-generated.patch b/test/oracles/seeds/ushort-key-not-generated.patch index 1d97a4cc..54b876f4 100644 --- a/test/oracles/seeds/ushort-key-not-generated.patch +++ b/test/oracles/seeds/ushort-key-not-generated.patch @@ -1,6 +1,6 @@ Seed: A value-object key over ushort is not declared generated, where a ushort key is -Oracles: KeyGenerationVariantTests -Fails: every key shape over ushort, on the three providers +Oracles: KeyGenerationVariantTests, SqlServerKeyVariantTests +Fails: every key shape over ushort, on the three providers, and on a real SQL Server diff --git a/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs b/src/CodoMetis.TypeKit.EntityFrameworkCore/ValueObjectKeyValueGenerationConvention.cs index c64d73e..be3d015 100644