Make the models implement Schema.Contracts (#110) - #129
Merged
Conversation
Closes #110 by taking option (a): the contracts become a real seam rather than 24 public interfaces nothing implements. The interfaces were not implementable as written. ISchema.Classes was typed ISchemaChildSet<ISchemaClass, ISchemaClassName>, and ISchemaChildSet derived from ISet<T>, which is invariant because Add(T) puts T in an input position. No arrangement of type parameters makes a set of concrete elements assignable to a set of interfaces. ISchemaChildSet is now covariant in its element type and drops the mutating members that forced invariance. Its lookup returns the element rather than using an out parameter, which C# also treats as an invariant position. Mutation moves onto the owning element - ISchema.AddClass, ISchemaClass.AddMember - which is where it belongs anyway: those are the choke points that enforce name uniqueness and give a new element the parent reference it needs to resolve its own type references. That leaves the name type invariant, so the contracts name the concrete ClassName rather than an ISchemaClassName. Entities are abstracted; values are not. A semantic string is already an abstraction over string, and wrapping it again buys nothing while making the variance unworkable. The five name interfaces used as generic constraints stay; the seven that only existed to be named in type arguments go, along with ISchemaChildDescription and ISchemaChildSummary. Three contracts described something the library does not have, and are reshaped to reality rather than the models being bent to fit: - ISchemaChild required a Summary no model has. Adding one would mean a new serialized field on every element, immediately after #113 collapsed two overlapping description fields into one. Dropped, with the unused SchemaChildSummary record. - ISchemaEnum exposed values as child elements. The format stores them as a list of strings, so they are exposed as names. SchemaEnumValue described a shape nothing produces and is removed. - ISchemaType derived from ISchemaMemberChild, but BaseType has never been a member child - it has no name, description or parent class. It is now its own interface carrying what a type actually has: which type it is, and the member holding it. SchemaMemberChild had no derived types left and goes too. BaseType.TypeName gives BaseTypeName a job it did not have: it reports the discriminator written to the file, derived from the CLR type name that the [JsonDerivedType] attributes are declared with. A test asserts the two agree for all seventeen types. SchemaChildSet is rewritten as an order-preserving view over the collection its owner serializes. It was backed by a HashSet, which is unordered: adopting it as the backing store would have broken member reordering (#118), and would have reordered a class's members after any remove-then-add - which is what undoing a deletion does. As a view there is also no second copy to diverge from the serialized one, and no change to the file format. It now owns the name-uniqueness rule that SchemaClass.AddMember, SchemaClass.RestoreMember, Schema.AddChild and Schema.RestoreChild each re-implemented as an Any(x => x.Name == name) check. Uniqueness is enforced on the way in, not on the way through: deserialization writes to the underlying collection directly, so a hand-edited file with duplicate names still loads with both elements present and is reported by Validate(). Silently dropping one at load would turn a diagnosable mistake into data loss - the same failure mode as the dropped member type fixed in Schema/Models/SchemaMember.cs. Also removes RootSchemaMember, never instantiated anywhere, and SchemaTypes.TypeQualifier, which returned a nested-type prefix for a layout the types no longer use. docs/examples/dependency-injection.md now shows injecting ISchema and says what the contract does not cover. CLAUDE.md's type hierarchy is corrected - it claimed BaseType derived from SchemaMemberChild<BaseTypeName>, which has not been true. Breaking: public interfaces and types are removed and ISchemaChildSet changes shape. Nothing in the package could hand a consumer any of the removed interfaces, so code using them had to supply its own implementations. Lands with a major version bump. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012WmzGm9XniSoqaiVGDT6qT
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Closes #110 by taking option (a), per your decision. The reasoning is recorded on the issue; this is the short version.
Breaking change — needs a major version bump.
The interfaces were not implementable as written
ISchema.Classeswas typedISchemaChildSet<ISchemaClass, ISchemaClassName>, andISchemaChildSet<TValue, TName> : ISet<TValue>.ISet<T>is invariant, becauseAdd(T)putsTin an input position — soSchemaChildSet<SchemaClass, ClassName>is not anISchemaChildSet<ISchemaClass, ISchemaClassName>, and no arrangement of type parameters makes it one.So the set becomes covariant in its element type, dropping the mutating members that forced invariance:
The lookup returns its result rather than using an
outparameter — C# treats those as invariant positions too. Mutation moves onto the owning element (ISchema.AddClass,ISchemaClass.AddMember), which is where it belonged anyway: those are the choke points that enforce name uniqueness and give a new element the parent reference it needs to resolve its own type references.TNamestays invariant, so the contracts name the concreteClassName, notISchemaClassName. Entities are abstracted; values are not. A semantic string is already an abstraction overstring; wrapping it again buys nothing and is exactly what made the variance unworkable. The five name interfaces that are load-bearing as generic constraints stay; the seven that existed only to be named in type arguments go, withISchemaChildDescriptionandISchemaChildSummary.Three contracts described something the library doesn't have
Reshaped to reality rather than bending the models to fit:
ISchemaChild.SummaryISchemaEnum.ValuesISchemaTypeISchemaMemberChildBaseTypehas no name, description or parent classSchemaEnumValuedescribed a shape nothing produces, and is removed withISchemaEnumValue.ISchemaTypenow carries what a type actually has: which type it is, and the member holding it.SchemaMemberChildhad no derived types left and goes too.That last one gives
BaseTypeNamea job it never had —BaseType.TypeNamereports the discriminator written to the file, derived from the same CLR type name the[JsonDerivedType]attributes are declared with. A test asserts the two agree across all seventeen types, so they can't drift.SchemaChildSetwas rewritten, not adoptedIt was backed by
HashSet<T>, which is unordered. Using it as the model's backing store as the issue suggested would have broken member reordering (#118), and would have reordered a class's members after any remove-then-add — which is what undoing a deletion does.HashSetpreserving insertion order absent removals is an implementation detail, not a guarantee.It's now an order-preserving view over the collection its owner serializes. As a view there's no second copy to diverge and no change to the file format. It owns the name-uniqueness rule that
SchemaClass.AddMember,SchemaClass.RestoreMember,Schema.AddChildandSchema.RestoreChildeach re-implemented as anAny(x => x.Name == name)check.Uniqueness is enforced on the way in, not on the way through. Deserialization writes to the underlying collection directly, so a hand-edited file containing duplicate names still loads with both present and is reported by
Validate(). Silently de-duplicating at load would turn a diagnosable mistake into data loss — the same failure mode as the dropped member type fixed in #124. There's a test for it.Also removed, per the issue
RootSchemaMember(never instantiated) andSchemaTypes.TypeQualifier(returned a nested-type prefix for a layout the types no longer use).Docs
docs/examples/dependency-injection.mdnow shows injectingISchemaand states plainly what the contract does not cover (serialization, validation, path resolution, data sources, generators — takeSchemafor those).CLAUDE.md's type hierarchy is corrected: it claimedBaseType : SchemaMemberChild<BaseTypeName>, which hasn't been true.Verification
299 tests pass on net8.0, net9.0 and net10.0 (287 before). The 12 new ones cover defining a schema through
ISchemaalone, navigating the model in both directions through the contracts, the covariance actually holding and the view being live rather than a snapshot, uniqueness and removal through the contract, rejecting a foreignISchemaType,TypeNamematching the serialized discriminator, insertion order, order after remove-and-restore, duplicate rejection, bounds-checked moves, and duplicates in a file surviving load to be reported.SonarAnalyzer.CSharprun locally over the library after the change: every pre-existing rule count is at or below baseline and no new rule appears — S3236 drops 36 → 2, S1450 and S1133 go to zero.On the breakage
Nothing in the package could hand a consumer any of the removed interfaces — no model implemented them — so code referencing them had to supply its own implementations. The practical break is narrower than the count of removed public types suggests.
Closes #110
🤖 Generated with Claude Code
https://claude.ai/code/session_012WmzGm9XniSoqaiVGDT6qT
Generated by Claude Code