CodoMetis.ValueRanges
This file is the canonical agent guide. CLAUDE.md only imports it (@AGENTS.md), so Claude Code
and every other agent read the same rules. Edit this file, never CLAUDE.md.
In-memory range and multirange types for .NET 10, mirroring PostgreSQL's six built-in range domains (int4range, int8range, numrange, daterange, tsrange, tstzrange) plus two v5 additions: TimeRange over TimeOnly (maps to the custom timerange type — needs HasPostgresRange + EnableUnmappedTypes on the EF side) and, in the NodaTime satellite, YearMonthRange over YearMonth (stored as a month-aligned daterange). Since 10.0.0, Float64Range and Float32Range cover finite double and float (the custom float8range and float4range, set up like timerange), with Float64Set/Float32Set and their arities on double precision[]/real[]. Each range type is a discriminated union of five sealed variants with exhaustive pattern matching.
Since v6 there is a second type family: value sets (Sets/) — immutable, canonical (deduplicated, sorted, no nulls) sets of scalar values stored as native PostgreSQL arrays (StringSet/text[], GuidSet/uuid[], …), plus a validated-wrapper arity for every family (StringSet<T> … DateTimeOffsetSet<T>, and five more in the NodaTime satellite) constrained only on BCL interfaces. EF maps them as scalars with plugin-owned mappings and translators (@>, &&, <@, cardinality, array_remove) — never through EF's primitive-collection machinery. Since 10.4.0 Union, Add, Intersect and Except run as one canonicalizing subquery (ARRAY((SELECT DISTINCT ON (x) x FROM unnest(…) WITH ORDINALITY … ORDER BY x, ordinality))) for every family whose order PostgreSQL shares (ISetTypeDefinition.CanonicalizesInSql, #478), built by the SQL guard. Only text is left out: ORDER BY follows the database collation rather than ordinal order, so on StringSet/StringSet<T> Union stays array_cat and On text they run as SQL only where order and repeats don't count (#485): left unsorted under @>/<@/&&, IsEmpty and the outermost projection, Count as count(DISTINCT …) and ==/!= as mutual containment, until #479 decides the order (docs/efcore.md#combine-sets-with-union).
Stack
.NET 10 · C# 14 (extension methods) · MSTest 4.x · EF Core + Npgsql (PostgreSQL bridge)
Structure
Shipping projects live under src/, test projects under test/. Never hard-code a positional
path (../../) to reach the repo root — walk up to the CodoMetis.ValueRanges.slnx marker file,
so moving a project cannot silently retarget a test.
src/CodoMetis.ValueRanges/— Core library: range types, interfaces, set opssrc/CodoMetis.ValueRanges.NodaTime/— NodaTime satellite: LocalDateRange, LocalDateTimeRange, InstantRange (namespace staysCodoMetis.ValueRanges; uses core internals via InternalsVisibleTo)src/CodoMetis.ValueRanges.EFCore.PostgreSQL/— EF Core provider for LINQ-to-SQL translationsrc/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime/— EF satellite: registers NodaTime RangeTypeDefinitions,UseValueRangesNodaTime()test/CodoMetis.ValueRanges.Tests/— Unit tests (one file per operation)test/CodoMetis.ValueRanges.NodaTime.Tests/— NodaTime satellite unit teststest/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/— EF Core SQL translation tests (no database)test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/— NodaTime EF translation tests (no database)test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/— Live PostgreSQL via Testcontainers (needs Docker; Inconclusive without, but hard failure under CI=true). The shared container runsVALUERANGES_POSTGRES_IMAGE, defaultpostgres:16-alpine(the 10.x floor); CI runs both integration projects on 16 and 18, and a server below 16 fails every test (PostgresServer.cs). Covers BCL and NodaTime types. Authority on PostgreSQL semantics — run when changing translations or range algebra.CapturedCollectionProbeTestssends every discovered range and set type as a captured collection (Contains,Any,Count); it linksRangeProbesandSetProbesfrom the conventions project rather than copying them.ComplexIndexesCrossTestsholds this package's columns underEFCore.ComplexIndexes.PostgreSQL's temporal and exclusion constraints and GiST indexes on PostgreSQL 16 and 18 (containers of its own) through a test-only reference; neither shipping package references the othertest/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/— The same live layer for configuration that is per process: its runtimeconfig setsNpgsql.DisableDateTimeInfinityConversions, which Npgsql and the plugin each read once, so it cannot be flipped inside the other project's run. Holds the temporal store rule at the extremes with the conversion off. Own container; CI's integration job runs everytest/*IntegrationTests/test/Directory.Build.targets— Floats the EF test projects (every project whose name containsEFCore) to a newer EF Core and Npgsql EF patch for one run,-p:EfFloat=10.0.14 -p:NpgsqlEfFloat=10.0.3, whilesrc/keeps compiling against the floor it ships: a consumer's situation, which floor-only CI missed for #302. Without the properties it does nothing. CI's newest-patch integration legs pass them, and the newest published versions live in one place, theintegration-legsmatrix indotnet.yml(#297)test/CodoMetis.ValueRanges.AspNetCore.Tests/— Host-level behaviour with no ValueRanges registration: configuration binding (TypeConverter), MVC and minimal-API binding and JSON bodies viaTestServer, and the documented OpenAPI schema transformer (ValueRangesSchemaTransformer.cs), whichdocs/serialization.mdmust carry verbatim — edit the test copy, then the doc.HostCultureProbeTestsbinds every discovered type (core and NodaTime) through both hosts under four request cultures; it linksRangeProbes,SetProbes,WrapperElementsandReflectfrom the conventions project rather than copying themtest/CodoMetis.ValueRanges.Conventions.Tests/— Repo-level conventions: changelog consistency and form, packaging metadata (each package's nuspec, generated by NuGet's own pack task, carries its version's changelog section as release notes), release wiring, the SECURITY.md supported-versions table, value set contract compliance, EF mapping parity, translator dispatch (TranslatorDispatchSweepTests: every public query member over every type, each operand mapped as its declared type), the default JSON contract. Everything is discovered (projects by globbingsrc/, types by reflection), so adding a package or a type needs no edit heretest/oracles/— The bug-finding system (docs/oracles.md):claims.jsonquotes every promise the consumer-facing docs make and names the test that holds it (emptyheldByis the worklist);oracles.jsonregisters the sweep classes;seeds/*.patchplant one defect each insrc/that the named oracles were shown to catch;surface-waivers.jsonnames the public members no claim covers, with the reason;candidates.jsonholds disagreements found but not fixed, keyed to issues;postgres-surface.jsonholds every PostgreSQL range, multirange and array operator and function (one entry per row ofpostgres-surface.catalog.txt, the rawpg_operator/pg_proclisting) with the member that mirrors it, the translation a test asserts, or a waiver or issue;translation-support.jsonis the other direction: every public query member (QueryMembers, discovered from the public API, one key per overload) classified as running as SQL, in memory only (with the reason) or planned (with the issue), whichTranslationSupportInventoryTestsholds complete andTranslationSupportProbeTests(integration) holds true against EF Core over every closed type;CloneSweep.csis the clone-divergence sweep over the hand-copied type families (sets, arities, ranges, EF registrations), andclone-waivers.jsonexplains every divergence it finds between siblings that is right as it is.Oracle*TestsandCloneDivergenceTestsin the conventions project hold all of it consistent in CI without building;dotnet run tools/oracles.cs seedsre-proves locally that every seed is caught,… statusprints the worklist,… pg-surfaceregenerates the listing from a container,… clonesprints the sibling divergences with their waiver statustools/oracles.cs— The local prover and status report fortest/oracles/; file-based app, never run in CItest/consumer-smoke-test.sh— The only check of delivery rather than code: packs the four packages, restores them into throwaway projects created outside the repository (inside it,Directory.Build.propswould apply), compiles and runs real code against them, and asserts on printed output and translated SQL — never on the exit code alone. Before the consumers it reads each.nupkg's nuspec: the release notes open with the version's changelog section in plain text and end with the changelog at the version's tag. Five consumers: core only; a Native AOT publish of core + NodaTime, run as a native binary (extra linker paths viaSMOKE_AOT_LINKER_ARGS, e.g. for Homebrew's dotnet;SMOKE_AOT_SDKpins that consumer to another installed SDK through aglobal.jsonbeside it and targets its framework, which is how the .NET 11 SDK's ILCompiler is run over the net10.0 packages by hand (#299) until #297 gives the 10.x CI a leg for it); the NodaTime EF satellite, which pulls in all four through the nuspec chain;samples/Bookingsrun as a web app against its own PostgreSQL container (VALUERANGES_POSTGRES_IMAGE; skipped without Docker, a failure underCI=true), asserting every response, SQL line and stored rowdocs/walkthrough.mdshows; and the EF package besideEFCore.ComplexIndexes.PostgreSQLfrom nuget.org, asserting its exclusion and temporal constraints over this package's columns in the create script.NUGET_PACKAGESis redirected to a private folder because NuGet resolves id+version from the global cache before any source, so a locally built package is otherwise shadowed by whatever build of that version was restored before; the generatednuget.configuses package source mapping so this repo's ids come from the local feed only and everything else from nuget.org. Runs on every PR in both modes (own feed; the pre-builtdistthe release workflow passes). Everything is discovered (projects by globbingsrc/, types by reflection), so adding a package or a type needs no edit heresamples/Bookings/— The minimal-API booking servicedocs/walkthrough.mdwalks through. Not in the solution:samples/Directory.Build.propsstops the repo's props, the smoke test runs it against the packed packages, andWalkthroughSampleTests(integration) links its source to hold the page's claims.WalkthroughSampleConventionTestsfails when a C# block on the page is not a contiguous run of the file its<!-- sample: … -->marker names, when an output block on the page is not asserted by the smoke test, or whenBookings.csprojnames another package version than the newest release on nuget.org (PackageValidationBaselineVersion) or another framework than that release's. GitHub's dependency submission restores every project from nuget.org, so an unreleased version fails it; move the sample with the baseline after each release, and regenerate its migration with that release's EF Core tools when the major changes (an EF Core 11 snapshot does not compile against EF Core 10). The smoke test moves the copy toVersionand this line's frameworkdocs/— Agent docs (read relevant doc before starting work)
Commands
dotnet build # Build everything
dotnet test # Run all tests
dotnet test --filter "ClassName=RangeContainsTests" # Single test class
dotnet test --filter "FullyQualifiedName~Contains_FiniteRange" # Single method
dotnet pack -c Release # Pack NuGet packages (builds first)
The build treats warnings as errors (Directory.Build.props), including CS1591 on the public
surface of the shipping projects — the shipped .xml is what IntelliSense shows. Deliberate
internal-API usage (EF1001) is acknowledged with a file-level pragma next to the reason, never
repo-wide.
Pack writes each package's release notes (nuget.org's Release Notes tab, Visual Studio's Updates
tab) from the ## [$(Version)] section of its own src/<package>/CHANGELOG.md, through the target
in Directory.Build.targets. They are plain text, because nuget.org shows release notes as typed
and links only bare URLs: a link becomes text (url), and backticks, bold and ### are dropped.
Pack fails, naming the file, when the section is missing, empty, doubled or over nuget.org's
35,000 characters, so a version bump needs its section in all four package changelogs before
dotnet pack runs. Never set PackageReleaseNotes anywhere else.
Workflow
- Read
docs/architecture.mdbefore modifying range types or interfaces - Explore the codebase — range operations are per-shape (Finite, UnboundedStart, etc.)
- Run
dotnet testafter each change; tests are method-level parallel - Prove every fix by reverting it. Strip only the fix, confirm the new test fails for the
expected reason, restore. A test that passes with and without the fix is not a regression
test — and one written against broken code can assert nothing at all without looking any
different. The same applies to a new convention test: seed the defect it claims to catch. For an
oracle sweep, keep the revert as
test/oracles/seeds/<defect>.patchnaming the oracles it fails (OracleSeedTestsrequires one per registered oracle), and rundotnet run tools/oracles.cs seedsbefore a release - Finding bugs is systematic, not one-off. Start from
dotnet run tools/oracles.cs status: an unheld claim or an unwaived public member is the next oracle to write, and a new sweep goes intooracles.jsonwith a seed. A new public member failsOracleSurfaceCoverageTestsuntil a claim names it orsurface-waivers.jsonsays why none should; a reworded doc sentence failsOracleCatalogueTestsuntil its claim follows. A disagreement a sweep finds goes throughCandidates.Verdictintocandidates.jsonwith its issue, never into anAssert.Inconclusiveor a deleted row; the entry fails ascandidate fixedonce the defect is gone. A fix commit carriesFound-By:andDefect-Class:trailers (CONTRIBUTING.md) - Commit with conventional format:
feat:,fix:,refactor:,docs:,test:,build:
Issues
Work is tracked as GitHub issues, not as lists in docs or memory. Open the issue before starting
the work, and close it from the PR (Closes #n).
- Labels, on issues and PRs alike (the EntglDb/BLite scheme): exactly one type (
bug,enhancement,documentation,tests,chore); everypackage/*the change affects (core,nodatime,efcore,efcore-nodatime); thearea/*concern (ranges,range-set,value-sets,json,literals,aot,release); andseverity/*on a bug. Addsilentto a bug that answers wrongly without throwing. Its fix went in the next major up to 10.0.0; after 10.0.0 it ships as a minor on every supported line (never a patch), whose changelog entry opens with### Changed answers, a row linking the issue and itsdocs/migration.mdsection (pr-hygiene.shfails asilentPR into a minor without that row). Addbreakingto anything that changes an existing answer or signature,found-by/oracleto what the generated-oracle machinery finds, andstackedto a PR whose base is another open PR - PRs carry their issue's labels and milestone, open with
Closes #n, and describe the problem, the change and how it was verified - Epics: a release is an
epicissue with a milestone of the same version (9.1.0). Its work is attached as native sub-issues and carries the same milestone. A theme that spans releases is anepicwithout a milestone; a finding it produces belongs to the release epic it ships in - Roadmap: the pinned
roadmapissue (#34) holds every epic as a sub-issue - Branches: up to two lines are supported at once, 10.x (LTS, .NET 10) and 11.x (.NET 11).
Until the
v10.0.0tag:mainis 10.0.0, andrelease/9.xholds 9.x patches and then takes no changes. There is no preview branch. At the tag,release/10.xis cut from it, andmainis retargeted to .NET 11 (#103) and ships11.0.0-preview.Nuntil 11.0.0. After it, a change for both merges intomainfirst and is cherry-picked (-x) into a PR againstrelease/10.x; additions ship as 10.x minors for six months afterv11.0.0, after which 10.x takes fixes only. An issue fixed only on a release branch is closed by hand, becauseCloses #nacts only onmain. SECURITY.md has one row per line; the other line's latest release isCompanionLineReleaseinDirectory.Build.props. See CONTRIBUTING.md#branches-and-releases
Docs
docs/architecture.md— Discriminated union pattern, interface hierarchy, RangeSet internalsdocs/testing.md— Test organization, patterns, shape-combination matrixdocs/oracles.md— The claim catalogue, API-surface coverage, seeds and candidate registry: how bug-finding is run as a system
Critical Rules
- NEVER create external subtypes of range base records — the private constructor is what makes the five variants the only ranges that can exist, so a switch over them is complete in fact. C# never proved it (exhaustiveness analysis ignores a closed class hierarchy, so a switch expression still warns CS8509 and needs a throwing discard — see
docs/ranges.md#pattern-matching), which is exactly why breaking the rule is unrecoverable: the guarantee is entirely a runtime one, and nothing would fail at compile time - ALWAYS preserve RangeSet's invariant (sorted, disjoint, non-adjacent, no empties) on every code path that constructs or mutates a set
- Do NOT add new range types without verifying the generic engines cover them — a new type must implement
IRange<T>+IRangeFactory<TRange, T>with the five sealed variants; the engines inInternals/dispatch per shape through the structural interfaces. The procedure isdocs/architecture.md#adding-a-range-or-set-type: measure the domain on a live server, copy the nearest sibling on every axis, and let the discovered tests and the clone sweep list what is left — there is deliberately no template or scaffold - Ranges — NEVER decide a binary operation by switching on the receiver's shape. A binary relation is a function of the pair of shapes: read the bounds it actually compares, or switch on
(left, right). Switching on the receiver and handling the operand's shapes in an inner switch has now produced the same bug four times —IsAdjacentTo(fixed 6.2.1),IsStrictlyLeftOfandExcept(both 7.0.0), andRangeSet.Except(TRange)with an infinity operand (8.0.0). The tell is an inner switch with a_fallback: it answersfalse, or returns the receiver unchanged, for exactly the operand shapes nobody wrote an arm for. This applies to value-producing operations, not just predicates —Exceptreturned a well-formed range holding the wrong values, which is the harder one to notice. It hides well because the EF translation is correct, so the disagreement is between memory and the database rather than inside either.ShapeMatrixParityTestsasks PostgreSQL for every ordered shape pair and is the check that catches it - Ranges — a shape dispatch's
_arm MUST throw, never produce a value. C# cannot prove a switch over interface patterns exhaustive, so the discard arm cannot be removed — the point is that it stops answering. Since 8.0.0 the three engines have one entry point each takingIRange<T>on both sides, switch over(left, right)with one arm per accepted pair, and throwShapePair.Unreachablenaming the pair for the rest, so an unhandled pair is a missing line and a loud failure rather than a plausible value.EngineDispatchConventionTestsparsessrc/**/Internals/and enforces both halves — throwing discards, and entry points that are not typed by one operand's shape - JSON and TypeConverter — every range union, every sealed variant,
RangeSetand every value set type MUST carry its own[JsonConverter]and[TypeConverter]. For[TypeConverter]the variant trap is reversed: it is inherited, so a variant without its own silently uses the union's and answers with whatever shape the literal has;TypeConverterContractTestscatches that. Since 9.0.0 the literal is the wire form with nothing registered. System.Text.Json does not inherit the attribute, and a value reached throughobjectis handed over as its variant — an attribute on the union alone silently leaves those on the{"Length":…}property dump. Non-generic types name a closed converter (source-generation friendly); generic ones nameRangeJsonConverterFactory, because an attribute cannot name an open generic converter.JsonDefaultContractTestsdiscovers every type and fails on a missing one. The range and set interfaces carryInterfaceDeclaredJsonConverter(write through the runtime type, refuse a read), and a variant converter defers to a union converter registered on the options — both so a value's wire form never depends on how it was declared - AOT — a generic range set or value set type MUST register its own closed converters (
internal static readonly bool ConvertersRegistered = GenericConverters.Register(…)), and nothing may build a converter withMakeGenericType. Under Native AOT an instantiation over a value type that no compiled code creates does not exist, and whether one does depends on unrelated code — an AOT test can pass by accident. Both libraries build withIsAotCompatible; the NativeAOT consumer intest/consumer-smoke-test.shis the only check that code is actually compiled (seed a defect there, not in a unit test) - Literals — NEVER let an
IFormatProviderreach the literal.Parse/TryParseread the invariant literal and ignore the provider; formatting without a format string is the invariant literal whatever the provider (a provider applies only with an explicit format). Before 9.0.0,th-THparsed2026-01-01as Gregorian 1483 andde-DEturned$"{DecimalRange [1.5,2)}"into[1,5,2)→[1,52), silently.LiteralProviderContractTestsandLiteralCultureContractTestssweep every type under five cultures - Literals — temporal element text is strict ISO 8601, NEVER a lenient BCL
Parse.IsoTemporal(BCL) andNodaPatterns(NodaTime) accept exactly what this library writes and what PostgreSQL prints in ISODateStyle— space separator,+05/+05:30/±HH:mm:ssoffsets,infinity/-infinityas the extremes — plusHH:mm. ADateTimeoffset is accepted and never applied — the wall clock is kept, asLocal, which is PostgreSQL'stimestampinput rule; converting it made one literal a differenttsrangevalue on every machine. Before 9.0.0DateOnly.Parse(invariant)read[01.02.2027,05.03.2027)month first and an offset-lessDateTimeOffsettook the machine's offset, silently. A wrapper arity parses as its primitive first and hands the element its round-trip form, and a closed temporal set reads its JSON elements through the same grammar whenever System.Text.Json's built-in converter would have (a registered converter still wins).TemporalLiteralStrictnessContractTestsrefuses a non-ISO element through every entry point of every discovered temporal type - EF Core: new range types are wired exclusively through
RangeTypeRegistry.Register(satellites call it from their options-builder extension); never bypass the registry - EF Core — a translator maps an operand from the type its overload declares, and throws
MissingArmfor any other. So does the interceptor'sRangeSetContains.Equivalent, which picks the static form of aRangeSet.Containsoverload by its operand type (#320). The translators recognize an overload by its name, andRangeSetcalls mapped every operand that was neither the set nor the element as a range, so an overload added later with another operand type would have become a range operator over the wrong mapping, without an error (#295) — the receiver-shaped dispatch trap one layer up. Declining is not enough: EF Core's own translators match by name too, as its collectionContainstookRangeSet.Contains(#288).TranslatorDispatchSweepTestshands every public overload to the translators, so a new one without an arm fails there first - EF Core — NEVER replace, register or derive from
IMigrationsModelDifferorIMigrationsSqlGenerator. EF resolves each as a single service, so when two packages replace one the last registration wins and the other's migration DDL disappears without an error. From 11.0.0 on,CodoMetis.EFCore.SchemaKitowns both in the CodoMetis family: a migration-aware feature goes into SchemaKit or plugs into its contract, never into a replacement here. The custom store types (timerange,float8range,float4range) are created through Npgsql'sHasPostgresRange, which needs neither.MigrationServiceOwnershipConventionTestsfails on a shipping type implementing either service and on any reference to them in code undersrc/(ReplaceService,TryAdd,ServiceDescriptor, a base list); comments may name them - EF Core — a temporal value written as text MUST be the value Npgsql would bind. A JSON document and a SQL literal hold the microsecond truncated toward 2000-01-01 (
StoreTemporal/NodaStoreTemporal, passed as a definition'sstoreValue— never folded intonormalizeValue, which must stay the core'sNormalizeElement) and spell the extremes Npgsql maps as-infinity/infinity; ranges are rebuilt through their factory afterwards and sets deduplicated. PostgreSQL's text input rounds half-to-even, so seven fraction digits make a document or constant a different value from the column. The JSON writer resolves its store text through the registries (StoreText<T>), never from state a definition's constructor set — a compiled model may never build a wrapper family's definition.TemporalStoreParityTests(integration) is the authority - EF Core — an inlined range constant arrives typed as its sealed variant. EF evaluates a range built from query-independent values on the client and inlines it as
Constant(value, Int32Range.Finite), for which no mapping exists;RangeVariantConstantRetyperin the query interceptor retypes it to the union. Do not "fix" this by mapping the variant types — that would make variant-typed properties mappable - EF Core — a constant element operand must be typed. The range operators are polymorphic (
anyrange @> anyelement) and PostgreSQL resolves those without implicit coercions, so a bare numeric literal (which it types asinteger) does not matchint8range. Assert the whole operand in translation tests, never the@>prefix, and execute at least one constant-operand query per element type against a live server - Value sets — ALWAYS preserve canonical form (deduplicated, sorted by the family's canonical comparer, no nulls) on every construction path, including reads. String-backed families sort ordinal — never culture, never the element's own
IComparable - Value sets — NEVER implement
IEnumerable<T>on a set type (EF would discover it as a primitive collection), and never letSetTypeRegistrymatch by store-type name (text[]belongs to the provider's nativestring[]mapping). New set types are wired exclusively throughSetTypeRegistry.Register - Value sets:
Count/IsEmptymust stay instance properties (extension properties cannot appear in expression trees — CS9296 — and would be untranslatable) - Value sets — a set type that normalizes or validates elements in
FromMUST also overrideIValueSet<T>.NormalizeElement(and, on the EF side, pass that same function as the definition'snormalizeValue).Contains/Add/Removetake a bare element, so without it the probe is compared un-normalized against normalized storage — a silently wrong answer client-side and a wrong bound parameter server-side - Value sets — a set type that overrides
CanonicalComparerMUST also overrideIValueSet<T>.CanonicalOrderto return it.Contains/Add/Removebinary-search the canonical array; searching with an order the array was not sorted by misses elements that are present - Value sets — a set type whose element type System.Text.Json cannot serialize as a scalar MUST override
IValueSetFactory<TSet,T>.ElementJsonConverter. JSON delegates elements to the serializer, which property-dumps an unknown type on write and yieldsdefaulton read — silently, both legs. The hook is consulted last (only whenGetTypeInfo(typeof(T)).Kind != None), so it never overrides a registered converter. The primitive-backed families are natively serializable and leave itnull; the wrapper arities and the five NodaTime sets define one. Integer-backed wrappers write a JSON number, not a string, and decimal-backed ones a number keeping scale — the token type must always match the primitive the wrapper wraps. Byte-for-byte parity additionally holds for the string, Guid, integer and decimal arities; for the temporal and NodaTime ones the text differs (seven fraction digits,+escaped) while the value does not, which is asserted inSetJsonConverterTestsrather than fixed - Value sets — a wrapper arity over a type whose default text form is lossy MUST pin a round-trip format, in
FormatValueand in itsSetTypeRegistrybridge entry, and parse it strictly.TimeOnly.ToString(null, invariant)is09:30andDateTime's is06/15/2024 10:30:00— an arity built likeInt32Set<T>stores every temporal truncated to the second, and losesDateTimeKind, silently on the way to the column. The four temporal arities and the five NodaTime ones do this — since 9.0.0 the coreParseValueparses strictly too, not only the EF bridge; the string, Guid, integer and decimal ones correctly take the default - Value sets — canonical form in SQL is built by
ValueSetSqlGuard, never by a translator. For every family withCanonicalizesInSql, the translator emitsValueSetSqlGuard.CanonicalUnion/CanonicalAdd/CanonicalIntersect/CanonicalExceptand the guard'sCanonicalExpanderbuilds the subquery: it needs table aliases, and EF Core's alias pass (which runs after translation and before the guard) parses every alias as one letter and a number, while a translator has noSqlAliasManager. KeepORDER BY x, ordinalitywithDISTINCT ON: it keeps the first copy of each value asValueSetCoredoes (1.0vs1.00,-0vs0); a plainDISTINCTkeeps any copy (seedcanonical-set-keeps-any-copy). Drop NULL elements (WHERE x IS NOT NULL), and write the multiset form only where order and duplicates cannot change the answer. A family joins only if PostgreSQL orders its store type as the family's canonical comparer does; a wrapper arity relies on its element'sCompareToordering as the primitive does (docs/value-sets.md).CanonicalSetOperationParityTests(integration) is the authority - Value sets — a text
Union,Add,IntersectorExceptis NOT canonical in SQL. PostgreSQL sortstextby the database collation, so the translator emitsValueSetSqlGuard.UnguardedUnion/UnguardedAdd/UnguardedIntersect/UnguardedExceptand the guard builds each unsorted:array_cat,array_catwith a one-element array, or a filter of the first array. Only order- and multiplicity-insensitive uses may compose on such a value:@>,<@,&&(the proper subset/superset pair is written as<@ AND NOT @>for exactly that reason),cardinality(…) = 0, a null test, the outermost projection (materialization canonicalizes), and two the guard rewrites (#485):cardinalitybecomes(SELECT count(DISTINCT x) FROM unnest(…)), NULL over a NULL set, and=/<>/NULLIFwith such a value on a side become mutual containment,a @> b AND b @> a. A stored-column=keeps=and its index. NoCOLLATE "C"anywhere: a collated operand turns a GIN-indexed@>into a sequential scan, and #483 rejects a collation on a string set column that may not compare bytes, so the column's own equality is byte equality. Since 9.0.0 everything else is refused on the final SQL tree byValueSetSqlGuard, run from a decorator around the provider'sIRelationalParameterBasedSqlProcessorFactory(every query,ExecuteUpdateandExecuteDeletepasses through it). It fails closed: only the guard builds the placeholders, so aReplaceServicethat drops the decorator makes every server-side text operation fail on the server instead of silently skipping the refusals. Never emitarray_catdirectly, and never match it by name before the guard has run. The walker follows values derived from one — througharray_remove(which preserves canonical form rather than establishing it),CASE,COALESCE,NULLIFand subquery/set-operation columns — and the expander recognizes the same set structurally plus the walker's derived columns;GROUP BY,DISTINCT,ORDER BY, deduplicating set operations,INandUPDATEsetters throw. Never go back to recognizing these in LINQ: the 8.0.0 guard matched==inside a list of operators and a projection, a ternary, a coalesce,GroupBy,DistinctandExecuteUpdateall translated and answered wrongly. The same guard refusesto_jsonb(<value set>)in anUPDATEsetter —ExecuteUpdateof a value set inside a JSON column, which writes a JSON array where the document holds the literal, after which the row no longer loads — keyed off the translated setter, so a join orEF.Propertycannot bypass it.UnionCanonicalityTests, the text sweep inCanonicalSetOperationParityTestsandExecuteUpdateJsonTests(integration) assert results, not SQL. Do not "fix" text by sorting it in SQL:ORDER BYontextuses the database collation, not ordinal, so it would disagree with the client's canonical order — the same defect one layer down (#479 decides whether text moves to code point order andCOLLATE "C")