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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion Schema.Test/GeneratedDataEditorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

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;
Expand Down Expand Up @@ -61,7 +62,12 @@

SchemaClass item = schema.AddClass("Item".As<ClassName>())!;
item.AddMember("Id".As<MemberName>())!.SetType(new SchemaTypes.String());
item.AddMember("Count".As<MemberName>())!.SetType(new SchemaTypes.Int());
SchemaMember count = item.AddMember("Count".As<MemberName>())!;
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<MemberName>())!.SetType(new SchemaTypes.Float());
item.AddMember("Status".As<MemberName>())!.SetType(new SchemaTypes.Enum { EnumName = "Status".As<EnumName>() });
item.AddMember("Tint".As<MemberName>())!.SetType(new SchemaTypes.ColorRGB());
Expand Down Expand Up @@ -167,6 +173,7 @@
"""[ { "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[]",
Expand All @@ -192,6 +199,16 @@
}
}

[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)));

Check warning on line 209 in Schema.Test/GeneratedDataEditorTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.Contains' instead of 'Assert.IsTrue'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaGkT3awseLMSSx8OFf_&open=AaGkT3awseLMSSx8OFf_&pullRequest=359
}

[TestMethod]
public void TestTheEditorStaysInStepWithTheValidatorAfterAnEdit()
{
Expand Down
106 changes: 106 additions & 0 deletions Schema.Test/SchemaDataValidatorTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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;
Expand Down Expand Up @@ -137,6 +138,111 @@
}
}

// ---- range ----------------------------------------------------------------

private static Collection<SchemaValidationIssue> ValidateRanged(BaseType type, MemberRange range, string value) =>
SchemaDataValidator.ValidateData(
ClassWith((_, c) =>
{
SchemaMember member = c.AddMember("Value".As<MemberName>())!;
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));

Check warning on line 165 in Schema.Test/SchemaDataValidatorTypeTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.HasCount'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaGkT3ZFseLMSSx8OFf5&open=AaGkT3ZFseLMSSx8OFf5&pullRequest=359

[TestMethod]
[DataRow("500")]
[DataRow("-7")]
[DataRow("101")]
public void AValueOutsideAMembersRangeIsAnErrorThatNamesTheRange(string value)
{
Collection<SchemaValidationIssue> 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));

Check warning on line 190 in Schema.Test/SchemaDataValidatorTypeTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.HasCount'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaGkT3ZFseLMSSx8OFf6&open=AaGkT3ZFseLMSSx8OFf6&pullRequest=359

[TestMethod]
public void AVectorIsBoundedComponentByComponent()
{
Collection<SchemaValidationIssue> 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<SemanticTypeName>())!;
health.SetUnderlyingType(new Int());
health.Range = Percent;
c.AddMember("Current".As<MemberName>())!.SetType(new Semantic { SemanticTypeName = "Health".As<SemanticTypeName>() });
c.AddMember("History".As<MemberName>())!.SetType(new ktsu.Schema.Models.Types.Array { ElementType = new Semantic { SemanticTypeName = "Health".As<SemanticTypeName>() } });
});

Collection<SchemaValidationIssue> 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<SemanticTypeName>())!;
health.SetUnderlyingType(new Int());
health.Range = Percent;
SchemaMember member = c.AddMember("Value".As<MemberName>())!;
member.SetType(new Semantic { SemanticTypeName = "Health".As<SemanticTypeName>() });
member.Range = new MemberRange { Minimum = 10, Maximum = 1000 };
});

Assert.HasCount(0, SchemaDataValidator.ValidateData(schemaClass, """{ "Value": 50 }""", "data"));

Check warning on line 240 in Schema.Test/SchemaDataValidatorTypeTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.IsEmpty' instead of 'Assert.HasCount'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaGkT3ZFseLMSSx8OFf7&open=AaGkT3ZFseLMSSx8OFf7&pullRequest=359
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"));
}

// ---- floating-point overflow -----------------------------------------------

[TestMethod]
Expand Down
25 changes: 25 additions & 0 deletions Schema.Test/SchemaMigrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,31 @@
Assert.AreSequenceEqual(expected, paths, string.Join("; ", result.Issues));
}

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public void AValueOutsideTheCurrentRangeIsReportedAndTakesTheDefault()
{
Schema previous = CreatePrevious();
Schema current = CreateCurrent(previous);
SchemaMember health = current.GetClass("Hero".As<ClassName>())!.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<ClassName>())!, PlayerData, "players");
JsonArray players = JsonNode.Parse(result.Json)!.AsArray();

Assert.AreEqual(5, players[0]!["Health"]!.GetValue<int>(), "Ada's 10 is above the new maximum.");
Assert.AreEqual(7, players[1]!["Health"]!.GetValue<int>(), "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<SchemaValidationIssue> issues = SchemaDataValidator.ValidateData(current.GetClass("Hero".As<ClassName>())!, result.Json, "players");
Assert.IsFalse(issues.Any(issue => issue.Severity == SchemaValidationSeverity.Error), string.Join("; ", issues));

Check warning on line 243 in Schema.Test/SchemaMigrationTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.DoesNotContain' instead of 'Assert.IsFalse'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Schema&issues=AaGkT3bLseLMSSx8OFgL&open=AaGkT3bLseLMSSx8OFgL&pullRequest=359
}

[TestMethod]
public void DataBoundToARemovedClassIsRefused()
{
Expand Down
16 changes: 10 additions & 6 deletions Schema/Models/SchemaDataMigrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
Message = message,
};

private static SchemaValidationIssue Warning(string path, string message) => new()

Check warning on line 104 in Schema/Models/SchemaDataMigrator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Move this method inside 'Migration'.

Check warning on line 104 in Schema/Models/SchemaDataMigrator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Move this method inside 'Migration'.

Check warning on line 104 in Schema/Models/SchemaDataMigrator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Move this method inside 'Migration'.

Check warning on line 104 in Schema/Models/SchemaDataMigrator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Move this method inside 'Migration'.

Check warning on line 104 in Schema/Models/SchemaDataMigrator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Move this method inside 'Migration'.

Check warning on line 104 in Schema/Models/SchemaDataMigrator.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Move this method inside 'Migration'.
{
Severity = SchemaValidationSeverity.Warning,
Path = path,
Expand Down Expand Up @@ -273,17 +273,21 @@
/// </summary>
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, []);
}

Expand Down Expand Up @@ -323,10 +327,10 @@
_ => 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);
}

/// <summary>
Expand All @@ -335,7 +339,7 @@
/// </summary>
private static JsonNode? DefaultFor(BaseType type, SchemaMember? member, HashSet<string> 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;
}
Expand Down
81 changes: 80 additions & 1 deletion Schema/Models/SchemaDataValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,13 @@ public static Collection<SchemaValidationIssue> ValidateData(SchemaClass schemaC
/// </remarks>
/// <param name="type">The type the value should conform to.</param>
/// <param name="value">The value to check.</param>
/// <param name="range">The range of the member holding the value, if it has one.</param>
/// <returns>True if validating the value reports no error.</returns>
internal static bool ConformsTo(BaseType type, JsonElement value)
internal static bool ConformsTo(BaseType type, JsonElement value, MemberRange? range = null)
{
Collection<SchemaValidationIssue> issues = [];
ValidateValue(type, value, string.Empty, issues);
ValidateRange(range, "the member", type, value, string.Empty, issues);
return !issues.Any(issue => issue.Severity == SchemaValidationSeverity.Error);
}

Expand All @@ -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()
Expand Down Expand Up @@ -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<SchemaSemanticType>)[semantic.Declaration, .. semantic.Declaration.Refines()])
{
ValidateRange(declaration.Range, $"semantic type '{declaration.Name}'", representation, value, path, issues);
}
}
}

/// <summary>
/// Refuses a number outside a range that bounds it.
/// </summary>
/// <remarks>
/// <para>
/// Without <c>wrap</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Only a value of the right shape is judged. One of the wrong type is already reported by
/// <see cref="ValidateValue"/>, and a range that is backwards or on a type it means nothing on
/// is already reported by schema validation.
/// </para>
/// </remarks>
/// <param name="range">The range, or <see langword="null"/> when there is none.</param>
/// <param name="owner">What declared the range, for the message.</param>
/// <param name="type">The type the value is declared as.</param>
/// <param name="value">The value.</param>
/// <param name="path">The value's path.</param>
/// <param name="issues">Where issues go.</param>
private static void ValidateRange(MemberRange? range, string owner, BaseType type, JsonElement value, string path, Collection<SchemaValidationIssue> 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<SchemaValidationIssue> 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}."));
}
}

Expand Down
5 changes: 5 additions & 0 deletions docs/schema-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading