From c9653fb6247b1148f32e17421d72941d87e61cf0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:46:47 +0000 Subject: [PATCH] Make the models implement the Schema.Contracts abstraction 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, and ISchemaChildSet derived from ISet, 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, 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 Claude-Session: https://claude.ai/code/session_012WmzGm9XniSoqaiVGDT6qT --- CLAUDE.md | 40 ++- Schema.Test/SchemaContractsTests.cs | 285 ++++++++++++++++++ Schema.Test/SchemaRenameTests.cs | 2 +- Schema.Test/SchemaSerializerTests.cs | 2 +- Schema/Contracts/ISchema.cs | 42 ++- Schema/Contracts/ISchemaChild.cs | 10 +- Schema/Contracts/ISchemaChildDescription.cs | 12 - Schema/Contracts/ISchemaChildSet.cs | 53 ++-- Schema/Contracts/ISchemaChildSummary.cs | 12 - Schema/Contracts/ISchemaClass.cs | 26 +- Schema/Contracts/ISchemaEnum.cs | 28 +- Schema/Contracts/ISchemaEnumValue.cs | 19 -- Schema/Contracts/ISchemaMember.cs | 17 +- Schema/Contracts/ISchemaMemberChild.cs | 17 -- Schema/Contracts/ISchemaType.cs | 22 +- Schema/Contracts/Names/ISchemaClassName.cs | 10 - .../Names/ISchemaCodeGeneratorName.cs | 10 - .../Contracts/Names/ISchemaDataSourceName.cs | 10 - Schema/Contracts/Names/ISchemaEnumName.cs | 10 - .../Contracts/Names/ISchemaEnumValueName.cs | 10 - .../Contracts/Names/ISchemaMemberChildName.cs | 10 - Schema/Contracts/Names/ISchemaMemberName.cs | 10 - Schema/Contracts/Names/ISchemaTypeName.cs | 10 - Schema/Models/Names/BaseTypeName.cs | 2 +- Schema/Models/Names/ClassName.cs | 2 +- Schema/Models/Names/CodeGeneratorName.cs | 2 +- Schema/Models/Names/ContainerName.cs | 2 +- Schema/Models/Names/DataSourceName.cs | 2 +- Schema/Models/Names/EnumName.cs | 2 +- Schema/Models/Names/EnumValueName.cs | 2 +- Schema/Models/Names/MemberName.cs | 2 +- Schema/Models/Schema.cs | 69 +++-- Schema/Models/SchemaChild.cs | 9 +- Schema/Models/SchemaChildDescription.cs | 3 +- Schema/Models/SchemaChildNameComparer.cs | 42 --- Schema/Models/SchemaChildSet.cs | 243 ++++++--------- Schema/Models/SchemaChildSummary.cs | 13 - Schema/Models/SchemaClass.cs | 75 +++-- Schema/Models/SchemaClassChild.cs | 9 +- Schema/Models/SchemaEnum.cs | 21 +- Schema/Models/SchemaEnumValue.cs | 41 --- Schema/Models/SchemaMember.cs | 32 +- Schema/Models/SchemaMemberChild.cs | 26 -- Schema/Models/Types/BaseType.cs | 22 +- Schema/Models/Types/SchemaTypes.cs | 13 - SchemaEditor/TreeClass.cs | 2 +- docs/examples/dependency-injection.md | 63 +++- 47 files changed, 785 insertions(+), 581 deletions(-) create mode 100644 Schema.Test/SchemaContractsTests.cs delete mode 100644 Schema/Contracts/ISchemaChildDescription.cs delete mode 100644 Schema/Contracts/ISchemaChildSummary.cs delete mode 100644 Schema/Contracts/ISchemaEnumValue.cs delete mode 100644 Schema/Contracts/ISchemaMemberChild.cs delete mode 100644 Schema/Contracts/Names/ISchemaClassName.cs delete mode 100644 Schema/Contracts/Names/ISchemaCodeGeneratorName.cs delete mode 100644 Schema/Contracts/Names/ISchemaDataSourceName.cs delete mode 100644 Schema/Contracts/Names/ISchemaEnumName.cs delete mode 100644 Schema/Contracts/Names/ISchemaEnumValueName.cs delete mode 100644 Schema/Contracts/Names/ISchemaMemberChildName.cs delete mode 100644 Schema/Contracts/Names/ISchemaMemberName.cs delete mode 100644 Schema/Contracts/Names/ISchemaTypeName.cs delete mode 100644 Schema/Models/SchemaChildNameComparer.cs delete mode 100644 Schema/Models/SchemaChildSummary.cs delete mode 100644 Schema/Models/SchemaEnumValue.cs delete mode 100644 Schema/Models/SchemaMemberChild.cs delete mode 100644 Schema/Models/Types/SchemaTypes.cs diff --git a/CLAUDE.md b/CLAUDE.md index b2dcf35..4b334d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,23 +28,43 @@ dotnet run --project SchemaTool -- generate my.schema.json # Run a schema's cod The type system uses polymorphic JSON serialization with `System.Text.Json`: ``` -SchemaChild (base for top-level elements) +SchemaChild (base for named elements) ├── SchemaClass : SchemaChild ├── SchemaEnum : SchemaChild ├── DataSource : SchemaChild -└── SchemaCodeGenerator : SchemaChild - -SchemaMemberChild (base for member-level elements) -└── SchemaTypes.BaseType : SchemaMemberChild - ├── Primitives: Int, Long, Float, Double, String, Bool, DateTime, TimeSpan - ├── Vectors: Vector2, Vector3, Vector4, ColorRGB, ColorRGBA - └── Complex: Array, Object, Enum, None +├── SchemaCodeGenerator : SchemaChild +└── SchemaClassChild : SchemaChild + └── SchemaMember : SchemaClassChild + +BaseType (types, in ktsu.Schema.Models.Types) +├── Primitives: Int, Long, Float, Double, String, Bool, DateTime, TimeSpan +├── Vectors: Vector2, Vector3, Vector4, ColorRGB, ColorRGBA +└── Complex: Array, Object, Enum, None ``` +A type is not a named child of the schema: it has no name or description of its own and exists only +as the type of the member holding it. `BaseType.TypeName` reports which type it is, and is the same +value written as the file's `TypeName` discriminator. + +### Contracts + +`ktsu.Schema.Contracts` is the abstraction seam the models implement: `Schema : ISchema`, +`SchemaClass : ISchemaClass`, `SchemaMember : ISchemaMember`, `SchemaEnum : ISchemaEnum`, +`BaseType : ISchemaType`. Inject `ISchema` where a consumer only defines and reads schema elements. + +Entities are abstracted; values are not. Name types (`ClassName`, `MemberName`, …) and +`SchemaChildDescription` appear in the contracts as themselves — a semantic string is already an +abstraction over `string`, and wrapping it again would make `ISchemaChildSet` +unusable, since a covariant element type cannot coexist with a varying name type. + +Collections on the contracts are read-only views (`ISchemaChildSet`). Mutation lives on the owning +element (`ISchema.AddClass`, `ISchemaClass.AddMember`), which is what enforces name uniqueness and +establishes parent association. + ### Semantic String Types The library uses `ktsu.Semantics.Strings` for type-safe identifiers. Convert strings using `.As()`: -- `ClassName`, `MemberName`, `EnumName`, `EnumValueName`, `BaseTypeName`, `ContainerName` +- `ClassName`, `MemberName`, `EnumName`, `EnumValueName`, `BaseTypeName`, `ContainerName`, `DataSourceName`, `CodeGeneratorName` Example: `"User".As()` @@ -54,7 +74,9 @@ Schema elements maintain parent references via `AssociateWith()` methods. After ### Key Files +- `Schema/Contracts/` - The `ISchema` abstraction seam implemented by the models - `Schema/Models/Schema.cs` - Root container with CRUD operations for classes/enums +- `Schema/Models/SchemaChildSet.cs` - Order-preserving, name-unique view owning the uniqueness rule - `Schema/Models/Types/BaseType.cs` - Abstract base with `[JsonDerivedType]` attributes for polymorphic serialization - `Schema/Models/SchemaClass.cs` - Class definitions containing `SchemaMember` collections - `SchemaEditor/SchemaEditor.cs` - Main editor application using `ktsu.ImGui.App` diff --git a/Schema.Test/SchemaContractsTests.cs b/Schema.Test/SchemaContractsTests.cs new file mode 100644 index 0000000..7e4c142 --- /dev/null +++ b/Schema.Test/SchemaContractsTests.cs @@ -0,0 +1,285 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Schema.Tests; + +using System.Collections.ObjectModel; +using ktsu.Schema.Contracts; +using ktsu.Schema.Models; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SchemaTypes = Models.Types; + +/// +/// Covers the abstraction seam: that the models implement it, +/// that a schema can be built and read through the contracts alone, and that the covariance the +/// contracts rely on actually holds. +/// +[TestClass] +public class SchemaContractsTests +{ + private static readonly string[] SeedMembers = ["First", "Second", "Third"]; + private static readonly string[] AfterReorder = ["Third", "First", "Second"]; + private static readonly string[] AfterRemoveAndRestore = ["First", "Third", "Second"]; + + /// + /// A consumer holding only — what dependency injection hands it — can + /// define a schema without ever naming a model type. This is the scenario + /// docs/examples/dependency-injection.md describes. + /// + [TestMethod] + public void ISchemaAloneCanDefineASchema() + { + ISchema schema = new Schema(); + + ISchemaClass? user = schema.AddClass("User".As()); + Assert.IsNotNull(user, "A class can be added through the contract."); + + ISchemaMember? name = user.AddMember("Name".As()); + Assert.IsNotNull(name, "A member can be added through the contract."); + name.SetType(new SchemaTypes.String()); + + ISchemaEnum? role = schema.AddEnum("Role".As()); + Assert.IsNotNull(role, "An enum can be added through the contract."); + Assert.IsTrue(role.TryAddValue("Admin".As()), "An enum value can be added through the contract."); + + Assert.AreEqual(1, schema.Classes.Count); + Assert.AreEqual(1, schema.Enums.Count); + } + + /// + /// The whole model is reachable through the contracts: schema to class to member to type, and + /// back up through the parent references. + /// + [TestMethod] + public void ContractsNavigateTheModelInBothDirections() + { + ISchema schema = new Schema(); + ISchemaClass user = schema.AddClass("User".As())!; + ISchemaMember member = user.AddMember("Name".As())!; + member.SetType(new SchemaTypes.String()); + + ISchemaClass found = schema.Classes.GetByName("User".As())!; + Assert.AreSame(user, found, "Lookup by name returns the same element."); + + ISchemaMember foundMember = found.Members.GetByName("Name".As())!; + Assert.AreSame(member, foundMember); + Assert.AreEqual("String".As(), foundMember.Type.TypeName); + + Assert.AreSame(user, foundMember.ParentClass, "A member knows its class through the contract."); + Assert.AreSame(schema, foundMember.ParentSchema, "A member knows its schema through the contract."); + Assert.AreSame(foundMember, foundMember.Type.ParentMember, "A type knows its member through the contract."); + } + + /// + /// The contract's element type is the interface while the model's is the concrete class. The + /// covariance of is what lets one be the other, + /// and it is the same object rather than a copy. + /// + [TestMethod] + public void TheContractCollectionIsTheModelCollection() + { + Schema schema = new(); + SchemaClass user = schema.AddClass("User".As())!; + + ISchema contract = schema; + ISchemaChildSet classes = contract.Classes; + + Assert.AreEqual(1, classes.Count); + Assert.AreSame(user, classes.GetByName("User".As()), "The covariant view yields the model's own elements."); + + // A class added afterwards through the model is visible through a view taken before it. + schema.AddClass("Item".As()); + Assert.IsTrue(classes.ContainsByName("Item".As()), "The view reads the live collection rather than a snapshot."); + } + + /// + /// Adding through the contract enforces the same name uniqueness as adding through the model. + /// + [TestMethod] + public void AddingADuplicateNameThroughTheContractFails() + { + ISchema schema = new Schema(); + Assert.IsNotNull(schema.AddClass("User".As())); + Assert.IsNull(schema.AddClass("User".As()), "A second class of the same name is refused."); + + ISchemaClass user = schema.Classes.GetByName("User".As())!; + Assert.IsNotNull(user.AddMember("Name".As())); + Assert.IsNull(user.AddMember("Name".As()), "A second member of the same name is refused."); + } + + /// + /// Removing through the contract removes the element from the schema itself. + /// + [TestMethod] + public void RemovingThroughTheContractRemovesFromTheSchema() + { + Schema schema = new(); + schema.AddClass("User".As()); + + Assert.IsTrue(((ISchema)schema).RemoveClass("User".As())); + Assert.AreEqual(0, schema.Classes.Count, "The model no longer holds the class."); + Assert.IsFalse(((ISchema)schema).RemoveClass("User".As()), "Removing it again reports nothing was removed."); + } + + /// + /// A type from outside the model hierarchy cannot be stored: the polymorphic serializer knows + /// only and its declared subtypes, so accepting anything + /// else would produce a member the library could not write or read back. + /// + [TestMethod] + public void SettingATypeFromOutsideTheModelHierarchyIsRejected() + { + ISchema schema = new Schema(); + ISchemaMember member = schema.AddClass("User".As())!.AddMember("Name".As())!; + + Assert.ThrowsExactly(() => member.SetType(new ForeignType())); + } + + /// + /// Every type's is the discriminator written to the + /// file, so the two cannot drift apart. + /// + [TestMethod] + public void TypeNameMatchesTheSerializedDiscriminator() + { + SchemaTypes.BaseType[] types = + [ + new SchemaTypes.None(), new SchemaTypes.Int(), new SchemaTypes.Long(), + new SchemaTypes.Float(), new SchemaTypes.Double(), new SchemaTypes.String(), + new SchemaTypes.Bool(), new SchemaTypes.DateTime(), new SchemaTypes.TimeSpan(), + new SchemaTypes.Enum(), new SchemaTypes.Array(), new SchemaTypes.Object(), + new SchemaTypes.Vector2(), new SchemaTypes.Vector3(), new SchemaTypes.Vector4(), + new SchemaTypes.ColorRGB(), new SchemaTypes.ColorRGBA(), + ]; + + foreach (SchemaTypes.BaseType type in types) + { + Schema schema = new(); + SchemaClass schemaClass = schema.AddClass("Holder".As())!; + schemaClass.AddMember("Value".As())!.SetType(type); + + string json = SchemaSerializer.Serialize(schema); + Assert.IsTrue( + json.Contains($"\"TypeName\": \"{type.TypeName}\"", StringComparison.Ordinal), + $"{type.GetType().Name} reports a TypeName matching what is written to the file."); + } + } + + /// + /// The set preserves the order elements were added in, which is what makes member order part of + /// the schema's meaning rather than an accident of storage. + /// + [TestMethod] + public void TheSetPreservesInsertionOrder() + { + SchemaChildSet members = CreateMemberSet(); + CollectionAssert.AreEqual(SeedMembers, members.Select(m => m.Name.ToString()).ToArray()); + } + + /// + /// A remove followed by an add — what undoing a deletion does — must not disturb the order of + /// the elements that stayed. A name-keyed hash set would give no such guarantee. + /// + [TestMethod] + public void RemovingAndRestoringLeavesTheOtherElementsInOrder() + { + SchemaChildSet members = CreateMemberSet(); + SchemaMember second = members.GetByName("Second".As())!; + + Assert.IsTrue(members.Remove(second)); + Assert.IsTrue(members.Add(second), "The element can be restored."); + + CollectionAssert.AreEqual( + AfterRemoveAndRestore, + members.Select(m => m.Name.ToString()).ToArray(), + "The surviving elements keep their relative order; the restored one goes to the end."); + } + + /// + /// The set owns the name-uniqueness rule that each call site would otherwise re-implement. + /// + [TestMethod] + public void TheSetRefusesADuplicateName() + { + SchemaChildSet members = CreateMemberSet(); + + SchemaMember duplicate = new(); + duplicate.Rename("First".As()); + + Assert.IsFalse(members.Add(duplicate), "A different element with a name already present is refused."); + Assert.AreEqual(SeedMembers.Length, members.Count); + } + + /// + /// Moving is bounds-checked, and an out-of-range move changes nothing. + /// + [TestMethod] + public void MovingReordersAndRejectsAnOutOfRangeIndex() + { + SchemaChildSet members = CreateMemberSet(); + SchemaMember third = members.GetByName("Third".As())!; + + Assert.IsTrue(members.Move(third, 0)); + CollectionAssert.AreEqual(AfterReorder, members.Select(m => m.Name.ToString()).ToArray()); + + Assert.IsFalse(members.Move(third, members.Count), "An index past the end is refused."); + Assert.IsFalse(members.Move(third, -1), "A negative index is refused."); + CollectionAssert.AreEqual(AfterReorder, members.Select(m => m.Name.ToString()).ToArray(), "A refused move changes nothing."); + + SchemaMember stranger = new(); + stranger.Rename("Stranger".As()); + Assert.IsFalse(members.Move(stranger, 0), "An element not in the set cannot be moved."); + } + + /// + /// Uniqueness is enforced on the way in, not on the way through. A hand-edited file containing + /// duplicate names still loads with both elements present, so can + /// report it. Dropping one silently at load would turn a diagnosable mistake into data loss. + /// + [TestMethod] + public void DuplicateNamesInAFileStillLoadAndAreReported() + { + string json = """ + { + "formatVersion": 1, + "classes": [ + { "name": "User", "members": [] }, + { "name": "User", "members": [] } + ] + } + """; + + Assert.IsTrue(SchemaSerializer.TryDeserialize(json, out Schema? schema)); + Assert.IsNotNull(schema); + Assert.AreEqual(2, schema.Classes.Count, "Both classes are loaded rather than one being dropped."); + + Collection issues = schema.Validate(); + Assert.IsTrue( + issues.Any(i => i.Message.Contains("Duplicate class name 'User'", StringComparison.Ordinal)), + "The duplicate is reported as a validation issue."); + } + + private static SchemaChildSet CreateMemberSet() + { + Schema schema = new(); + SchemaClass schemaClass = schema.AddClass("User".As())!; + foreach (string name in SeedMembers) + { + schemaClass.AddMember(name.As())?.SetType(new SchemaTypes.Int()); + } + + return schemaClass.Members; + } + + /// + /// An implemented outside the model hierarchy, used to check that it + /// is refused rather than stored. + /// + private sealed class ForeignType : ISchemaType + { + public BaseTypeName TypeName => "Foreign".As(); + + public ISchemaMember? ParentMember => null; + } +} diff --git a/Schema.Test/SchemaRenameTests.cs b/Schema.Test/SchemaRenameTests.cs index 78103eb..cff2c19 100644 --- a/Schema.Test/SchemaRenameTests.cs +++ b/Schema.Test/SchemaRenameTests.cs @@ -203,7 +203,7 @@ public void TestRenameEnumValue() status.TryAddValue("Inactive".As()); Assert.IsTrue(status.TryRenameValue("Active".As(), "Enabled".As())); - Assert.AreEqual("Enabled", status.Values.First().ToString(), "The renamed value keeps its position."); + Assert.AreEqual("Enabled", status.Values[0].ToString(), "The renamed value keeps its position."); Assert.IsFalse(status.TryRenameValue("Enabled".As(), "Inactive".As()), "Collides."); Assert.IsFalse(status.TryRenameValue("Enabled".As(), string.Empty.As()), "Empty."); diff --git a/Schema.Test/SchemaSerializerTests.cs b/Schema.Test/SchemaSerializerTests.cs index c439521..6a2d357 100644 --- a/Schema.Test/SchemaSerializerTests.cs +++ b/Schema.Test/SchemaSerializerTests.cs @@ -260,7 +260,7 @@ public void TestRoundtripReassociatesParents() // Verify parent references were re-established SchemaClass? deserializedClass = deserialized.Classes.First(); Assert.AreEqual(deserialized, deserializedClass.ParentSchema); - SchemaMember deserializedMember = deserializedClass.Members.First(); + SchemaMember deserializedMember = deserializedClass.Members[0]; Assert.AreEqual(deserializedClass, deserializedMember.ParentClass); } } diff --git a/Schema/Contracts/ISchema.cs b/Schema/Contracts/ISchema.cs index f3aeceb..e195d63 100644 --- a/Schema/Contracts/ISchema.cs +++ b/Schema/Contracts/ISchema.cs @@ -2,21 +2,53 @@ namespace ktsu.Schema.Contracts; -using ktsu.Schema.Contracts.Names; +using ktsu.Schema.Models.Names; /// /// Defines a provider for schema definitions that can be injected as a dependency. /// This interface focuses solely on schema definition and management without serialization or filesystem concerns. /// +/// +/// Mutation lives here rather than on the collections so that adding an element can enforce name +/// uniqueness and establish the parent association the element needs to resolve its own references. +/// public interface ISchema { /// - /// Gets the collection of schema classes. + /// Gets the collection of schema classes, in declaration order. /// - public ISchemaChildSet Classes { get; } + public ISchemaChildSet Classes { get; } /// - /// Gets the collection of schema enums. + /// Gets the collection of schema enums, in declaration order. /// - public ISchemaChildSet Enums { get; } + public ISchemaChildSet Enums { get; } + + /// + /// Adds a class to the schema. + /// + /// The name of the class to add. + /// The added class, or if the name is already taken. + public ISchemaClass? AddClass(ClassName name); + + /// + /// Adds an enum to the schema. + /// + /// The name of the enum to add. + /// The added enum, or if the name is already taken. + public ISchemaEnum? AddEnum(EnumName name); + + /// + /// Removes a class from the schema. + /// + /// The name of the class to remove. + /// if a class with that name was found and removed; otherwise, . + public bool RemoveClass(ClassName name); + + /// + /// Removes an enum from the schema. + /// + /// The name of the enum to remove. + /// if an enum with that name was found and removed; otherwise, . + public bool RemoveEnum(EnumName name); } diff --git a/Schema/Contracts/ISchemaChild.cs b/Schema/Contracts/ISchemaChild.cs index 131984b..33a48bc 100644 --- a/Schema/Contracts/ISchemaChild.cs +++ b/Schema/Contracts/ISchemaChild.cs @@ -3,6 +3,7 @@ namespace ktsu.Schema.Contracts; using ktsu.Schema.Contracts.Names; +using ktsu.Schema.Models; /// /// Defines a child element of a schema with a specific name type. @@ -11,23 +12,18 @@ namespace ktsu.Schema.Contracts; public interface ISchemaChild where TName : ISchemaChildName { /// - /// Gets the name of the schema child. + /// Gets or sets the name of the schema child. /// public TName Name { get; set; } /// /// Gets or sets the description of the schema child. /// - public ISchemaChildDescription Description { get; set; } + public SchemaChildDescription Description { get; set; } /// /// Gets the parent schema that owns this child element. /// All schema children maintain a reference to their root schema. /// public ISchema? ParentSchema { get; } - - /// - /// Gets or sets the summary of this child. - /// - public ISchemaChildSummary Summary { get; set; } } diff --git a/Schema/Contracts/ISchemaChildDescription.cs b/Schema/Contracts/ISchemaChildDescription.cs deleted file mode 100644 index 03761a8..0000000 --- a/Schema/Contracts/ISchemaChildDescription.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts; - -using ktsu.Semantics.Strings; - -/// -/// Represents a description for a schema child. -/// -public interface ISchemaChildDescription : ISemanticString -{ -} diff --git a/Schema/Contracts/ISchemaChildSet.cs b/Schema/Contracts/ISchemaChildSet.cs index 119fd2f..33fc51b 100644 --- a/Schema/Contracts/ISchemaChildSet.cs +++ b/Schema/Contracts/ISchemaChildSet.cs @@ -2,41 +2,44 @@ namespace ktsu.Schema.Contracts; -using ktsu.Schema.Contracts.Names; - /// -/// Defines a set container for schema child elements with name-based uniqueness. +/// Defines a name-indexed, order-preserving view over a set of schema child elements. /// -/// The type of schema child elements, must implement ISchemaChild. -/// The type of the name used for comparison. -public interface ISchemaChildSet : ISet - where TValue : class, ISchemaChild - where TName : ISchemaChildName +/// +/// +/// is covariant so that a set of concrete elements can be consumed +/// through the contracts: a SchemaChildSet<SchemaClass, ClassName> is an +/// ISchemaChildSet<ISchemaClass, ClassName>. That rules out the mutating members of +/// Add would put in an input position +/// and make the interface invariant — so mutation lives on the owning element instead, where it can +/// also establish parent association. For the same reason the lookup returns its result rather than +/// using an parameter, which C# treats as an invariant position. +/// +/// +/// is invariant, so implementations and consumers name the same +/// concrete name type. Name types are values rather than entities: abstracting a semantic string +/// behind a further interface buys nothing and is what makes the variance unworkable. +/// +/// +/// Enumeration order is the order elements were added, and is preserved through serialization. +/// +/// +/// The type of the schema child elements. +/// The type of the name used to look elements up. +public interface ISchemaChildSet : IReadOnlyCollection + where TValue : class { /// - /// Gets the comparer used for name-based uniqueness comparison. - /// - public IEqualityComparer NameComparer { get; } - - /// - /// Tries to get an element by its name. + /// Gets the element with the specified name. /// /// The name of the element to find. - /// The found element, if any. - /// True if an element with the specified name was found, false otherwise. - public bool TryGetByName(TName name, out TValue? element); + /// The element with that name, or if the set contains no such element. + public TValue? GetByName(TName name); /// /// Determines whether the set contains an element with the specified name. /// /// The name to check for. - /// True if an element with the specified name exists in the set, false otherwise. + /// if an element with that name exists in the set; otherwise, . public bool ContainsByName(TName name); - - /// - /// Removes an element by its name. - /// - /// The name of the element to remove. - /// True if an element with the specified name was found and removed, false otherwise. - public bool RemoveByName(TName name); } diff --git a/Schema/Contracts/ISchemaChildSummary.cs b/Schema/Contracts/ISchemaChildSummary.cs deleted file mode 100644 index 282955e..0000000 --- a/Schema/Contracts/ISchemaChildSummary.cs +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts; - -using ktsu.Semantics.Strings; - -/// -/// Represents a summary of a schema child element. -/// -public interface ISchemaChildSummary : ISemanticString -{ -} diff --git a/Schema/Contracts/ISchemaClass.cs b/Schema/Contracts/ISchemaClass.cs index e002a96..f2b7060 100644 --- a/Schema/Contracts/ISchemaClass.cs +++ b/Schema/Contracts/ISchemaClass.cs @@ -2,15 +2,33 @@ namespace ktsu.Schema.Contracts; -using ktsu.Schema.Contracts.Names; +using ktsu.Schema.Models.Names; /// /// Defines a class within a schema. /// -public interface ISchemaClass : ISchemaChild +public interface ISchemaClass : ISchemaChild { /// - /// Gets the members of the schema class. + /// Gets the members of the schema class, in declaration order. /// - public ISchemaChildSet Members { get; } + /// + /// Member order is the declaration order generated code will use, so it is part of the + /// schema's meaning rather than a display concern. + /// + public ISchemaChildSet Members { get; } + + /// + /// Adds a member to the schema class. + /// + /// The name of the member to add. + /// The added member, or if the name is already taken. + public ISchemaMember? AddMember(MemberName name); + + /// + /// Removes a member from the schema class. + /// + /// The name of the member to remove. + /// if a member with that name was found and removed; otherwise, . + public bool RemoveMember(MemberName name); } diff --git a/Schema/Contracts/ISchemaEnum.cs b/Schema/Contracts/ISchemaEnum.cs index 20313d4..989a7ae 100644 --- a/Schema/Contracts/ISchemaEnum.cs +++ b/Schema/Contracts/ISchemaEnum.cs @@ -2,16 +2,34 @@ namespace ktsu.Schema.Contracts; -using ktsu.Schema.Contracts.Names; +using ktsu.Schema.Models.Names; /// /// Defines an enumeration in a schema. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "It's representing a custom enumeration")] -public interface ISchemaEnum : ISchemaChild +public interface ISchemaEnum : ISchemaChild { /// - /// Gets the values of the enumeration. + /// Gets the values of the enumeration, in declaration order. /// - public ISchemaChildSet Values { get; } -} \ No newline at end of file + /// + /// An enum value is a name and nothing else — the format stores the values as a list of + /// strings — so they are exposed as names rather than as child elements. + /// + public IReadOnlyList Values { get; } + + /// + /// Adds a value to the enumeration. + /// + /// The value to add. + /// if the value was added; if it is already present. + public bool TryAddValue(EnumValueName name); + + /// + /// Removes a value from the enumeration. + /// + /// The value to remove. + /// if the value was found and removed; otherwise, . + public bool TryRemoveValue(EnumValueName name); +} diff --git a/Schema/Contracts/ISchemaEnumValue.cs b/Schema/Contracts/ISchemaEnumValue.cs deleted file mode 100644 index a725575..0000000 --- a/Schema/Contracts/ISchemaEnumValue.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts; - -using ktsu.Schema.Contracts.Names; - -/// -/// Defines a value within an enumeration. -/// This interface maintains two parent references: -/// - ParentSchema (inherited): References the root schema that owns this enum value -/// - ParentEnum: References the immediate parent enumeration that contains this value -/// -public interface ISchemaEnumValue : ISchemaChild -{ - /// - /// Gets the parent enum that contains this enumeration value. - /// - public ISchemaEnum? ParentEnum { get; } -} diff --git a/Schema/Contracts/ISchemaMember.cs b/Schema/Contracts/ISchemaMember.cs index 91c7ebb..0365b60 100644 --- a/Schema/Contracts/ISchemaMember.cs +++ b/Schema/Contracts/ISchemaMember.cs @@ -2,15 +2,26 @@ namespace ktsu.Schema.Contracts; -using ktsu.Schema.Contracts.Names; +using ktsu.Schema.Models.Names; /// /// Defines a member of a schema class. /// -public interface ISchemaMember : ISchemaClassChild +public interface ISchemaMember : ISchemaClassChild { /// /// Gets the type of the schema member. /// - public ISchemaType Type { get; set; } + public ISchemaType Type { get; } + + /// + /// Sets the type of the schema member. + /// + /// + /// A method rather than a settable property because setting a type also associates it with + /// this member, which is what gives the type a route back to the schema. A plain setter + /// invites assigning a type that resolves none of its own references. + /// + /// The type to set. + public void SetType(ISchemaType type); } diff --git a/Schema/Contracts/ISchemaMemberChild.cs b/Schema/Contracts/ISchemaMemberChild.cs deleted file mode 100644 index 6645df5..0000000 --- a/Schema/Contracts/ISchemaMemberChild.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts; - -using ktsu.Schema.Contracts.Names; - -/// -/// Defines a child element of a schema member. -/// -/// The type of the name. -public interface ISchemaMemberChild : ISchemaChild where TName : ISchemaMemberChildName -{ - /// - /// Gets the parent member of the schema member child. - /// - public ISchemaMember? ParentMember { get; } -} diff --git a/Schema/Contracts/ISchemaType.cs b/Schema/Contracts/ISchemaType.cs index 7994983..7426138 100644 --- a/Schema/Contracts/ISchemaType.cs +++ b/Schema/Contracts/ISchemaType.cs @@ -2,11 +2,29 @@ namespace ktsu.Schema.Contracts; -using ktsu.Schema.Contracts.Names; +using ktsu.Schema.Models.Names; /// /// Represents a schema type that can be part of a schema member. /// -public interface ISchemaType : ISchemaMemberChild +/// +/// A type is not a named child of the schema the way a class or member is: it has no name or +/// description of its own, and exists only as the type of the member that holds it. It is +/// identified by which type it is, which is what reports. +/// +public interface ISchemaType { + /// + /// Gets the name identifying which type this is. + /// + /// + /// This is the discriminator written to and read from the schema file's TypeName + /// property, so it is stable across versions in the way the file format is. + /// + public BaseTypeName TypeName { get; } + + /// + /// Gets the member this type belongs to, if it has been associated with one. + /// + public ISchemaMember? ParentMember { get; } } diff --git a/Schema/Contracts/Names/ISchemaClassName.cs b/Schema/Contracts/Names/ISchemaClassName.cs deleted file mode 100644 index 21d2991..0000000 --- a/Schema/Contracts/Names/ISchemaClassName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Represents a schema class name interface that extends the root name functionality. -/// -public interface ISchemaClassName : ISchemaRootName -{ -} diff --git a/Schema/Contracts/Names/ISchemaCodeGeneratorName.cs b/Schema/Contracts/Names/ISchemaCodeGeneratorName.cs deleted file mode 100644 index fab7e9d..0000000 --- a/Schema/Contracts/Names/ISchemaCodeGeneratorName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Represents a schema code generator name interface. -/// -public interface ISchemaCodeGeneratorName : ISchemaRootName -{ -} diff --git a/Schema/Contracts/Names/ISchemaDataSourceName.cs b/Schema/Contracts/Names/ISchemaDataSourceName.cs deleted file mode 100644 index e9b5a9c..0000000 --- a/Schema/Contracts/Names/ISchemaDataSourceName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Represents a schema data source name interface. -/// -public interface ISchemaDataSourceName : ISchemaRootName -{ -} diff --git a/Schema/Contracts/Names/ISchemaEnumName.cs b/Schema/Contracts/Names/ISchemaEnumName.cs deleted file mode 100644 index 551ee2d..0000000 --- a/Schema/Contracts/Names/ISchemaEnumName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Represents a schema enum name interface. -/// -public interface ISchemaEnumName : ISchemaRootName -{ -} diff --git a/Schema/Contracts/Names/ISchemaEnumValueName.cs b/Schema/Contracts/Names/ISchemaEnumValueName.cs deleted file mode 100644 index 17da51a..0000000 --- a/Schema/Contracts/Names/ISchemaEnumValueName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Interface for names that represent enumeration values. -/// -public interface ISchemaEnumValueName : ISchemaChildName -{ -} diff --git a/Schema/Contracts/Names/ISchemaMemberChildName.cs b/Schema/Contracts/Names/ISchemaMemberChildName.cs deleted file mode 100644 index 973db4a..0000000 --- a/Schema/Contracts/Names/ISchemaMemberChildName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Defines a name for a schema member child. -/// -public interface ISchemaMemberChildName : ISchemaChildName -{ -} diff --git a/Schema/Contracts/Names/ISchemaMemberName.cs b/Schema/Contracts/Names/ISchemaMemberName.cs deleted file mode 100644 index cea19e9..0000000 --- a/Schema/Contracts/Names/ISchemaMemberName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Represents a schema member name interface. -/// -public interface ISchemaMemberName : ISchemaClassChildName -{ -} diff --git a/Schema/Contracts/Names/ISchemaTypeName.cs b/Schema/Contracts/Names/ISchemaTypeName.cs deleted file mode 100644 index 0eea321..0000000 --- a/Schema/Contracts/Names/ISchemaTypeName.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Contracts.Names; - -/// -/// Interface for names that represent schema types. -/// -public interface ISchemaTypeName : ISchemaMemberChildName -{ -} diff --git a/Schema/Models/Names/BaseTypeName.cs b/Schema/Models/Names/BaseTypeName.cs index 15b58bb..93aa309 100644 --- a/Schema/Models/Names/BaseTypeName.cs +++ b/Schema/Models/Names/BaseTypeName.cs @@ -8,4 +8,4 @@ namespace ktsu.Schema.Models.Names; /// /// Represents a base type name as a strong string type. /// -public sealed record class BaseTypeName : SemanticString, ISchemaTypeName, ISchemaClassChildName { } +public sealed record class BaseTypeName : SemanticString, ISchemaName { } diff --git a/Schema/Models/Names/ClassName.cs b/Schema/Models/Names/ClassName.cs index 4842fb3..82f2cd0 100644 --- a/Schema/Models/Names/ClassName.cs +++ b/Schema/Models/Names/ClassName.cs @@ -8,4 +8,4 @@ namespace ktsu.Schema.Models.Names; /// /// Represents a class name as a strong string type. /// -public sealed record class ClassName : SemanticString, ISchemaClassName { } +public sealed record class ClassName : SemanticString, ISchemaRootName { } diff --git a/Schema/Models/Names/CodeGeneratorName.cs b/Schema/Models/Names/CodeGeneratorName.cs index 87a14a3..2037206 100644 --- a/Schema/Models/Names/CodeGeneratorName.cs +++ b/Schema/Models/Names/CodeGeneratorName.cs @@ -8,4 +8,4 @@ namespace ktsu.Schema.Models.Names; /// /// Represents a code generator name as a strong string type. /// -public sealed record class CodeGeneratorName : SemanticString, ISchemaCodeGeneratorName { } +public sealed record class CodeGeneratorName : SemanticString, ISchemaRootName { } diff --git a/Schema/Models/Names/ContainerName.cs b/Schema/Models/Names/ContainerName.cs index bfcc224..19e74cc 100644 --- a/Schema/Models/Names/ContainerName.cs +++ b/Schema/Models/Names/ContainerName.cs @@ -8,7 +8,7 @@ namespace ktsu.Schema.Models.Names; /// /// Represents a container name as a strong string type. /// -public sealed record class ContainerName : SemanticString, ISchemaTypeName +public sealed record class ContainerName : SemanticString, ISchemaName { /// /// The container name for an ordered sequence, mapped to a list by a code generator. diff --git a/Schema/Models/Names/DataSourceName.cs b/Schema/Models/Names/DataSourceName.cs index 77d8fff..f0b1601 100644 --- a/Schema/Models/Names/DataSourceName.cs +++ b/Schema/Models/Names/DataSourceName.cs @@ -8,4 +8,4 @@ namespace ktsu.Schema.Models.Names; /// /// Represents a data source name as a strong string type. /// -public sealed record class DataSourceName : SemanticString, ISchemaDataSourceName { } +public sealed record class DataSourceName : SemanticString, ISchemaRootName { } diff --git a/Schema/Models/Names/EnumName.cs b/Schema/Models/Names/EnumName.cs index 259e53f..c191dff 100644 --- a/Schema/Models/Names/EnumName.cs +++ b/Schema/Models/Names/EnumName.cs @@ -8,4 +8,4 @@ namespace ktsu.Schema.Models.Names; /// /// Represents an enum name as a strong string type. /// -public sealed record class EnumName : SemanticString, ISchemaEnumName { } +public sealed record class EnumName : SemanticString, ISchemaRootName { } diff --git a/Schema/Models/Names/EnumValueName.cs b/Schema/Models/Names/EnumValueName.cs index 85775f8..178848d 100644 --- a/Schema/Models/Names/EnumValueName.cs +++ b/Schema/Models/Names/EnumValueName.cs @@ -8,4 +8,4 @@ namespace ktsu.Schema.Models.Names; /// /// Represents an enum value name as a strong string type. /// -public sealed record class EnumValueName : SemanticString, ISchemaEnumValueName { } +public sealed record class EnumValueName : SemanticString, ISchemaChildName { } diff --git a/Schema/Models/Names/MemberName.cs b/Schema/Models/Names/MemberName.cs index 9c76df6..bb6d569 100644 --- a/Schema/Models/Names/MemberName.cs +++ b/Schema/Models/Names/MemberName.cs @@ -8,4 +8,4 @@ namespace ktsu.Schema.Models.Names; /// /// Represents a member name as a strong string type. /// -public sealed record class MemberName : SemanticString, ISchemaMemberName { } +public sealed record class MemberName : SemanticString, ISchemaClassChildName { } diff --git a/Schema/Models/Schema.cs b/Schema/Models/Schema.cs index 9ed9557..8756f87 100644 --- a/Schema/Models/Schema.cs +++ b/Schema/Models/Schema.cs @@ -5,6 +5,7 @@ namespace ktsu.Schema.Models; using System.Collections.ObjectModel; using System.Reflection; using System.Text.Json.Serialization; +using ktsu.Schema.Contracts; using ktsu.Schema.Contracts.Names; using ktsu.Schema.Models.Names; using ktsu.Schema.Models.Types; @@ -15,7 +16,7 @@ namespace ktsu.Schema.Models; /// Provides schema definitions and management functionality. /// This class focuses solely on schema definition without serialization or filesystem concerns. /// -public partial class Schema +public partial class Schema : ISchema { /// /// The format version this build of the library writes. @@ -99,6 +100,50 @@ public partial class Schema [JsonIgnore] public IReadOnlyCollection DataSources => DataSourcesInternal; + /// + /// Gets the schema's classes as a name-indexed, order-preserving set. + /// + [JsonIgnore] + public SchemaChildSet ClassSet => new(ClassesInternal); + + /// + /// Gets the schema's enums as a name-indexed, order-preserving set. + /// + [JsonIgnore] + public SchemaChildSet EnumSet => new(EnumsInternal); + + /// + /// Explicit because the contract's element type is ; the covariance + /// of makes it the same object. + /// + ISchemaChildSet ISchema.Classes => ClassSet; + + /// + /// Explicit because the contract's element type is ; the covariance + /// of makes it the same object. + /// + ISchemaChildSet ISchema.Enums => EnumSet; + + /// + ISchemaClass? ISchema.AddClass(ClassName name) => AddClass(name); + + /// + ISchemaEnum? ISchema.AddEnum(EnumName name) => AddEnum(name); + + /// + /// Removes the class with the specified name. + /// + /// The name of the class to remove. + /// True if a class with that name was found and removed; otherwise, false. + public bool RemoveClass(ClassName name) => GetClass(name) is SchemaClass schemaClass && TryRemoveClass(schemaClass); + + /// + /// Removes the enum with the specified name. + /// + /// The name of the enum to remove. + /// True if an enum with that name was found and removed; otherwise, false. + public bool RemoveEnum(EnumName name) => GetEnum(name) is SchemaEnum schemaEnum && TryRemoveEnum(schemaEnum); + /// /// Initializes a new instance of the Schema class. /// @@ -240,16 +285,10 @@ public static bool TryGetChild(TName name, Collection col Ensure.NotNull(name); Ensure.NotNull(collection); - if (GetChild(name, collection) is null) - { - TChild child = new(); - child.Rename(name); - child.AssociateWith(this); - collection.Add(child); - return child; - } - - return null; + TChild child = new(); + child.Rename(name); + child.AssociateWith(this); + return new SchemaChildSet(collection).Add(child) ? child : null; } /// @@ -268,14 +307,8 @@ public bool RestoreChild(TChild child, Collection collect Ensure.NotNull(child); Ensure.NotNull(collection); - if (GetChild(child.Name, collection) is not null) - { - return false; - } - child.AssociateWith(this); - collection.Add(child); - return true; + return new SchemaChildSet(collection).Add(child); } /// diff --git a/Schema/Models/SchemaChild.cs b/Schema/Models/SchemaChild.cs index 7499158..a183c8a 100644 --- a/Schema/Models/SchemaChild.cs +++ b/Schema/Models/SchemaChild.cs @@ -3,6 +3,7 @@ namespace ktsu.Schema.Models; using System.Text.Json.Serialization; +using ktsu.Schema.Contracts; using ktsu.Schema.Contracts.Names; using ktsu.Semantics.Strings; @@ -10,7 +11,7 @@ namespace ktsu.Schema.Models; /// Represents a child of a schema with a specific name type. /// /// The type of the name. -public abstract class SchemaChild : ISchemaElement where TName : SemanticString, ISchemaChildName, new() +public abstract class SchemaChild : ISchemaElement, ISchemaChild where TName : SemanticString, ISchemaChildName, new() { /// /// Gets the name of the schema child. @@ -32,6 +33,12 @@ namespace ktsu.Schema.Models; [JsonIgnore] public Schema? ParentSchema { get; private set; } + /// + /// Explicit because the contract exposes the parent as while the model + /// exposes the concrete ; both are the same object. + /// + ISchema? ISchemaChild.ParentSchema => ParentSchema; + /// /// Returns the name of the schema child as a string. /// diff --git a/Schema/Models/SchemaChildDescription.cs b/Schema/Models/SchemaChildDescription.cs index a9bd2ee..86cf978 100644 --- a/Schema/Models/SchemaChildDescription.cs +++ b/Schema/Models/SchemaChildDescription.cs @@ -2,12 +2,11 @@ namespace ktsu.Schema.Models; -using ktsu.Schema.Contracts; using ktsu.Semantics.Strings; /// /// Represents a description for a schema child. /// -public sealed record class SchemaChildDescription : SemanticString, ISchemaChildDescription +public sealed record class SchemaChildDescription : SemanticString { } diff --git a/Schema/Models/SchemaChildNameComparer.cs b/Schema/Models/SchemaChildNameComparer.cs deleted file mode 100644 index a79d97a..0000000 --- a/Schema/Models/SchemaChildNameComparer.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Models; - -using ktsu.Schema.Contracts; -using ktsu.Schema.Contracts.Names; -using ktsu.Semantics.Strings; - -/// -/// Equality comparer for schema child elements based on their name property. -/// -/// The type of schema child elements. -/// The type of the name used for comparison. -internal class SchemaChildNameComparer : IEqualityComparer - where T : class, ISchemaChild - where TName : SemanticString, ISchemaChildName, new() -{ - private readonly EqualityComparer nameComparer = EqualityComparer.Default; - - /// - public bool Equals(T? x, T? y) - { - if (ReferenceEquals(x, y)) - { - return true; - } - - if (x is null || y is null) - { - return false; - } - - return nameComparer.Equals(x.Name, y.Name); - } - - /// - public int GetHashCode(T obj) - { - Ensure.NotNull(obj, nameof(obj)); - return nameComparer.GetHashCode(obj.Name); - } -} diff --git a/Schema/Models/SchemaChildSet.cs b/Schema/Models/SchemaChildSet.cs index 102142a..0a6c246 100644 --- a/Schema/Models/SchemaChildSet.cs +++ b/Schema/Models/SchemaChildSet.cs @@ -3,191 +3,144 @@ namespace ktsu.Schema.Models; using System.Collections; -using System.Diagnostics; +using System.Collections.ObjectModel; using ktsu.Schema.Contracts; using ktsu.Schema.Contracts.Names; using ktsu.Semantics.Strings; /// -/// Implementation of a set container for schema child elements with name-based uniqueness. +/// An order-preserving, name-unique view over a collection of schema child elements. /// -/// The type of schema child elements, must implement ISchemaChild. -/// The type of the name used for comparison. -public class SchemaChildSet : ISchemaChildSet +/// +/// +/// This is a view rather than a store: it wraps the collection the owning element serializes, so +/// there is no second copy to diverge from it and no change to the on-disk format. It owns the +/// name-uniqueness rule that would otherwise be re-implemented as an Any(x => x.Name == name) +/// check at each call site. +/// +/// +/// Order is the order elements were added, and is preserved through serialization. A name-keyed +/// hash set would not preserve it: makes no ordering guarantee, and reuses +/// freed slots after a removal, so a remove-then-add — what undoing a deletion does — could reorder +/// a class's members. +/// +/// +/// 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 elements present and is reported by . Silently dropping +/// one at load would turn a diagnosable mistake into data loss. +/// +/// +/// The type of schema child elements. +/// The type of the name used for uniqueness and lookup. +public sealed class SchemaChildSet : ISchemaChildSet, IReadOnlyList where T : class, ISchemaChild where TName : SemanticString, ISchemaChildName, new() { - private readonly HashSet innerSet; - private readonly SchemaChildNameComparer nameComparer; + private readonly Collection items; /// - /// Initializes a new instance of the SchemaChildSet class. + /// Initializes a new instance of the class over the specified collection. /// - public SchemaChildSet() + /// The collection to present. The set reads and writes it in place; it does not copy. + public SchemaChildSet(Collection items) { - nameComparer = new SchemaChildNameComparer(); - innerSet = new HashSet(nameComparer); + Ensure.NotNull(items); + this.items = items; } - /// - /// Initializes a new instance of the SchemaChildSet class with the specified capacity. - /// - /// The initial capacity of the set. - public SchemaChildSet(int capacity) - { - nameComparer = new SchemaChildNameComparer(); - innerSet = new HashSet(capacity, nameComparer); - } + /// + public int Count => items.Count; /// - /// Initializes a new instance of the SchemaChildSet class with the specified collection. + /// Gets the element at the specified position in declaration order. /// - /// The collection to initialize the set with. - public SchemaChildSet(IEnumerable collection) - { - nameComparer = new SchemaChildNameComparer(); - innerSet = new HashSet(collection, nameComparer); - } - - /// - public IEqualityComparer NameComparer => EqualityComparer.Default; - - /// - public int Count => innerSet.Count; + /// The zero-based position. + /// The element at that position. + public T this[int index] => items[index]; /// - public bool IsReadOnly => false; - - /// - public bool Add(T item) - { - Ensure.NotNull(item, nameof(item)); - return innerSet.Add(item); - } - - /// - void ICollection.Add(T item) => Add(item); - - /// - public void Clear() => innerSet.Clear(); - - /// - public bool Contains(T item) - { - Ensure.NotNull(item, nameof(item)); - return innerSet.Contains(item); - } + public T? GetByName(TName name) => items.FirstOrDefault(item => item.Name == name); /// - public void CopyTo(T[] array, int arrayIndex) => innerSet.CopyTo(array, arrayIndex); + public bool ContainsByName(TName name) => GetByName(name) is not null; - /// - public void ExceptWith(IEnumerable other) + /// + /// Tries to get an element by its name. + /// + /// The name of the element to find. + /// The found element, if any. + /// if an element with that name was found; otherwise, . + public bool TryGetByName(TName name, out T? element) { - Ensure.NotNull(other, nameof(other)); - innerSet.ExceptWith(other); + element = GetByName(name); + return element is not null; } - /// - public IEnumerator GetEnumerator() => innerSet.GetEnumerator(); - - /// - public void IntersectWith(IEnumerable other) + /// + /// Adds an element, unless its name is already taken. + /// + /// The element to add. + /// if the element was added; if an element with the same name is already present. + public bool Add(T element) { - Ensure.NotNull(other, nameof(other)); - innerSet.IntersectWith(other); - } + Ensure.NotNull(element); - /// - public bool IsProperSubsetOf(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - return innerSet.IsProperSubsetOf(other); - } + if (ContainsByName(element.Name)) + { + return false; + } - /// - public bool IsProperSupersetOf(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - return innerSet.IsProperSupersetOf(other); + items.Add(element); + return true; } - /// - public bool IsSubsetOf(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - return innerSet.IsSubsetOf(other); - } + /// + /// Removes the specified element. + /// + /// The element to remove. + /// if the element was found and removed; otherwise, . + public bool Remove(T element) => items.Remove(element); - /// - public bool IsSupersetOf(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - return innerSet.IsSupersetOf(other); - } + /// + /// Removes the element with the specified name. + /// + /// The name of the element to remove. + /// if an element with that name was found and removed; otherwise, . + public bool RemoveByName(TName name) => GetByName(name) is T element && items.Remove(element); - /// - public bool Overlaps(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - return innerSet.Overlaps(other); - } + /// + /// Gets the position of an element in declaration order. + /// + /// The element to locate. + /// The element's index, or -1 if it is not in the set. + public int IndexOf(T element) => items.IndexOf(element); - /// - public bool Remove(T item) + /// + /// Moves an element to a new position in declaration order. + /// + /// The element to move. + /// The zero-based position to move it to. + /// if the element was moved; if it is not in the set or the index is out of range. + public bool Move(T element, int newIndex) { - Ensure.NotNull(item, nameof(item)); - return innerSet.Remove(item); - } + int currentIndex = IndexOf(element); + if (currentIndex < 0 || newIndex < 0 || newIndex >= items.Count) + { + return false; + } - /// - public bool SetEquals(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - return innerSet.SetEquals(other); - } + if (newIndex != currentIndex) + { + items.RemoveAt(currentIndex); + items.Insert(newIndex, element); + } - /// - public void SymmetricExceptWith(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - innerSet.SymmetricExceptWith(other); + return true; } /// - public void UnionWith(IEnumerable other) - { - Ensure.NotNull(other, nameof(other)); - innerSet.UnionWith(other); - } + public IEnumerator GetEnumerator() => items.GetEnumerator(); - /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - - /// - public bool TryGetByName(TName name, out T? element) - { - Ensure.NotNull(name, nameof(name)); - element = innerSet.FirstOrDefault(item => NameComparer.Equals(item.Name, name)); - return element is not null; - } - - /// - public bool ContainsByName(TName name) - { - Ensure.NotNull(name, nameof(name)); - return innerSet.Any(item => NameComparer.Equals(item.Name, name)); - } - - /// - public bool RemoveByName(TName name) - { - Ensure.NotNull(name, nameof(name)); - if (TryGetByName(name, out T? element)) - { - Debug.Assert(element is not null); - return innerSet.Remove(element); - } - return false; - } } diff --git a/Schema/Models/SchemaChildSummary.cs b/Schema/Models/SchemaChildSummary.cs deleted file mode 100644 index be3005a..0000000 --- a/Schema/Models/SchemaChildSummary.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Models; - -using ktsu.Schema.Contracts; -using ktsu.Semantics.Strings; - -/// -/// Represents a summary of a schema child element. -/// -public sealed record class SchemaChildSummary : SemanticString, ISchemaChildSummary -{ -} diff --git a/Schema/Models/SchemaClass.cs b/Schema/Models/SchemaClass.cs index ce8177e..6c697f0 100644 --- a/Schema/Models/SchemaClass.cs +++ b/Schema/Models/SchemaClass.cs @@ -4,12 +4,13 @@ namespace ktsu.Schema.Models; using System.Collections.ObjectModel; using System.Text.Json.Serialization; +using ktsu.Schema.Contracts; using ktsu.Schema.Models.Names; /// /// Represents a class within a schema. /// -public class SchemaClass : SchemaChild +public class SchemaClass : SchemaChild, ISchemaClass { /// /// Gets the internal collection of members. @@ -19,10 +20,31 @@ public class SchemaClass : SchemaChild internal Collection MembersInternal { get; set; } = []; /// - /// Gets the members of the schema class. + /// Gets the members of the schema class, in declaration order. /// + /// + /// A fresh view each time rather than a cached one: deserialization replaces + /// wholesale, and a cached view would go on reading the + /// collection that replaced it. The view holds one reference and no state of its own. + /// [JsonIgnore] - public IReadOnlyCollection Members => MembersInternal; + public SchemaChildSet Members => new(MembersInternal); + + /// + /// Explicit because the contract's element type is ; the covariance + /// of makes it the same object. + /// + ISchemaChildSet ISchemaClass.Members => Members; + + /// + ISchemaMember? ISchemaClass.AddMember(MemberName name) => AddMember(name); + + /// + /// Removes the member with the specified name. + /// + /// The name of the member to remove. + /// True if a member with that name was found and removed; otherwise, false. + public bool RemoveMember(MemberName name) => Members.RemoveByName(name); /// /// Gets a summary of the schema class. @@ -45,16 +67,10 @@ public class SchemaClass : SchemaChild { Ensure.NotNull(name); - if (MembersInternal.Any(m => m.Name == name)) - { - return null; - } - SchemaMember member = new(); member.Rename(name); member.AssociateWith(this); - MembersInternal.Add(member); - return member; + return Members.Add(member) ? member : null; } /// @@ -62,7 +78,7 @@ public class SchemaClass : SchemaChild /// /// The member to remove. /// True if the member was removed; otherwise, false. - internal bool TryRemoveMember(SchemaMember member) => MembersInternal.Remove(member); + internal bool TryRemoveMember(SchemaMember member) => Members.Remove(member); /// /// Restores a previously removed member back into the class. @@ -74,14 +90,8 @@ public bool RestoreMember(SchemaMember member) { Ensure.NotNull(member); - if (MembersInternal.Any(m => m.Name == member.Name)) - { - return false; - } - member.AssociateWith(this); - MembersInternal.Add(member); - return true; + return Members.Add(member); } /// @@ -90,25 +100,21 @@ public bool RestoreMember(SchemaMember member) /// The name of the member to find. /// The found member, if any. /// True if the member was found; otherwise, false. - public bool TryGetMember(MemberName name, out SchemaMember? member) - { - member = MembersInternal.FirstOrDefault(m => m.Name == name); - return member is not null; - } + public bool TryGetMember(MemberName name, out SchemaMember? member) => Members.TryGetByName(name, out member); /// /// Gets a member by name. /// /// The name of the member. /// The member if found, null otherwise. - public SchemaMember? GetMember(MemberName name) => MembersInternal.FirstOrDefault(m => m.Name == name); + public SchemaMember? GetMember(MemberName name) => Members.GetByName(name); /// /// Gets the position of a member in the class's declaration order. /// /// The member to locate. /// The member's index, or -1 if it does not belong to this class. - public int IndexOfMember(SchemaMember member) => MembersInternal.IndexOf(member); + public int IndexOfMember(SchemaMember member) => Members.IndexOf(member); /// /// Renames a member, repointing any array key that named it. @@ -126,12 +132,12 @@ public bool TryRenameMember(SchemaMember member, MemberName newName) Ensure.NotNull(member); Ensure.NotNull(newName); - if (!MembersInternal.Contains(member) || string.IsNullOrEmpty(newName)) + if (Members.IndexOf(member) < 0 || string.IsNullOrEmpty(newName)) { return false; } - if (member.Name != newName && MembersInternal.Any(m => m.Name == newName)) + if (member.Name != newName && Members.ContainsByName(newName)) { return false; } @@ -156,19 +162,6 @@ public bool TryMoveMember(SchemaMember member, int newIndex) { Ensure.NotNull(member); - int currentIndex = MembersInternal.IndexOf(member); - if (currentIndex < 0 || newIndex < 0 || newIndex >= MembersInternal.Count) - { - return false; - } - - if (newIndex == currentIndex) - { - return true; - } - - MembersInternal.RemoveAt(currentIndex); - MembersInternal.Insert(newIndex, member); - return true; + return Members.Move(member, newIndex); } } diff --git a/Schema/Models/SchemaClassChild.cs b/Schema/Models/SchemaClassChild.cs index c9be68c..d442074 100644 --- a/Schema/Models/SchemaClassChild.cs +++ b/Schema/Models/SchemaClassChild.cs @@ -3,6 +3,7 @@ namespace ktsu.Schema.Models; using System.Text.Json.Serialization; +using ktsu.Schema.Contracts; using ktsu.Schema.Contracts.Names; using ktsu.Semantics.Strings; @@ -10,7 +11,7 @@ namespace ktsu.Schema.Models; /// Represents a child of a schema class. /// /// The type of the name. -public abstract class SchemaClassChild : SchemaChild where TName : SemanticString, ISchemaClassChildName, new() +public abstract class SchemaClassChild : SchemaChild, ISchemaClassChild where TName : SemanticString, ISchemaClassChildName, new() { /// /// Gets the parent class of the schema class child. @@ -18,6 +19,12 @@ namespace ktsu.Schema.Models; [JsonIgnore] public SchemaClass? ParentClass { get; private set; } + /// + /// Explicit because the contract exposes the parent as while the + /// model exposes the concrete ; both are the same object. + /// + ISchemaClass? ISchemaClassChild.ParentClass => ParentClass; + /// /// Associates the schema class child with a parent class. /// diff --git a/Schema/Models/SchemaEnum.cs b/Schema/Models/SchemaEnum.cs index 10e5988..876296b 100644 --- a/Schema/Models/SchemaEnum.cs +++ b/Schema/Models/SchemaEnum.cs @@ -4,13 +4,14 @@ namespace ktsu.Schema.Models; using System.Collections.ObjectModel; using System.Text.Json.Serialization; +using ktsu.Schema.Contracts; using ktsu.Schema.Models.Names; /// /// Represents an enumeration in a schema. /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Naming", "CA1711:Identifiers should not have incorrect suffix", Justification = "It's representing a custom enumeration")] -public class SchemaEnum : SchemaChild +public class SchemaEnum : SchemaChild, ISchemaEnum { /// /// Gets the internal collection of enumeration values. @@ -23,20 +24,20 @@ public class SchemaEnum : SchemaChild /// Gets the read-only collection of enumeration values. /// [JsonIgnore] - public IReadOnlyCollection Values => ValuesInternal; + public IReadOnlyList Values => ValuesInternal; /// /// Tries to add a new value to the enumeration. /// - /// The value to add. + /// The value to add. /// True if the value was added; otherwise, false. - /// Thrown when is null or empty. - public bool TryAddValue(EnumValueName enumValueName) + /// Thrown when is null or empty. + public bool TryAddValue(EnumValueName name) { - Ensure.NotNullOrEmpty(enumValueName, nameof(enumValueName)); - if (!ValuesInternal.Any(v => v == enumValueName)) + Ensure.NotNullOrEmpty(name, nameof(name)); + if (!ValuesInternal.Any(v => v == name)) { - ValuesInternal.Add(enumValueName); + ValuesInternal.Add(name); return true; } @@ -46,9 +47,9 @@ public bool TryAddValue(EnumValueName enumValueName) /// /// Tries to remove a value from the enumeration. /// - /// The value to remove. + /// The value to remove. /// True if the value was removed; otherwise, false. - public bool TryRemoveValue(EnumValueName enumValueName) => ValuesInternal.Remove(enumValueName); + public bool TryRemoveValue(EnumValueName name) => ValuesInternal.Remove(name); /// /// Renames a value, keeping its position in the enumeration. diff --git a/Schema/Models/SchemaEnumValue.cs b/Schema/Models/SchemaEnumValue.cs deleted file mode 100644 index 3679c31..0000000 --- a/Schema/Models/SchemaEnumValue.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Models; - -using ktsu.Schema.Models.Names; - -/// -/// Represents a value within an enumeration. -/// This class maintains two parent references: -/// - ParentSchema (inherited): References the root schema that owns this enum value -/// - ParentEnum: References the immediate parent enumeration that contains this value -/// -public class SchemaEnumValue : SchemaChild -{ - /// - /// Gets the parent enum that contains this enumeration value. - /// - public SchemaEnum? ParentEnum { get; private set; } - - /// - /// Associates the schema enum value with a parent enum. - /// - /// The parent enum to associate with. - /// Thrown when the provided schema enum is null. - public void AssociateWith(SchemaEnum schemaEnum) - { - Ensure.NotNull(schemaEnum); - - ParentEnum = schemaEnum; - if (schemaEnum.ParentSchema is not null) - { - AssociateWith(schemaEnum.ParentSchema); - } - } - - /// - /// Tries to remove this enum value from its parent enum. - /// - /// True if the enum value was removed; otherwise, false. - public override bool TryRemove() => ParentEnum?.TryRemoveValue(Name) ?? false; -} \ No newline at end of file diff --git a/Schema/Models/SchemaMember.cs b/Schema/Models/SchemaMember.cs index 79593c0..54a23bc 100644 --- a/Schema/Models/SchemaMember.cs +++ b/Schema/Models/SchemaMember.cs @@ -3,6 +3,7 @@ namespace ktsu.Schema.Models; using System.Text.Json.Serialization; +using ktsu.Schema.Contracts; using ktsu.Schema.Models.Names; using ktsu.Schema.Models.Types; using ktsu.Semantics.Strings; @@ -10,7 +11,7 @@ namespace ktsu.Schema.Models; /// /// Represents a member of a schema class. /// -public class SchemaMember : SchemaClassChild +public class SchemaMember : SchemaClassChild, ISchemaMember { /// /// Gets the type of the schema member. @@ -23,6 +24,12 @@ public class SchemaMember : SchemaClassChild [JsonInclude] public BaseType Type { get; private set; } = new None(); + /// + /// Explicit because the contract exposes the type as while the model + /// exposes the concrete ; both are the same object. + /// + ISchemaType ISchemaMember.Type => Type; + /// /// Reads the description written by versions that stored it under "memberDescription". /// @@ -58,23 +65,18 @@ public void SetType(BaseType type) Type.AssociateWith(this); } + /// + /// Explicit because the contract takes the type as . Every type the + /// library can store derives from — the polymorphic serializer knows no + /// other — so a type from outside that hierarchy is rejected rather than stored as something + /// nothing else can read. + /// + void ISchemaMember.SetType(ISchemaType type) => + SetType(type as BaseType ?? throw new ArgumentException($"The type must derive from {nameof(BaseType)}.", nameof(type))); + /// /// Tries to remove the schema member from its parent class. /// /// True if the member was successfully removed; otherwise, false. public override bool TryRemove() => ParentClass?.TryRemoveMember(this) ?? false; } - -/// -/// Represents the root member of a schema. -/// -public class RootSchemaMember : SchemaMember -{ - /// - /// Throws a NotSupportedException as renaming is not supported on the root schema member. - /// - /// The new name (not used). - /// Always thrown as renaming is not supported on the root schema member. - [Obsolete("Not supported on the root schema member", true)] - public new void Rename(MemberName _) => throw new NotSupportedException("Not supported on the root schema member"); -} diff --git a/Schema/Models/SchemaMemberChild.cs b/Schema/Models/SchemaMemberChild.cs deleted file mode 100644 index be61438..0000000 --- a/Schema/Models/SchemaMemberChild.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Models; - -using System.Text.Json.Serialization; -using ktsu.Schema.Contracts.Names; -using ktsu.Semantics.Strings; - -/// -/// Represents a child of a schema member. -/// -/// The type of the name. -public abstract class SchemaMemberChild : SchemaClassChild where TName : SemanticString, ISchemaClassChildName, new() -{ - /// - /// Gets the parent member of the schema member child. - /// - [JsonIgnore] - public SchemaMember? ParentMember { get; private set; } - - /// - /// Associates the schema member child with a parent member. - /// - /// The parent member to associate with. - public void AssociateWith(SchemaMember schemaMember) => ParentMember = schemaMember; -} diff --git a/Schema/Models/Types/BaseType.cs b/Schema/Models/Types/BaseType.cs index 63114d4..3cc5e3b 100644 --- a/Schema/Models/Types/BaseType.cs +++ b/Schema/Models/Types/BaseType.cs @@ -3,6 +3,9 @@ namespace ktsu.Schema.Models.Types; using System.Text.Json.Serialization; +using ktsu.Schema.Contracts; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; /// /// Represents the base type for all schema types. @@ -28,7 +31,7 @@ namespace ktsu.Schema.Models.Types; [JsonDerivedType(typeof(ColorRGBA), nameof(ColorRGBA))] [JsonDerivedType(typeof(Object), nameof(Object))] [JsonPolymorphic(TypeDiscriminatorPropertyName = "TypeName")] -public abstract class BaseType : IEquatable +public abstract class BaseType : IEquatable, ISchemaType { /// /// Gets or sets the parent member of the schema type. @@ -36,6 +39,23 @@ public abstract class BaseType : IEquatable [JsonIgnore] public SchemaMember? ParentMember { get; private set; } + /// + /// Gets the name identifying which type this is. + /// + /// + /// Derived from the CLR type name, which is exactly what the [JsonDerivedType] + /// discriminators above are declared as, so this always matches the TypeName written + /// to the file. A test asserts that correspondence. + /// + [JsonIgnore] + public BaseTypeName TypeName => GetType().Name.As(); + + /// + /// Explicit because the contract exposes the parent as while the + /// model exposes the concrete ; both are the same object. + /// + ISchemaMember? ISchemaType.ParentMember => ParentMember; + /// /// Associates this type with a schema member. /// diff --git a/Schema/Models/Types/SchemaTypes.cs b/Schema/Models/Types/SchemaTypes.cs deleted file mode 100644 index d5dea17..0000000 --- a/Schema/Models/Types/SchemaTypes.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) 2023-2026 ktsu-dev contributors - -namespace ktsu.Schema.Models.Types; -/// -/// Contains various schema types used in the application. -/// -public static class SchemaTypes -{ - /// - /// Gets the type qualifier string. - /// - public static string TypeQualifier => $"{typeof(SchemaTypes).FullName}+"; -} diff --git a/SchemaEditor/TreeClass.cs b/SchemaEditor/TreeClass.cs index c76a755..710134c 100644 --- a/SchemaEditor/TreeClass.cs +++ b/SchemaEditor/TreeClass.cs @@ -66,7 +66,7 @@ internal void Show() private void ShowMemberTree(ImGuiWidgets.Tree parent, SchemaClass schemaClass) { - IReadOnlyCollection children = schemaClass.Members; + SchemaChildSet children = schemaClass.Members; ImGui.PushID(schemaClass.Name); ButtonTree.ShowTree(schemaClass.Name, $"{schemaClass.Name} ({children.Count})", children, new() diff --git a/docs/examples/dependency-injection.md b/docs/examples/dependency-injection.md index ea78c87..9749473 100644 --- a/docs/examples/dependency-injection.md +++ b/docs/examples/dependency-injection.md @@ -2,6 +2,8 @@ The Schema library focuses solely on schema definition — serialization is handled by the separate `SchemaSerializer` class and there are no filesystem concerns baked into the model. This makes it straightforward to use in dependency injection scenarios. +You can inject either the concrete `Schema` or the `ISchema` contract. Inject `ISchema` when a consumer only needs to read or edit a schema and you want it substitutable in tests; inject `Schema` when the consumer needs the parts that are not on the contract — serialization, validation, path resolution, data sources or code generators. + ## Basic Setup ### Using Microsoft.Extensions.DependencyInjection @@ -27,6 +29,21 @@ builder.Services.AddSingleton(provider => var host = builder.Build(); ``` +### Registering the Contract + +`Schema` implements `ISchema`, so a consumer can depend on the abstraction instead of the model: + +```csharp +using ktsu.Schema.Contracts; + +builder.Services.AddSingleton(provider => +{ + Schema schema = new(); + schema.AddClass("User".As()); + return schema; +}); +``` + ### Loading a Schema from a File ```csharp @@ -41,6 +58,48 @@ builder.Services.AddSingleton(provider => ## Consuming the Schema +### Through the contract + +A service that only defines and reads schema elements needs nothing but `ISchema`: + +```csharp +using ktsu.Schema.Contracts; +using ktsu.Schema.Models.Names; +using ktsu.Semantics.Strings; +using SchemaTypes = ktsu.Schema.Models.Types; + +public class UserSchemaBuilder(ISchema schema) +{ + public void Define() + { + ISchemaClass? user = schema.AddClass("User".As()); + user?.AddMember("Name".As())?.SetType(new SchemaTypes.String()); + user?.AddMember("Email".As())?.SetType(new SchemaTypes.String()); + } + + public void Describe() + { + foreach (ISchemaClass schemaClass in schema.Classes) + { + Console.WriteLine($"{schemaClass.Name} ({schemaClass.Members.Count} members)"); + } + } +} +``` + +`Classes` and `Members` are name-indexed and preserve declaration order, so `GetByName` and +`ContainsByName` avoid scanning by hand: + +```csharp +if (schema.Classes.GetByName("User".As()) is ISchemaClass user + && user.Members.ContainsByName("Email".As())) +{ + // ... +} +``` + +### Through the concrete type + ```csharp using ktsu.Schema.Models; using ktsu.Schema.Models.Names; @@ -78,7 +137,7 @@ public class MyService ## Wrapping the Schema in Your Own Abstraction -If your application needs schema management behavior (loading, saving, caching, change tracking), wrap `Schema` in your own service interface so the rest of your code depends on your abstraction rather than the library type: +`ISchema` abstracts what a schema *is*. It deliberately says nothing about where a schema comes from or what happens to it — loading, saving, caching and change tracking are your application's concerns, not the model's. If you need those, wrap `Schema` in your own service interface: ```csharp public interface ISchemaService @@ -112,6 +171,8 @@ builder.Services.AddSingleton(_ => new FileSchemaService("app.sc ## Notes - `Schema` is not thread-safe; if multiple services mutate a shared schema concurrently, provide your own synchronization. +- `ISchema` covers classes, enums, members and types. Serialization, validation, path resolution, data sources and code generators are on the concrete `Schema` — a consumer that needs them should take `Schema`, or your own abstraction over it. +- The collections on the contracts are read-only views. Add and remove through `ISchema`/`ISchemaClass`, which is what enforces name uniqueness and gives a new element the parent reference it needs to resolve its own type references. - Register schemas as singletons when they represent application-wide definitions; use factories or scoped services if each scope needs an independent copy. ## Navigation