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..3bbfd6f 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 @@ -614,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 f110eac..d59a319 100644 --- a/docs/moving-from-complexindexes-5x.md +++ b/docs/moving-from-complexindexes-5x.md @@ -65,11 +65,12 @@ 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. ## 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"