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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`, `FindDeclaration<T>(name)` and `Redeclare<T>(…)` 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)).
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion design/schemakit-core-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 4 additions & 3 deletions docs/moving-from-complexindexes-5x.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
using CodoMetis.EFCore.SchemaKit.PostgreSQL.Internal;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
Expand Down Expand Up @@ -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();

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,37 @@ public static bool DerivesFromTheBaseClass(Type contributor)
return false;
}

/// <summary>
/// The owner of a contributor, read from its type: <c>TDeclaration.OwnerId</c> of the base
/// class, which is what <see cref="SchemaContributor{TDeclaration, TDescriptor}.Owner" /> answers.
/// Null for a class that does not derive from it.
/// </summary>
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<TDeclaration>() where TDeclaration : class, ISchemaDeclaration<TDeclaration>
=> TDeclaration.OwnerId;

public IReadOnlyCollection<ISchemaContributor> All => _byOwner.Values;

public ISchemaContributor? Find(string owner) => _byOwner.GetValueOrDefault(owner);
}

/// <summary>
/// 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. <c>AddSchemaContributor</c> adds it.
/// </summary>
/// <param name="Owner">The contributor's owner.</param>
/// <param name="Contributor">The contributor's type.</param>
internal sealed record SchemaContributorRegistration(string Owner, Type Contributor);
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public static class SchemaDeclarationExtensions
/// <param name="entityType">The entity type the objects belong to.</param>
/// <param name="declarations">The declarations.</param>
/// <exception cref="InvalidOperationException">
/// <c>UseSchemaKit()</c> is not on the context's options, or a declaration's key is taken.
/// <c>UseSchemaKit()</c> is not on the context's options, they register no contributor for a
/// declaration's owner, or a declaration's key is taken.
/// </exception>
[Experimental(SchemaKitDiagnosticIds.ContributorContract)]
public static void Declare(this IMutableEntityType entityType, IEnumerable<ISchemaObject> declarations)
Expand All @@ -43,6 +44,7 @@ public static void Declare(this IMutableEntityType entityType, IEnumerable<ISche
foreach (var declaration in declarations)
{
ArgumentNullException.ThrowIfNull(declaration, nameof(declarations));
SchemaKitModelStamp.Require(entityType.Model, entityType.DisplayName(), declaration.Owner, declaration.GetType());
var key = DeclarationCodec.Key(declaration);

if (added.TryGetValue(key, out var earlier))
Expand Down Expand Up @@ -73,7 +75,7 @@ public static int Redeclare<TDeclaration>(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<TDeclaration>(entityType).ToList();
Dictionary<string, (TDeclaration Declaration, string Value)> result = new(StringComparer.Ordinal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. " +
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -46,7 +48,11 @@ public void ProcessModelInitialized(
services.GetService<ICurrentDbContext>()?.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<SchemaContributorRegistration>().Select(r => r.Owner));
}

private void ThrowUnlessOutermost<TService, TOurs>() where TService : notnull
Expand Down
Loading
Loading