From 09f09d0321ea7173beb5f6ed339f3e83c52a551e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 14 Nov 2026 16:37:28 +0100 Subject: [PATCH 1/2] feat: a declaration needs its contributor's registration, and a package the kernel of its own version The kernel's UseSchemaKit() stamps a model and renders nothing. With UseNpgsql(cs).UseSchemaKit(), DeclarePostgres passed on that stamp and the refusal came at the first diff. Declare and Redeclare now ask for a contributor of the declaration's owner as well, and name the package whose registration is missing. The owners are kept beside the model, not as an annotation, which would land in the snapshot. The PostgreSQL package asks SchemaKitPackages.RequireKernel first in both registrations. A direct reference to another kernel builds with NU1608; the pair is now refused with both versions, where it failed with a MissingMethodException at the first member that differed. The consumer smoke test has a consumer that mixes the two versions. The name of the kernel's own UseSchemaKit() is #141. Closes #113 Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 1 + CLAUDE.md | 17 ++++- design/schemakit-core-plan.md | 3 +- docs/moving-from-complexindexes-5x.md | 8 ++- .../SchemaKitNpgsqlExtensions.cs | 17 +++++ .../Internal/SchemaContributorRegistry.cs | 30 +++++++++ .../SchemaDeclarationExtensions.cs | 6 +- .../Internal/SchemaKitStrings.cs | 13 ++++ .../Internal/SchemaKitConventionSetPlugin.cs | 8 ++- .../Metadata/Internal/SchemaKitModelStamp.cs | 35 +++++++++- .../SchemaKitPackages.cs | 53 +++++++++++++++ .../SchemaKitServiceCollectionExtensions.cs | 11 ++- .../NpgsqlWiringTests.cs | 28 ++++++++ .../Conventions/PublicSurfaceTests.cs | 1 + .../Declarations/DeclarationStoreTests.cs | 2 +- .../Migrations/SchemaOperationPlannerTests.cs | 49 ++++++++++++-- .../Wiring/PackageVersionTests.cs | 67 +++++++++++++++++++ test/ConsumerSmokeTest/Mixed/Mixed.csproj | 24 +++++++ test/ConsumerSmokeTest/Mixed/Program.cs | 8 +++ test/ConsumerSmokeTest/run.sh | 33 ++++++++- 20 files changed, 396 insertions(+), 18 deletions(-) create mode 100644 src/CodoMetis.EFCore.SchemaKit/SchemaKitPackages.cs create mode 100644 test/CodoMetis.EFCore.SchemaKit.Tests/Wiring/PackageVersionTests.cs create mode 100644 test/ConsumerSmokeTest/Mixed/Mixed.csproj create mode 100644 test/ConsumerSmokeTest/Mixed/Program.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d19e9..fb234e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,4 +19,5 @@ The first preview, for `net11.0`, EF Core 11 with `Npgsql.EntityFrameworkCore.Po - An operation of a migration 5.x scaffolded that only 5.x's SQL generator rendered is refused, not applied as another object ([#110](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/110)). - `GetDeclarations()`, `FindDeclaration(name)` and `Redeclare(…)` read and amend what a model declares ([#45](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/45)). - A name two objects of one model take is refused as `SK1001`, every collision at once ([#96](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/96)). +- A declaration is refused where it is written when the context's options do not register the package that renders it, as with `UseNpgsql(…).UseSchemaKit()` in place of `UseNpgsql(…, o => o.UseSchemaKit())`; and the PostgreSQL package beside a kernel of another version is refused by `UseSchemaKit()` with both versions ([#113](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/113)). - The contributor contract other packages declare objects through, experimental (`SK9001`) until `rc.1` ([#7](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/7)). diff --git a/CLAUDE.md b/CLAUDE.md index 94d6c9c..defc03d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ the resolver is #43, of the planner #24, of the translator #26, of exclusion con | `spikes/` | The two spikes the host was built on (A: decorators and ownership, B: design time from a packed consumer) and E, the impact table of the plan's section 3.7 measured on live servers. B is the consumer smoke test until the real one replaces it | | `design/schemakit-api.md` | The public surface and the type model, written against rc.2. Its open questions are answered in the plan | | `design/schemakit-core-plan.md` | The plan for the core after EF Core 11 GA, with the maintainer's decisions (section 7). **Where the two disagree, the plan holds** | -| `test/ConsumerSmokeTest` | The consumer smoke test (#34): `run.sh` packs both packages, restores them into consumers outside the repository and drives every path a consumer has (`dotnet ef`, the runtime, a compiled model, a factory with a migrations assembly of its own, a project that comes from 5.x) on a live server, judged by `Judge/` from the catalog and a row that must be refused. `SMOKE_BREAK` takes a registration away to show it failing | +| `test/ConsumerSmokeTest` | The consumer smoke test (#34): `run.sh` packs both packages, restores them into consumers outside the repository and drives every path a consumer has (`dotnet ef`, the runtime, a compiled model, a factory with a migrations assembly of its own, a project that comes from 5.x) on a live server, judged by `Judge/` from the catalog and a row that must be refused. `SMOKE_BREAK` takes a registration away to show it failing. `Mixed/` references the kernel at another version than the PostgreSQL package's, which NuGet builds with NU1608, and must be refused (#113) | | `docs/moving-from-complexindexes-5x.md` | The guide for a project coming from 5.x, a first cut that covers `preview.1` (#73) | | `test/SchemaKit.ApiProbes` | A prototype of the surface and 30 compile probes: each forbidden declaration is compiled with Roslyn and its diagnostic asserted, beside an allowed twin | @@ -111,6 +111,21 @@ Read `design/schemakit-core-plan.md` before writing code. What must not be re-de reference, and stamps the model (`CodoMetis:SchemaKit`). A declaration API calls `SchemaKitModelStamp.Require` first. The stamp lands in snapshots, so its name and value are permanent from 11.0.0. +- **The stamp says the model is wired; which owners can be declared is kept beside the model** + (#113). The kernel's `UseSchemaKit()` alone stamps a model and renders nothing, so `Declare` and + `Redeclare` also ask for a contributor of the declaration's owner and name the package to + register. `AddSchemaContributor` adds a `SchemaContributorRegistration`, a plain value, since no + contributor may be resolved while a model is built; the convention puts the owners into a + `ConditionalWeakTable` keyed by the model. Not an annotation: it would land in the snapshot and + move with the packages a context registers. A snapshot's own declarations are still the + planner's to refuse, at diff. A contributor put into the collection without + `AddSchemaContributor` cannot be declared for. +- **`SchemaKitPackages.RequireKernel(assembly)` is the version check** (#113): a package of this + repository calls it first in each registration, from a method that calls nothing else of the + kernel (the rest is in a `NoInlining` method), since the runtime binds a method's calls when it + compiles it. It compares informational versions without the commit; the assembly version is + `11.0.0.0` for every preview. Its signature never changes: a version that changed it would fail + as the `MissingMethodException` it is there to replace. - **`SchemaKitModelDiffer`** asks an internal `ISchemaOperationPlanner` for operations before and after the inner differ's and splices them; `HasDifferences` runs the same plan. The host knows nothing of declarations or contributors: they arrive behind that planner. diff --git a/design/schemakit-core-plan.md b/design/schemakit-core-plan.md index 9cfc35b..d64a941 100644 --- a/design/schemakit-core-plan.md +++ b/design/schemakit-core-plan.md @@ -481,7 +481,8 @@ var report = await context.Database.VerifySchemaKitAsync(ct); ### 3.12 Other providers and SQLite -- A declaration whose owner has no contributor in the active provider is an **error** at diff. +- A declaration whose owner has no contributor in the active provider is an **error**: where it is + written in the context's own model (#113), and at diff in a snapshot. - The exception is explicit: `IgnoreDeclarationsOf(SchemaProvider.PostgreSql)` on the options of the other provider, logged once. Since the stamp already requires `UseSchemaKit()` on those options, a consumer testing on SQLite writes one argument. A warning by default, my first position, would diff --git a/docs/moving-from-complexindexes-5x.md b/docs/moving-from-complexindexes-5x.md index f110eac..eda2f97 100644 --- a/docs/moving-from-complexindexes-5x.md +++ b/docs/moving-from-complexindexes-5x.md @@ -65,11 +65,13 @@ What happens when a step is missed, as this preview has it: |---|---| | `UseSchemaKit()` | `DeclarePostgres` throws in `OnModelCreating`: "… declares a SchemaKit object, but UseSchemaKit() is not on this context's options." | | `UseSchemaKit()`, with a 5.x snapshot that holds exclusion constraints and a model that declares none | Nothing refuses it: no SchemaKit service reads the snapshot, so the next `migrations add` does not see the constraints. Wire it before the first `migrations add` | -| `UseSchemaKit()` written after `UseNpgsql(…)` instead of inside it | It compiles: that is the kernel's own `UseSchemaKit()`, without the PostgreSQL package's services. The first diff is refused: "… carries a declaration of 'Pg.Exclusion', and no contributor with that owner is registered." A 5.x snapshot is refused the same way, with "call UseSchemaKit() on the options builder of UseNpgsql" | +| `UseSchemaKit()` written after `UseNpgsql(…)` instead of inside it | It compiles: that is the kernel's own `UseSchemaKit()`, without the PostgreSQL package's services. `DeclarePostgres` throws in `OnModelCreating`: "… declares a 'Pg.Exclusion' object (…), and this context's options do not register what renders it: UseSchemaKit() is there, the registration of CodoMetis.EFCore.SchemaKit.PostgreSQL is not." A model that declares nothing over a 5.x snapshot is refused at the first diff, with "call UseSchemaKit() on the options builder of UseNpgsql" | +| A direct reference to `CodoMetis.EFCore.SchemaKit` at another version than the PostgreSQL package's | NuGet builds it with the warning NU1608. `UseSchemaKit()` throws: "CodoMetis.EFCore.SchemaKit.PostgreSQL … is loaded beside CodoMetis.EFCore.SchemaKit …" Reference the PostgreSQL package alone; it brings the kernel | | `ReplaceService` of the differ or the SQL generator after `UseSchemaKit()` | The first model built is refused, and the message says to call `UseSchemaKit()` last | -How a wrong wiring is refused, and where, is not settled for `11.0.0` -([#113](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/113)). +Each row but the second is a row of the consumer smoke test (`test/ConsumerSmokeTest/run.sh`), on +the packed packages. Whether the kernel's own `UseSchemaKit()` keeps its name is not settled for +`11.0.0` ([#141](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/141)). ## Exclusion constraints diff --git a/src/CodoMetis.EFCore.SchemaKit.PostgreSQL/SchemaKitNpgsqlExtensions.cs b/src/CodoMetis.EFCore.SchemaKit.PostgreSQL/SchemaKitNpgsqlExtensions.cs index a27bc0e..3c7372e 100644 --- a/src/CodoMetis.EFCore.SchemaKit.PostgreSQL/SchemaKitNpgsqlExtensions.cs +++ b/src/CodoMetis.EFCore.SchemaKit.PostgreSQL/SchemaKitNpgsqlExtensions.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using CodoMetis.EFCore.SchemaKit.PostgreSQL.Internal; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -27,6 +28,15 @@ public static NpgsqlDbContextOptionsBuilder UseSchemaKit(this NpgsqlDbContextOpt { ArgumentNullException.ThrowIfNull(optionsBuilder); + // First, and from a method that calls nothing else of the kernel: beside a kernel of another + // version this is the message, where the calls below would be a MissingMethodException. + SchemaKitPackages.RequireKernel(typeof(SchemaKitNpgsqlDbContextOptionsBuilderExtensions).Assembly); + return Register(optionsBuilder); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static NpgsqlDbContextOptionsBuilder Register(NpgsqlDbContextOptionsBuilder optionsBuilder) + { var core = ((IRelationalDbContextOptionsBuilderInfrastructure)optionsBuilder).OptionsBuilder; core.UseSchemaKit(); @@ -51,6 +61,13 @@ public static class SchemaKitNpgsqlServiceCollectionExtensions public static IServiceCollection AddEntityFrameworkSchemaKitNpgsql(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); + SchemaKitPackages.RequireKernel(typeof(SchemaKitNpgsqlServiceCollectionExtensions).Assembly); + return Register(services); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static IServiceCollection Register(IServiceCollection services) + { services.AddEntityFrameworkSchemaKit(); NpgsqlSchemaKitOptionsExtension.AddServices(services); return services; diff --git a/src/CodoMetis.EFCore.SchemaKit/Contributors/Internal/SchemaContributorRegistry.cs b/src/CodoMetis.EFCore.SchemaKit/Contributors/Internal/SchemaContributorRegistry.cs index 51b8efd..8a34c69 100644 --- a/src/CodoMetis.EFCore.SchemaKit/Contributors/Internal/SchemaContributorRegistry.cs +++ b/src/CodoMetis.EFCore.SchemaKit/Contributors/Internal/SchemaContributorRegistry.cs @@ -50,7 +50,37 @@ public static bool DerivesFromTheBaseClass(Type contributor) return false; } + /// + /// The owner of a contributor, read from its type: TDeclaration.OwnerId of the base + /// class, which is what answers. + /// Null for a class that does not derive from it. + /// + public static string? OwnerOf(Type contributor) + { + for (var type = contributor.BaseType; type is not null; type = type.BaseType) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(SchemaContributor<,>)) + return (string?)typeof(SchemaContributorRegistry) + .GetMethod(nameof(OwnerId), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! + .MakeGenericMethod(type.GetGenericArguments()[0]) + .Invoke(null, null); + } + + return null; + } + + private static string OwnerId() where TDeclaration : class, ISchemaDeclaration + => TDeclaration.OwnerId; + public IReadOnlyCollection All => _byOwner.Values; public ISchemaContributor? Find(string owner) => _byOwner.GetValueOrDefault(owner); } + +/// +/// That a contributor is registered, as a value: what the model's stamp is built from while the +/// model is created, when no contributor may be resolved yet. AddSchemaContributor adds it. +/// +/// The contributor's owner. +/// The contributor's type. +internal sealed record SchemaContributorRegistration(string Owner, Type Contributor); diff --git a/src/CodoMetis.EFCore.SchemaKit/Declarations/SchemaDeclarationExtensions.cs b/src/CodoMetis.EFCore.SchemaKit/Declarations/SchemaDeclarationExtensions.cs index f72eed4..61b2aa1 100644 --- a/src/CodoMetis.EFCore.SchemaKit/Declarations/SchemaDeclarationExtensions.cs +++ b/src/CodoMetis.EFCore.SchemaKit/Declarations/SchemaDeclarationExtensions.cs @@ -30,7 +30,8 @@ public static class SchemaDeclarationExtensions /// The entity type the objects belong to. /// The declarations. /// - /// UseSchemaKit() is not on the context's options, or a declaration's key is taken. + /// UseSchemaKit() is not on the context's options, they register no contributor for a + /// declaration's owner, or a declaration's key is taken. /// [Experimental(SchemaKitDiagnosticIds.ContributorContract)] public static void Declare(this IMutableEntityType entityType, IEnumerable declarations) @@ -43,6 +44,7 @@ public static void Declare(this IMutableEntityType entityType, IEnumerable(this IMutableEntityType entityType, Fu { ArgumentNullException.ThrowIfNull(entityType); ArgumentNullException.ThrowIfNull(amend); - SchemaKitModelStamp.Require(entityType.Model, entityType.DisplayName()); + SchemaKitModelStamp.Require(entityType.Model, entityType.DisplayName(), TDeclaration.OwnerId, typeof(TDeclaration)); var current = Stored(entityType).ToList(); Dictionary result = new(StringComparer.Ordinal); diff --git a/src/CodoMetis.EFCore.SchemaKit/Infrastructure/Internal/SchemaKitStrings.cs b/src/CodoMetis.EFCore.SchemaKit/Infrastructure/Internal/SchemaKitStrings.cs index d421d7b..bc17a4b 100644 --- a/src/CodoMetis.EFCore.SchemaKit/Infrastructure/Internal/SchemaKitStrings.cs +++ b/src/CodoMetis.EFCore.SchemaKit/Infrastructure/Internal/SchemaKitStrings.cs @@ -11,6 +11,19 @@ public static string NotWired(string declaring) "Without it the declaration would be left out of migrations and of EnsureCreated() without an error. " + "Add UseSchemaKit() where the context's options are built, including in an IDesignTimeDbContextFactory."; + public static string NotWiredForOwner(string declaring, string owner, Type declaration, string package) + => $"{declaring} declares a '{owner}' object ({declaration.Name}), and this context's options do not register what " + + $"renders it: UseSchemaKit() is there, the registration of {package} is not. No migration could be scaffolded " + + $"or applied from this model. Call the UseSchemaKit() of {package} on its provider's builder, inside the " + + "provider's Use…() call, in place of UseSchemaKit() on the context's own builder; with " + + "UseInternalServiceProvider(), add that package's AddEntityFrameworkSchemaKit…() to the collection as well."; + + public static string MixedVersions(string package, string packageVersion, string kernel, string kernelVersion) + => $"{package} {packageVersion} is loaded beside {kernel} {kernelVersion}. A SchemaKit package works with the kernel " + + "of its own version only: with another, the first call into a member that differs fails with a " + + $"MissingMethodException. Reference {package} and {kernel} at one version; a direct reference to {kernel} " + + "is not needed, since the package brings it."; + public static string DisplacedByReplaceService(Type service, Type replacement) => $"ReplaceService<{service.Name}, {replacement.Name}>() was called after UseSchemaKit() and displaced it: " + "EF Core keeps one replacement per service. SchemaKit declarations would be left out of migrations without an error. " + diff --git a/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitConventionSetPlugin.cs b/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitConventionSetPlugin.cs index 41b43ec..5ea4016 100644 --- a/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitConventionSetPlugin.cs +++ b/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitConventionSetPlugin.cs @@ -1,7 +1,9 @@ using System.Reflection; +using CodoMetis.EFCore.SchemaKit.Contributors.Internal; using CodoMetis.EFCore.SchemaKit.Infrastructure.Internal; using CodoMetis.EFCore.SchemaKit.Migrations.Internal; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Conventions; using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure; @@ -46,7 +48,11 @@ public void ProcessModelInitialized( services.GetService()?.Context.GetType().Assembly, Assembly.GetEntryAssembly()); - modelBuilder.HasAnnotation(SchemaKitModelStamp.Annotation, SchemaKitModelStamp.Value); + // The registrations are plain values: a contributor itself may need the dialect, which reads + // the design-time model, and must not be resolved while a model is being built. + SchemaKitModelStamp.Stamp( + (IMutableModel)modelBuilder.Metadata, + services.GetServices().Select(r => r.Owner)); } private void ThrowUnlessOutermost() where TService : notnull diff --git a/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitModelStamp.cs b/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitModelStamp.cs index 62038fa..9b7c4c9 100644 --- a/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitModelStamp.cs +++ b/src/CodoMetis.EFCore.SchemaKit/Metadata/Internal/SchemaKitModelStamp.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using CodoMetis.EFCore.SchemaKit.Infrastructure.Internal; using Microsoft.EntityFrameworkCore.Metadata; @@ -5,7 +6,7 @@ namespace CodoMetis.EFCore.SchemaKit.Metadata.Internal; /// /// The mark UseSchemaKit() leaves on a model when it is created. A declaration API calls -/// before it stores anything, so a model that declares a SchemaKit object +/// before it stores anything, so a model that declares a SchemaKit object /// without the wiring fails in OnModelCreating, on every path, instead of scaffolding and /// applying without the object. /// @@ -13,12 +14,27 @@ namespace CodoMetis.EFCore.SchemaKit.Metadata.Internal; /// The annotation is written into model snapshots, as Relational:MaxIdentifierLength is, so /// its name and value are permanent from 11.0.0 and the value never changes with the package /// version: a changing value would rewrite every consumer's snapshot on upgrade. +/// +/// Which owners the context can render is kept beside the model and not on it (#113). It differs +/// with the packages a context registers, so as an annotation it would be written into the snapshot +/// and move with them. It is needed only while the model is configured, and a snapshot's model, +/// which declares nothing, has none. +/// /// internal static class SchemaKitModelStamp { public const string Annotation = "CodoMetis:SchemaKit"; public const string Value = "1"; + private static readonly ConditionalWeakTable> Owners = new(); + + /// Stamps a model, with the owners the context has a contributor for. + public static void Stamp(IMutableModel model, IEnumerable owners) + { + model.SetAnnotation(Annotation, Value); + Owners.AddOrUpdate(model, new HashSet(owners, StringComparer.Ordinal)); + } + /// The model being configured. /// What declares, for the message: an entity type's display name. public static void Require(IReadOnlyModel model, string declaring) @@ -26,4 +42,21 @@ public static void Require(IReadOnlyModel model, string declaring) if (model.FindAnnotation(Annotation) is null) throw new InvalidOperationException(SchemaKitStrings.NotWired(declaring)); } + + /// + /// Requires the stamp and a contributor for the declaration's owner. The kernel's + /// UseSchemaKit() alone stamps a model and renders nothing: without this the declaration + /// was stored and the refusal came at the first diff. + /// + /// The model being configured. + /// What declares, for the message: an entity type's display name. + /// The declaration's owner. + /// The declaration's type, whose assembly is the package to register. + public static void Require(IReadOnlyModel model, string declaring, string owner, Type declaration) + { + Require(model, declaring); + if (!Owners.TryGetValue(model, out var owners) || !owners.Contains(owner)) + throw new InvalidOperationException(SchemaKitStrings.NotWiredForOwner( + declaring, owner, declaration, declaration.Assembly.GetName().Name ?? declaration.Assembly.ToString())); + } } diff --git a/src/CodoMetis.EFCore.SchemaKit/SchemaKitPackages.cs b/src/CodoMetis.EFCore.SchemaKit/SchemaKitPackages.cs new file mode 100644 index 0000000..26ba509 --- /dev/null +++ b/src/CodoMetis.EFCore.SchemaKit/SchemaKitPackages.cs @@ -0,0 +1,53 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using CodoMetis.EFCore.SchemaKit.Infrastructure.Internal; + +namespace CodoMetis.EFCore.SchemaKit; + +/// What a SchemaKit package asks of the kernel it finds itself beside. +[Experimental(SchemaKitDiagnosticIds.ContributorContract)] +public static class SchemaKitPackages +{ + /// + /// Refuses a package that was not built with this kernel. A package of this repository depends + /// on the kernel at exactly its own version, and a consumer that references another kernel + /// directly gets a warning from NuGet (NU1608) and a build: the first call into a member that + /// differs between the two then fails with a that names + /// neither package. + /// + /// + /// + /// A package calls it first in its registration, from a method that calls nothing else of the + /// kernel besides it: the runtime binds a method's calls when it compiles the method, so a + /// registration that calls a missing member fails before its first statement runs. + /// + /// + /// This signature is what makes a mixed pair a message, so it does not change: it has been here + /// since the first preview, and a version that changed it would be refused by the runtime and + /// not by this method. + /// + /// + /// The calling package's assembly. + /// The two were built as different versions. + public static void RequireKernel(Assembly package) + { + ArgumentNullException.ThrowIfNull(package); + + var kernel = typeof(SchemaKitPackages).Assembly; + var (theirs, ours) = (VersionOf(package), VersionOf(kernel)); + if (!string.Equals(theirs, ours, StringComparison.Ordinal)) + throw new InvalidOperationException(SchemaKitStrings.MixedVersions( + package.GetName().Name ?? package.ToString(), theirs, kernel.GetName().Name!, ours)); + } + + // The informational version is the package version, with the commit after a '+' when Source + // Link stamps it. The assembly version is 11.0.0.0 for every preview and says nothing here. + private static string VersionOf(Assembly assembly) + { + var version = assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? ""; + var commit = version.IndexOf('+', StringComparison.Ordinal); + return commit < 0 ? version : version[..commit]; + } +} diff --git a/src/CodoMetis.EFCore.SchemaKit/SchemaKitServiceCollectionExtensions.cs b/src/CodoMetis.EFCore.SchemaKit/SchemaKitServiceCollectionExtensions.cs index fc7b2f6..90cd3cc 100644 --- a/src/CodoMetis.EFCore.SchemaKit/SchemaKitServiceCollectionExtensions.cs +++ b/src/CodoMetis.EFCore.SchemaKit/SchemaKitServiceCollectionExtensions.cs @@ -54,7 +54,9 @@ public static IServiceCollection AddEntityFrameworkSchemaKit(this IServiceCollec /// /// /// Adding the same contributor twice adds it once. Two different contributors with one owner - /// are refused at the first diff. + /// are refused at the first diff. This call is also what lets a model declare the + /// contributor's objects: a declaration whose owner no call registered is refused where it is + /// written, and a contributor added to the collection another way is not counted. /// /// /// does not derive from . @@ -74,6 +76,13 @@ public static IServiceCollection AddSchemaContributor(this IServic throw new InvalidOperationException(SchemaKitStrings.ContributorNotDerived(typeof(TContributor))); services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + // What a declaration is checked against where it is written. An owner the registry will + // refuse is refused there; here it only has to be a value. + var registration = new SchemaContributorRegistration( + SchemaContributorRegistry.OwnerOf(typeof(TContributor)) ?? "", typeof(TContributor)); + if (!services.Any(d => registration.Equals(d.ImplementationInstance))) + services.AddSingleton(registration); return services; } diff --git a/test/CodoMetis.EFCore.SchemaKit.PostgreSQL.Tests/NpgsqlWiringTests.cs b/test/CodoMetis.EFCore.SchemaKit.PostgreSQL.Tests/NpgsqlWiringTests.cs index 89dfaf3..18514ae 100644 --- a/test/CodoMetis.EFCore.SchemaKit.PostgreSQL.Tests/NpgsqlWiringTests.cs +++ b/test/CodoMetis.EFCore.SchemaKit.PostgreSQL.Tests/NpgsqlWiringTests.cs @@ -64,6 +64,34 @@ public void The_kernels_own_dialect_lets_the_same_name_through() Assert.AreEqual(40, context.GetService().Identifier(FortyTwoByteCharacters).Name.Length); } + private sealed class KernelOnlyDeclaring(DbContextOptions options) : DbContext(options) + { + protected override void OnModelCreating(ModelBuilder modelBuilder) => Models.Scheduling.Build(modelBuilder); + } + + /// + /// UseNpgsql(cs).UseSchemaKit() is the kernel's method and compiles as soon as its + /// namespace is imported. The declaration passed on the kernel's stamp, and the refusal came at + /// the first diff, naming a package the consumer already references (#113). + /// + [TestMethod] + public void The_kernels_call_alone_is_refused_where_a_PostgreSQL_object_is_declared() + { + using var context = new KernelOnlyDeclaring(new DbContextOptionsBuilder() + .ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) + .UseNpgsql(Contexts.Offline).UseSchemaKit().Options); + + var refusal = Assert.ThrowsExactly(() => context.Model); + + StringAssert.Contains(refusal.Message, "declares a 'Pg.Exclusion' object"); + StringAssert.Contains(refusal.Message, "Call the UseSchemaKit() of CodoMetis.EFCore.SchemaKit.PostgreSQL on its provider's builder"); + } + + /// The package's two registrations ask the kernel for its version first; here the two are one build. + [TestMethod] + public void The_package_is_of_the_kernels_version() + => SchemaKitPackages.RequireKernel(typeof(PgExclusion).Assembly); + private sealed class LongName : ITestModel { public static void Build(ModelBuilder modelBuilder) => modelBuilder.Entity() diff --git a/test/CodoMetis.EFCore.SchemaKit.Tests/Conventions/PublicSurfaceTests.cs b/test/CodoMetis.EFCore.SchemaKit.Tests/Conventions/PublicSurfaceTests.cs index 6f8fa41..e62156b 100644 --- a/test/CodoMetis.EFCore.SchemaKit.Tests/Conventions/PublicSurfaceTests.cs +++ b/test/CodoMetis.EFCore.SchemaKit.Tests/Conventions/PublicSurfaceTests.cs @@ -69,6 +69,7 @@ public sealed class PublicSurfaceTests "CodoMetis.EFCore.SchemaKit.SchemaDialect", "CodoMetis.EFCore.SchemaKit.SchemaDialectDependencies", "CodoMetis.EFCore.SchemaKit.SchemaJsonReader", + "CodoMetis.EFCore.SchemaKit.SchemaKitPackages", "CodoMetis.EFCore.SchemaKit.SchemaJsonWriter", "CodoMetis.EFCore.SchemaKit.SchemaNameComparison", "CodoMetis.EFCore.SchemaKit.SchemaPlacement", diff --git a/test/CodoMetis.EFCore.SchemaKit.Tests/Declarations/DeclarationStoreTests.cs b/test/CodoMetis.EFCore.SchemaKit.Tests/Declarations/DeclarationStoreTests.cs index 19b09a4..e1bb1f2 100644 --- a/test/CodoMetis.EFCore.SchemaKit.Tests/Declarations/DeclarationStoreTests.cs +++ b/test/CodoMetis.EFCore.SchemaKit.Tests/Declarations/DeclarationStoreTests.cs @@ -29,7 +29,7 @@ public sealed class DeclarationStoreTests private static IMutableEntityType Entity(bool stamped = true) { var builder = new ModelBuilder(); - if (stamped) builder.Model.SetAnnotation(SchemaKitModelStamp.Annotation, SchemaKitModelStamp.Value); + if (stamped) SchemaKitModelStamp.Stamp(builder.Model, ["Test.Guard", "Test.Loose", "Test.Domain"]); return builder.Entity().Metadata; } diff --git a/test/CodoMetis.EFCore.SchemaKit.Tests/Migrations/SchemaOperationPlannerTests.cs b/test/CodoMetis.EFCore.SchemaKit.Tests/Migrations/SchemaOperationPlannerTests.cs index 64a222d..cd0d239 100644 --- a/test/CodoMetis.EFCore.SchemaKit.Tests/Migrations/SchemaOperationPlannerTests.cs +++ b/test/CodoMetis.EFCore.SchemaKit.Tests/Migrations/SchemaOperationPlannerTests.cs @@ -768,21 +768,62 @@ public void What_tables_are_made_of_changes_in_place_or_not_at_all() // ---- what the planner refuses, and what it leaves alone --------------------------------------------- - /// A declaration nothing renders would be left out of every migration, in silence. + /// + /// A declaration nothing renders would be left out of every migration, in silence. The context's + /// own model cannot hold one any more, since it is refused where it is written (#113); a + /// snapshot can, scaffolded while the package that declared it was still registered. + /// [TestMethod] public void A_declaration_with_no_contributor_is_refused_on_either_side() { - var builder = new DbContextOptionsBuilder>().UseProvider(Provider.Npgsql); - using var context = new Planned(builder.UseSchemaKit().Options); + using var declaring = Context(); + var builder = new DbContextOptionsBuilder>().UseProvider(Provider.Npgsql); + using var context = new Planned(builder.UseSchemaKit().Options); var differ = context.GetService(); - foreach (var (source, target) in new[] { ((IRelationalModel?)null, (IRelationalModel?)Model(context)), (Model(context), null) }) + foreach (var (source, target) in new[] { ((IRelationalModel?)null, (IRelationalModel?)Model(declaring)), (Model(declaring), null) }) { var error = Assert.ThrowsExactly(() => differ.GetDifferences(source, target)); StringAssert.Contains(error.Message, "Thing carries a declaration of 'Test.Guard', and no contributor with that owner is registered"); } } + /// + /// The kernel's UseSchemaKit() stamps a model and renders nothing. A declaration whose + /// contributor the options do not register was stored on that stamp and refused at the first + /// diff; it is refused where it is written, on every provider, and names what to register (#113). + /// + [TestMethod, DataRow(Provider.Npgsql), DataRow(Provider.SqlServer), DataRow(Provider.Sqlite)] + public void A_declaration_whose_contributor_the_options_do_not_register_is_refused_where_it_is_written(Provider provider) + { + var builder = new DbContextOptionsBuilder>().UseProvider(provider); + using var context = new Planned(builder.UseSchemaKit().Options); + + var error = Assert.ThrowsExactly(() => context.Model); + + StringAssert.Contains(error.Message, "Thing declares a 'Test.Guard' object"); + StringAssert.Contains(error.Message, "UseSchemaKit() is there, the registration of CodoMetis.EFCore.SchemaKit.Tests is not"); + } + + /// The allowed twin, and what the refusal reads: one contributor is registered, and only its owner may be declared. + [TestMethod] + public void Only_the_owners_the_options_register_may_be_declared() + { + static Planned OnlyTheGuard() where TModel : IPlanModel + { + var builder = new DbContextOptionsBuilder>().UseProvider(Provider.Npgsql); + ServicesExtension.Add(builder, "the guard alone", services => services.AddSchemaContributor()); + return new Planned(builder.UseSchemaKit().Options); + } + + using var guarded = OnlyTheGuard(); + Assert.AreEqual(1, guarded.Model.GetEntityTypes().Single().GetDeclarations().Count); + + using var domain = OnlyTheGuard(); + var error = Assert.ThrowsExactly(() => domain.Model); + StringAssert.Contains(error.Message, "declares a 'Test.Domain' object"); + } + /// /// A context that declares nothing pays nothing: the dialect, which reads the design-time model, /// is not built. The second half shows the first is not vacuous. diff --git a/test/CodoMetis.EFCore.SchemaKit.Tests/Wiring/PackageVersionTests.cs b/test/CodoMetis.EFCore.SchemaKit.Tests/Wiring/PackageVersionTests.cs new file mode 100644 index 0000000..e14a109 --- /dev/null +++ b/test/CodoMetis.EFCore.SchemaKit.Tests/Wiring/PackageVersionTests.cs @@ -0,0 +1,67 @@ +using System.Reflection; +using System.Reflection.Emit; + +namespace CodoMetis.EFCore.SchemaKit.Tests.Wiring; + +/// +/// A SchemaKit package beside a kernel of another version (#113). NuGet lets it through with a +/// warning when the consumer references the kernel directly, and without the check the pair fails +/// with a at whichever member differs first. +/// +[TestClass] +public sealed class PackageVersionTests +{ + private static readonly Assembly Kernel = typeof(SchemaKitPackages).Assembly; + + private static string KernelVersion + { + get + { + var version = Kernel.GetCustomAttribute()!.InformationalVersion; + return version.Contains('+') ? version[..version.IndexOf('+')] : version; + } + } + + /// An assembly that says it was built as the version, as a package's does. + private static Assembly Package(string name, string? informationalVersion) + { + var attributes = informationalVersion is null + ? Array.Empty() + : [new CustomAttributeBuilder(typeof(AssemblyInformationalVersionAttribute).GetConstructor([typeof(string)])!, [informationalVersion])]; + return AssemblyBuilder.DefineDynamicAssembly(new AssemblyName(name) { Version = new Version(11, 0, 0, 0) }, AssemblyBuilderAccess.Run, attributes); + } + + [TestMethod] + public void A_package_of_the_kernels_version_is_taken_whatever_commit_either_was_built_from() + { + SchemaKitPackages.RequireKernel(Package("Some.Package", KernelVersion)); + SchemaKitPackages.RequireKernel(Package("Some.Package", KernelVersion + "+0123456789abcdef")); + } + + [TestMethod] + public void A_package_of_another_version_is_refused_with_both_versions_and_both_names() + { + var error = Assert.ThrowsExactly( + () => SchemaKitPackages.RequireKernel(Package("Some.Package", "11.0.0-preview.0+0123456789abcdef"))); + + StringAssert.Contains(error.Message, $"Some.Package 11.0.0-preview.0 is loaded beside CodoMetis.EFCore.SchemaKit {KernelVersion}."); + StringAssert.Contains(error.Message, "Reference Some.Package and CodoMetis.EFCore.SchemaKit at one version"); + } + + /// + /// The assembly version is 11.0.0.0 for every preview, so a package that states no + /// informational version must not pass on it. + /// + [TestMethod] + public void The_assembly_version_alone_does_not_pass() + { + Assert.AreEqual(new Version(11, 0, 0, 0), Kernel.GetName().Version, "If this moves with the package version, the check may read it."); + + Assert.ThrowsExactly(() => SchemaKitPackages.RequireKernel(Package("Some.Package", null))); + } + + /// The kernel's own version has a suffix to tell apart: without one the test above would prove nothing about previews. + [TestMethod] + public void The_kernel_states_its_package_version() + => StringAssert.Matches(KernelVersion, new System.Text.RegularExpressions.Regex(@"^11\.\d+\.\d+(-[0-9A-Za-z.]+)?$")); +} diff --git a/test/ConsumerSmokeTest/Mixed/Mixed.csproj b/test/ConsumerSmokeTest/Mixed/Mixed.csproj new file mode 100644 index 0000000..196a19d --- /dev/null +++ b/test/ConsumerSmokeTest/Mixed/Mixed.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net11.0 + enable + enable + + + + + + + + + diff --git a/test/ConsumerSmokeTest/Mixed/Program.cs b/test/ConsumerSmokeTest/Mixed/Program.cs new file mode 100644 index 0000000..44c86df --- /dev/null +++ b/test/ConsumerSmokeTest/Mixed/Program.cs @@ -0,0 +1,8 @@ +using CodoMetis.EFCore.SchemaKit.PostgreSQL; +using Microsoft.EntityFrameworkCore; + +// The registration alone: it is where the PostgreSQL package asks the kernel for its version, and +// no server is reached. With the check the process ends on SchemaKit's message; without it this +// prints REGISTERED, or dies of a MissingMethodException at the first member that differs. +new DbContextOptionsBuilder().UseNpgsql("Host=localhost", o => o.UseSchemaKit()); +Console.WriteLine("REGISTERED"); diff --git a/test/ConsumerSmokeTest/run.sh b/test/ConsumerSmokeTest/run.sh index 79778d3..c873855 100755 --- a/test/ConsumerSmokeTest/run.sh +++ b/test/ConsumerSmokeTest/run.sh @@ -34,7 +34,9 @@ # test fails without it: no-schemakit (no UseSchemaKit()), kernel-only (the kernel's UseSchemaKit() # on the outer builder in place of the provider's), user-differ (the consumer's own # IDesignTimeServices registers EF Core's differ). Each must make the run fail. The rows that ask -# for a refusal set their wiring themselves and keep it. +# for a refusal set their wiring themselves and keep it. The version check has no switch: it is +# shown by taking RequireKernel out of the package's UseSchemaKit(), after which the mixed consumer +# prints REGISTERED. set -uo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) @@ -169,6 +171,15 @@ else echo " the packages in $feed" fi +# A kernel of another version, for the consumer that mixes the two (#113). Higher than the one under +# test: NuGet refuses a direct reference below a dependency's pin (NU1605) and lets one above it +# through with a warning. Packed from this checkout in both modes, into a folder of its own, since +# a feed that was handed in is not written to. +mixed_version=$version.mixed +mixed_feed=$work/feed-mixed +in_dir "$root" pack-mixed-kernel dotnet pack "$root/src/CodoMetis.EFCore.SchemaKit/CodoMetis.EFCore.SchemaKit.csproj" -c Release -o "$mixed_feed" -p:Version="$mixed_version" +[[ $code == 0 ]] || fail "pack the kernel as $mixed_version" "dotnet pack failed" + kernel_package=$feed/CodoMetis.EFCore.SchemaKit.$version.nupkg pg_package=$feed/CodoMetis.EFCore.SchemaKit.PostgreSQL.$version.nupkg [[ -f $kernel_package && -f $pg_package ]] || { log=; fail "the packages" "no $version of both packages in $feed"; } @@ -193,10 +204,12 @@ cat > "$work/nuget.config" < + + @@ -206,6 +219,7 @@ write_props() { # Npgsql's version, once the consumer has restored it $version + $mixed_version $ef_version $npgsql_ef_version $1 @@ -252,11 +266,24 @@ refused "no UseSchemaKit(): EnsureCreated()" "UseSchemaKit() is not on this cont export SMOKE_WIRING=kernel ef "$app_dir" migrations add KernelOnly -refused "the kernel's UseSchemaKit() alone: migrations add" "no contributor with that owner is registered" KernelOnly +refused "the kernel's UseSchemaKit() alone: migrations add" "the registration of CodoMetis.EFCore.SchemaKit.PostgreSQL is not" KernelOnly app ensure-created -refused "the kernel's UseSchemaKit() alone: EnsureCreated()" "no contributor with that owner is registered" +refused "the kernel's UseSchemaKit() alone: EnsureCreated()" "the registration of CodoMetis.EFCore.SchemaKit.PostgreSQL is not" unset SMOKE_WIRING +# The PostgreSQL package beside a kernel of another version. The build's log has to hold NuGet's +# warning: without it the consumer would have got one kernel after all and the row would test nothing. +build "$work/Mixed" +build_log=$log +in_dir "$work/Mixed" mixed-run dotnet bin/Debug/net11.0/Mixed.dll +if has "$build_log" NU1608 && [[ $code != 0 ]] \ + && has "$log" "CodoMetis.EFCore.SchemaKit.PostgreSQL $version is loaded beside CodoMetis.EFCore.SchemaKit $mixed_version" \ + && lacks "$log" REGISTERED "System.MissingMethodException:"; then + ok "a kernel of another version beside the package" "NuGet builds the pair with NU1608; refused: …is loaded beside…" +else + fail "a kernel of another version beside the package" "the pair was not refused with both versions (exit $code; NU1608 in the build: $(has "$build_log" NU1608 && echo yes || echo no))" +fi + # ---- design time ---------------------------------------------------------------------------------------- echo "==> Design time, through the host's AddDbContext" From 6bb0c778bf69bd4fd6aed39bad83974c2825ffd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Gro=C3=9F?= Date: Sat, 14 Nov 2026 17:11:12 +0100 Subject: [PATCH 2/2] docs: the kernel's UseSchemaKit() keeps its name Decided by the maintainer on 2026-11-14. The plan's section 7 records it, and the guide no longer says the name is open. Closes #141 Co-Authored-By: Claude Fable 5.2 --- design/schemakit-core-plan.md | 1 + docs/moving-from-complexindexes-5x.md | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/design/schemakit-core-plan.md b/design/schemakit-core-plan.md index d64a941..3bbfd6f 100644 --- a/design/schemakit-core-plan.md +++ b/design/schemakit-core-plan.md @@ -615,6 +615,7 @@ Taken by the maintainer on 2026-11-11. "As recommended" means the plan above alr | The catalog verifier ([3.11](#311-the-catalog-verifier)) | In 11.0.0, for the PostgreSQL kinds | | Identity: no silent replacement ([3.4](#34-declarations-identity-storage)) | Adopted. It repairs a confirmed defect, so it was not put as a choice | | The contract: `Plan` and `ImpactOf` mandatory, frozen after an outside contributor ([3.10](#310-the-contributor-contract)) | Adopted, with the freeze at `rc.1` | +| The name of the kernel's own `UseSchemaKit()` on `DbContextOptionsBuilder`, beside a provider package's (#141, decided 2026-11-14) | It keeps its name, and is neither renamed nor hidden. The guard is the refusal where an object is declared without its package's registration (#113) | **Surface** (the open questions of `design/schemakit-api.md`, section 16) diff --git a/docs/moving-from-complexindexes-5x.md b/docs/moving-from-complexindexes-5x.md index eda2f97..d59a319 100644 --- a/docs/moving-from-complexindexes-5x.md +++ b/docs/moving-from-complexindexes-5x.md @@ -70,8 +70,7 @@ What happens when a step is missed, as this preview has it: | `ReplaceService` of the differ or the SQL generator after `UseSchemaKit()` | The first model built is refused, and the message says to call `UseSchemaKit()` last | Each row but the second is a row of the consumer smoke test (`test/ConsumerSmokeTest/run.sh`), on -the packed packages. Whether the kernel's own `UseSchemaKit()` keeps its name is not settled for -`11.0.0` ([#141](https://github.com/CaffeinatedCoder/CodoMetis.EFCore.SchemaKit/issues/141)). +the packed packages. ## Exclusion constraints