Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ section is the GitHub release's notes.
- Analyzers: CMTK0009 warns at `FirstOrNone`, `SingleOrNone` or `FirstOrError` on a query given a `Func` variable or a method group, which binds to the `IEnumerable<T>` overload and reads every row; a lambda at the call or an `Expression` stays silent ([A condition that reads every row](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#a-condition-that-reads-every-row)).
- Docs: the guides, the package READMEs, the changelog and the API of every run-time package as one site with a menu and search, built from the latest release ([caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/)).
- 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

Expand Down
10 changes: 7 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,15 @@
<PackageVersion Include="NodaTime.Serialization.SystemTextJson" Version="1.4.0" />
<!--
The EF satellite's tests: SQLite for the model, Npgsql for SQL and the round trips, SQL Server
for its model alone (key generation, built without a server), and EF's design-time services
to write the migration snapshot a model is compared with.
for its model, the round trips and the query catalogue, PostgreSQL and SQL Server each in a
container, and EF's design-time services to write the migration snapshot a model is compared with.
-->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.16.0" />
<PackageVersion Include="Testcontainers" Version="4.16.0" />
<!-- The Serilog README's sample writes to the console, as a reader's setup would. -->
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<!-- The ASP.NET Core satellite's tests: a probe host whose OpenAPI document they read. -->
Expand Down
21 changes: 21 additions & 0 deletions design/knowledge/KB-048-sql-server-compares-text-ignoring-case.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions design/knowledge/KB-050-a-decimal-is-decimal-18-2-on-sql-server.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions design/knowledge/KB-051-sql-server-s-time-holds-less-than-a-day.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions design/knowledge/KB-052-sql-server-takes-no-max-of-a-bit.md
Original file line number Diff line number Diff line change
@@ -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`).
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions design/knowledge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,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 |
4 changes: 2 additions & 2 deletions docs/ef-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 28 additions & 3 deletions src/CodoMetis.TypeKit.EntityFrameworkCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Amount>().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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

<!--
The EF satellite against value objects the real generators wove: the probes, and the model's own
identifiers, which this project gets through the probes' transitive fabric. The PostgreSQL round
trips start a container, so these tests need Docker.
identifiers, which this project gets through the probes' transitive fabric. The PostgreSQL and SQL
Server tests start a container, so they need Docker.
-->
<ItemGroup>
<ProjectReference Include="..\..\src\CodoMetis.TypeKit.EntityFrameworkCore\CodoMetis.TypeKit.EntityFrameworkCore.csproj" />
Expand All @@ -20,6 +20,8 @@
<!-- Past the advisories of the 9.0.11 Microsoft.Data.SqlClient brings; see Directory.Packages.props. -->
<PackageReference Include="System.Security.Cryptography.Pkcs" />
<PackageReference Include="Testcontainers.PostgreSql" />
<!-- SQL Server as a plain container (SqlServer.cs): the module waits through sqlcmd, which Azure SQL Edge has not. -->
<PackageReference Include="Testcontainers" />
</ItemGroup>

</Project>
Loading
Loading