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