From 15001909d1f078d3f86c1d653e1f3656f244f8a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 08:30:40 +0000 Subject: [PATCH] Hold data to member and semantic-type ranges SchemaDataValidator checked a value against its type and never read a range, so a non-wrapping [0, 100] Int member accepted 500 and -7. A number outside a non-wrapping range is now an error at the value's path that names the range and its owner. Vectors and vector quantities are checked per component, a wrapping range accepts any number, and the range of a semantic type applies wherever a value of it appears, alongside the member's own. The migrator asks the validator through ConformsTo, which now takes the member's range, so a value the new range refuses is reported and takes the default instead of being carried into data that no longer validates. Fixes #349 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3VYgr52Mw1KS9otcE --- Schema.Test/GeneratedDataEditorTests.cs | 19 +++- Schema.Test/SchemaDataValidatorTypeTests.cs | 106 ++++++++++++++++++++ Schema.Test/SchemaMigrationTests.cs | 25 +++++ Schema/Models/SchemaDataMigrator.cs | 16 +-- Schema/Models/SchemaDataValidator.cs | 81 ++++++++++++++- docs/schema-format.md | 5 + 6 files changed, 244 insertions(+), 8 deletions(-) diff --git a/Schema.Test/GeneratedDataEditorTests.cs b/Schema.Test/GeneratedDataEditorTests.cs index bb644c8..13646eb 100644 --- a/Schema.Test/GeneratedDataEditorTests.cs +++ b/Schema.Test/GeneratedDataEditorTests.cs @@ -8,6 +8,7 @@ namespace ktsu.Schema.Tests; using ktsu.Schema.Generation; using ktsu.Schema.Models; +using ktsu.Schema.Models.Metadata; using ktsu.Schema.Models.Names; using ktsu.Schema.Runtime; using ktsu.Semantics.Paths; @@ -61,7 +62,12 @@ private static Schema CreateSchema(out SchemaCodeGenerator generator, string dat SchemaClass item = schema.AddClass("Item".As())!; item.AddMember("Id".As())!.SetType(new SchemaTypes.String()); - item.AddMember("Count".As())!.SetType(new SchemaTypes.Int()); + SchemaMember count = item.AddMember("Count".As())!; + count.SetType(new SchemaTypes.Int()); + + // A bound the embedded schema has to carry, so an out-of-range payload below proves the + // editor's range check is the validator's rather than one of its own. + count.Range = new MemberRange { Minimum = 0, Maximum = 100 }; item.AddMember("Weight".As())!.SetType(new SchemaTypes.Float()); item.AddMember("Status".As())!.SetType(new SchemaTypes.Enum { EnumName = "Status".As() }); item.AddMember("Tint".As())!.SetType(new SchemaTypes.ColorRGB()); @@ -167,6 +173,7 @@ public void TestABindingEmitsARecordAndAnEditorThatCompile() """[ { "Id": "sword", "Count": "three", "Weight": 1, "Status": "Active", "Tint": [0, 0, 0] } ]""", """[ { "Id": "sword", "Count": 3, "Weight": 1, "Status": "Broken", "Tint": [0, 0, 0] } ]""", """[ { "Count": 3 } ]""", + """[ { "Id": "sword", "Count": 500, "Weight": 1, "Status": "Active", "Tint": [0, 0, 0] } ]""", """[ 42 ]""", "{ \"Id\": ", "// a comment\n[]", @@ -192,6 +199,16 @@ public void TestTheEditorReportsExactlyWhatTheValidatorReports() } } + [TestMethod] + public void TestTheEditorRefusesAValueOutsideAMembersRange() + { + object editor = CreateEditor(CompileEditor()); + Invoke(editor, "Parse", """[ { "Id": "sword", "Count": 500, "Weight": 1, "Status": "Active", "Tint": [0, 0, 0] } ]"""); + + Assert.IsFalse((bool)Get(editor, "IsValid")!); + Assert.IsTrue(Issues(editor).Any(i => i.Path == "Items[0].Count" && i.Severity == SchemaValidationSeverity.Error), string.Join("; ", Issues(editor))); + } + [TestMethod] public void TestTheEditorStaysInStepWithTheValidatorAfterAnEdit() { diff --git a/Schema.Test/SchemaDataValidatorTypeTests.cs b/Schema.Test/SchemaDataValidatorTypeTests.cs index 5b42d74..fa837fb 100644 --- a/Schema.Test/SchemaDataValidatorTypeTests.cs +++ b/Schema.Test/SchemaDataValidatorTypeTests.cs @@ -4,6 +4,7 @@ namespace ktsu.Schema.Tests; using System.Collections.ObjectModel; using ktsu.Schema.Models; +using ktsu.Schema.Models.Metadata; using ktsu.Schema.Models.Names; using ktsu.Schema.Models.Types; using ktsu.Semantics.Strings; @@ -136,4 +137,109 @@ public void ATypeWithNoStoredFormIsReportedAsUnchecked() Assert.AreEqual(SchemaValidationSeverity.Warning, issues[0].Severity, type.DisplayName); } } + + // ---- range ---------------------------------------------------------------- + + private static Collection ValidateRanged(BaseType type, MemberRange range, string value) => + SchemaDataValidator.ValidateData( + ClassWith((_, c) => + { + SchemaMember member = c.AddMember("Value".As())!; + member.SetType(type); + member.Range = range; + }), + $"{{ \"Value\": {value} }}", + "data"); + + private static readonly MemberRange Percent = new() { Minimum = 0, Maximum = 100 }; + + private static readonly string[] OutOfRangeComponentPaths = ["data.Value[1]", "data.Value[2]"]; + + private static readonly string[] OutOfRangeSemanticPaths = ["data.Current", "data.History[1]"]; + + [TestMethod] + [DataRow("50")] + [DataRow("0")] + [DataRow("100")] + public void AValueInsideOrOnTheBoundOfAMembersRangeIsAccepted(string value) => + Assert.HasCount(0, ValidateRanged(new Int(), Percent, value)); + + [TestMethod] + [DataRow("500")] + [DataRow("-7")] + [DataRow("101")] + public void AValueOutsideAMembersRangeIsAnErrorThatNamesTheRange(string value) + { + Collection issues = ValidateRanged(new Int(), Percent, value); + + Assert.HasCount(1, issues, string.Join("; ", issues)); + Assert.AreEqual(SchemaValidationSeverity.Error, issues[0].Severity); + Assert.AreEqual("data.Value", issues[0].Path); + Assert.Contains($"{Percent}", issues[0].Message); + Assert.Contains("member 'Value'", issues[0].Message); + } + + [TestMethod] + public void AFloatJustPastTheBoundIsOutsideTheRange() => + Assert.HasCount(1, ValidateRanged(new Double(), new MemberRange { Minimum = 0, Maximum = 1 }, "1.0000001")); + + [TestMethod] + [DataRow("7")] + [DataRow("-100")] + public void AWrappingRangeAcceptsAnyNumber(string value) => + Assert.HasCount(0, ValidateRanged(new Double(), new MemberRange { Minimum = 0, Maximum = 6.2831853, Wrap = true }, value)); + + [TestMethod] + public void AVectorIsBoundedComponentByComponent() + { + Collection issues = ValidateRanged(new Vector3(), Percent, "[1, 200, -1]"); + + string[] paths = [.. issues.Select(i => i.Path)]; + Assert.AreSequenceEqual(OutOfRangeComponentPaths, paths, string.Join("; ", issues)); + } + + [TestMethod] + public void AValueOfTheWrongTypeIsReportedOnceAndNotAlsoAsOutOfRange() => + Assert.HasCount(1, ValidateRanged(new Int(), Percent, "\"fast\"")); + + [TestMethod] + public void ASemanticTypesRangeBoundsEveryValueOfIt() + { + SchemaClass schemaClass = ClassWith((schema, c) => + { + SchemaSemanticType health = schema.AddSemanticType("Health".As())!; + health.SetUnderlyingType(new Int()); + health.Range = Percent; + c.AddMember("Current".As())!.SetType(new Semantic { SemanticTypeName = "Health".As() }); + c.AddMember("History".As())!.SetType(new ktsu.Schema.Models.Types.Array { ElementType = new Semantic { SemanticTypeName = "Health".As() } }); + }); + + Collection issues = SchemaDataValidator.ValidateData( + schemaClass, + """{ "Current": 500, "History": [ 50, -7 ] }""", + "data"); + + string[] paths = [.. issues.Select(i => i.Path).Order(StringComparer.Ordinal)]; + Assert.AreSequenceEqual(OutOfRangeSemanticPaths, paths, string.Join("; ", issues)); + Assert.IsTrue(issues.All(i => i.Message.Contains("semantic type 'Health'", StringComparison.Ordinal)), string.Join("; ", issues)); + } + + [TestMethod] + public void AMembersRangeAndItsSemanticTypesRangeBothApply() + { + SchemaClass schemaClass = ClassWith((schema, c) => + { + SchemaSemanticType health = schema.AddSemanticType("Health".As())!; + health.SetUnderlyingType(new Int()); + health.Range = Percent; + SchemaMember member = c.AddMember("Value".As())!; + member.SetType(new Semantic { SemanticTypeName = "Health".As() }); + member.Range = new MemberRange { Minimum = 10, Maximum = 1000 }; + }); + + Assert.HasCount(0, SchemaDataValidator.ValidateData(schemaClass, """{ "Value": 50 }""", "data")); + Assert.HasCount(1, SchemaDataValidator.ValidateData(schemaClass, """{ "Value": 5 }""", "data")); + Assert.HasCount(1, SchemaDataValidator.ValidateData(schemaClass, """{ "Value": 500 }""", "data")); + Assert.HasCount(2, SchemaDataValidator.ValidateData(schemaClass, """{ "Value": 5000 }""", "data")); + } } diff --git a/Schema.Test/SchemaMigrationTests.cs b/Schema.Test/SchemaMigrationTests.cs index c92c6ba..d73ca8c 100644 --- a/Schema.Test/SchemaMigrationTests.cs +++ b/Schema.Test/SchemaMigrationTests.cs @@ -218,6 +218,31 @@ public void EveryValueGivenUpIsReported() Assert.AreSequenceEqual(expected, paths, string.Join("; ", result.Issues)); } + /// + /// A value the current member's range refuses is not carried across: it is reported and takes + /// the default, as a value of the wrong type does, so migrated data still validates clean. + /// + [TestMethod] + public void AValueOutsideTheCurrentRangeIsReportedAndTakesTheDefault() + { + Schema previous = CreatePrevious(); + Schema current = CreateCurrent(previous); + SchemaMember health = current.GetClass("Hero".As())!.Members.First(m => m.Name == "Health"); + health.Range = new MemberRange { Minimum = 0, Maximum = 8 }; + health.DefaultValue = new NumberDefault { Value = 5 }; + + SchemaMigrationResult result = SchemaDataMigrator.Migrate(SchemaDiff.Compare(previous, current), previous.GetClass("Player".As())!, PlayerData, "players"); + JsonArray players = JsonNode.Parse(result.Json)!.AsArray(); + + Assert.AreEqual(5, players[0]!["Health"]!.GetValue(), "Ada's 10 is above the new maximum."); + Assert.AreEqual(7, players[1]!["Health"]!.GetValue(), "Bo's 7 is inside it and is kept."); + SchemaValidationIssue reported = result.Issues.Single(issue => issue.Path == "players[0].Hp"); + Assert.Contains("outside the range", reported.Message); + + Collection issues = SchemaDataValidator.ValidateData(current.GetClass("Hero".As())!, result.Json, "players"); + Assert.IsFalse(issues.Any(issue => issue.Severity == SchemaValidationSeverity.Error), string.Join("; ", issues)); + } + [TestMethod] public void DataBoundToARemovedClassIsRefused() { diff --git a/Schema/Models/SchemaDataMigrator.cs b/Schema/Models/SchemaDataMigrator.cs index acdbac7..fec21ae 100644 --- a/Schema/Models/SchemaDataMigrator.cs +++ b/Schema/Models/SchemaDataMigrator.cs @@ -273,17 +273,21 @@ JsonValue text when text.TryGetValue(out string? s) => s, /// private JsonNode? Settle(BaseType type, SchemaMember? member, JsonNode? value, string path) { - if (Conforms(type, value)) + if (Conforms(type, member, value)) { return value; } - if (Convert(type, value) is JsonNode converted && Conforms(type, converted)) + if (Convert(type, value) is JsonNode converted && Conforms(type, member, converted)) { return converted; } - issues.Add(Warning(path, $"Value {value?.ToJsonString() ?? "null"} is not a '{type.DisplayName}'; replaced with the default.")); + // A value of the right type that the member's range refuses is said to be out of range, + // not of the wrong type, since that is the change it would take to keep it. + issues.Add(Warning(path, member?.Range is MemberRange range && Conforms(type, null, value) + ? $"Value {value?.ToJsonString() ?? "null"} is outside the range {range} of member '{member.Name}'; replaced with the default." + : $"Value {value?.ToJsonString() ?? "null"} is not a '{type.DisplayName}'; replaced with the default.")); return DefaultFor(type, member, []); } @@ -323,10 +327,10 @@ private static JsonValue Number(BaseType type, double value) => _ => false, }; - private static bool Conforms(BaseType type, JsonNode? value) + private static bool Conforms(BaseType type, SchemaMember? member, JsonNode? value) { using JsonDocument document = JsonDocument.Parse(value?.ToJsonString() ?? "null"); - return SchemaDataValidator.ConformsTo(type, document.RootElement); + return SchemaDataValidator.ConformsTo(type, document.RootElement, member?.Range); } /// @@ -335,7 +339,7 @@ private static bool Conforms(BaseType type, JsonNode? value) /// private static JsonNode? DefaultFor(BaseType type, SchemaMember? member, HashSet building) { - if (member?.DefaultValue is MemberDefault declared && Declared(type, declared) is JsonNode node && Conforms(type, node)) + if (member?.DefaultValue is MemberDefault declared && Declared(type, declared) is JsonNode node && Conforms(type, member, node)) { return node; } diff --git a/Schema/Models/SchemaDataValidator.cs b/Schema/Models/SchemaDataValidator.cs index 3cb401f..06cf7b4 100644 --- a/Schema/Models/SchemaDataValidator.cs +++ b/Schema/Models/SchemaDataValidator.cs @@ -177,11 +177,13 @@ public static Collection ValidateData(SchemaClass schemaC /// /// The type the value should conform to. /// The value to check. + /// The range of the member holding the value, if it has one. /// True if validating the value reports no error. - internal static bool ConformsTo(BaseType type, JsonElement value) + internal static bool ConformsTo(BaseType type, JsonElement value, MemberRange? range = null) { Collection issues = []; ValidateValue(type, value, string.Empty, issues); + ValidateRange(range, "the member", type, value, string.Empty, issues); return !issues.Any(issue => issue.Severity == SchemaValidationSeverity.Error); } @@ -208,6 +210,7 @@ private static void ValidateObject(SchemaClass schemaClass, JsonElement element, accountedFor.Add(matchedName); ValidateValue(member.Type, value, $"{path}.{member.Name}", issues); + ValidateRange(member.Range, $"member '{member.Name}'", member.Type, value, $"{path}.{member.Name}", issues); } foreach (string propertyName in element.EnumerateObject() @@ -357,6 +360,82 @@ private static void ValidateSemanticValue(Semantic semantic, JsonElement value, if (semantic.Declaration?.Representation() is BaseType representation and not Semantic) { ValidateValue(representation, value, path, issues); + + // Every type on the chain bounds the value: a refinement narrows what it refines, so a + // value has to sit inside the range of each one, not only the nearest. + foreach (SchemaSemanticType declaration in (IEnumerable)[semantic.Declaration, .. semantic.Declaration.Refines()]) + { + ValidateRange(declaration.Range, $"semantic type '{declaration.Name}'", representation, value, path, issues); + } + } + } + + /// + /// Refuses a number outside a range that bounds it. + /// + /// + /// + /// Without wrap a range is a bound, and a value outside it is invalid. With it the range + /// is a period, so any number is the un-normalised form of one inside it and is accepted. This + /// is the reading schema validation already takes of a default. + /// + /// + /// A member's own range and the range of a semantic type it holds both apply, each reported + /// against its owner, so neither takes precedence: a value has to satisfy both. + /// + /// + /// Only a value of the right shape is judged. One of the wrong type is already reported by + /// , and a range that is backwards or on a type it means nothing on + /// is already reported by schema validation. + /// + /// + /// The range, or when there is none. + /// What declared the range, for the message. + /// The type the value is declared as. + /// The value. + /// The value's path. + /// Where issues go. + private static void ValidateRange(MemberRange? range, string owner, BaseType type, JsonElement value, string path, Collection issues) + { + if (range is null || range.Wrap || !range.IsWellFormed) + { + return; + } + + BaseType represented = type switch + { + Semantic { Declaration: SchemaSemanticType declaration } => declaration.Representation(), + Quantity quantity => quantity.Storage, + _ => type, + }; + + if (value.ValueKind == JsonValueKind.Number && represented.IsNumeric) + { + ExpectInRange(range, owner, value, path, issues); + return; + } + + // A vector, or a quantity's vector form, is bounded component by component. + if (value.ValueKind == JsonValueKind.Array && (represented is Vector || type is Quantity)) + { + int index = 0; + foreach (JsonElement component in value.EnumerateArray()) + { + if (component.ValueKind == JsonValueKind.Number) + { + ExpectInRange(range, owner, component, $"{path}[{index}]", issues); + } + + index++; + } + } + } + + private static void ExpectInRange(MemberRange range, string owner, JsonElement value, string path, Collection issues) + { + if (value.TryGetDouble(out double number) && (number < range.Minimum || number > range.Maximum)) + { + issues.Add(Error(path, $"{value.GetRawText()} is outside the range {range} of {owner}.")); } } diff --git a/docs/schema-format.md b/docs/schema-format.md index fa7a424..2de2493 100644 --- a/docs/schema-format.md +++ b/docs/schema-format.md @@ -169,6 +169,11 @@ un-normalised rather than wrong, and clamping it to the maximum would be the one that is certainly incorrect. A validator should reduce a wrapping value into range and reject a non-wrapping one — and for the same reason, a default outside a wrapping range is accepted. +`kschema validate`, the generated data editors and data migration all hold data to these bounds. A +vector is bounded component by component. A member's own range and the range of a semantic type it +holds (and of every semantic type that one refines) all apply, so neither takes precedence: a value +has to sit inside each of them, and each one it misses is reported against its owner. + ### Default Polymorphic, discriminated by `DefaultKind`, matching the `TypeName` discriminator on types: