diff --git a/src/Trax.Effect.StateMachine/Fluent.cs b/src/Trax.Effect.StateMachine/Fluent.cs index 67ef6db..2889578 100644 --- a/src/Trax.Effect.StateMachine/Fluent.cs +++ b/src/Trax.Effect.StateMachine/Fluent.cs @@ -76,6 +76,14 @@ public interface IStateBuilder /// Declare that this state carries no context (an empty schema). IStateBuilder Context(); + /// + /// Add a per-state context requirement, composed (ANDed) with the schema from + /// and with any other requirements. The declarative, exportable peer of a + /// policy check in : use it for what a state demands beyond its shape (a complete draft, + /// an absent receipt). Chain several; each is one small rule. + /// + IStateBuilder Requires(Rule constraint); + /// Mark this state committed: a soft autosave may not move a draft out of it (the guarded path). IStateBuilder Committed(); @@ -138,6 +146,7 @@ public sealed class MachineBuilder : IMachineBuilder> _migrations = []; private readonly Dictionary _contextSchemas = []; private readonly Dictionary _triggerInputs = []; + private readonly Dictionary> _stateInvariants = []; private readonly List> _declarativeTransitions = []; private bool _usedDeclarative; @@ -198,7 +207,11 @@ public BuiltMachine Build() ? new DeclarativeModel( _contextSchemas, _triggerInputs, - _declarativeTransitions + _declarativeTransitions, + _stateInvariants.ToDictionary( + kv => kv.Key, + kv => kv.Value.Count == 1 ? kv.Value[0] : (Rule)new Rule.All(kv.Value) + ) ) : null; @@ -223,10 +236,42 @@ private IStateBuilder SetSchema(ContextSchema schema) { owner._usedDeclarative = true; owner._contextSchemas[state] = schema; - owner._validators[state] = ctx => SchemaValidator.Validate(schema, ctx); + RebuildValidator(); + return this; + } + + public IStateBuilder Requires(Rule constraint) + { + owner._usedDeclarative = true; + if (!owner._stateInvariants.TryGetValue(state, out var list)) + owner._stateInvariants[state] = list = []; + list.Add(constraint); + RebuildValidator(); return this; } + // The state's validator is its schema (if declared) AND each requirement, composed. Rebuilt whenever + // Context or Requires changes, so the order of those calls does not matter. + private void RebuildValidator() + { + var schema = owner._contextSchemas.GetValueOrDefault(state); + var invariants = owner._stateInvariants.GetValueOrDefault(state); + owner._validators[state] = ctx => + { + if (schema is not null) + { + var error = SchemaValidator.Validate(schema, ctx); + if (error is not null) + return error; + } + if (invariants is not null) + foreach (var rule in invariants) + if (!RuleEvaluator.Evaluate(rule, ctx, input: null)) + return "A state requirement was not satisfied."; + return null; + }; + } + public IStateBuilder Committed() { owner._committed.Add(state); diff --git a/src/Trax.Effect.StateMachine/Rules/DeclarativeModel.cs b/src/Trax.Effect.StateMachine/Rules/DeclarativeModel.cs index a7f8768..454405c 100644 --- a/src/Trax.Effect.StateMachine/Rules/DeclarativeModel.cs +++ b/src/Trax.Effect.StateMachine/Rules/DeclarativeModel.cs @@ -23,7 +23,8 @@ public sealed record DeclarativeTransition( public sealed record DeclarativeModel( IReadOnlyDictionary ContextSchemas, IReadOnlyDictionary TriggerInputs, - IReadOnlyList> Transitions + IReadOnlyList> Transitions, + IReadOnlyDictionary StateInvariants ) where TState : struct, Enum where TTrigger : struct, Enum; diff --git a/src/Trax.Effect.StateMachine/Rules/IrExporter.cs b/src/Trax.Effect.StateMachine/Rules/IrExporter.cs index d99182c..99d45dc 100644 --- a/src/Trax.Effect.StateMachine/Rules/IrExporter.cs +++ b/src/Trax.Effect.StateMachine/Rules/IrExporter.cs @@ -47,6 +47,16 @@ public static string Export(BuiltMachine mac ["transitions"] = transitions, }; + // Per-state invariants (the .Requires(...) policy, on top of the context schema). Omitted when a + // machine has none, so a shape-only machine's IR is unchanged. + if (declarative.StateInvariants.Count > 0) + { + var invariants = new JsonObject(); + foreach (var (state, rule) in declarative.StateInvariants) + invariants[state.ToString()!] = WriteRule(rule); + ir["invariants"] = invariants; + } + return CanonicalJson.Serialize(ir); } @@ -166,6 +176,25 @@ private static JsonNode WriteRule(Rule rule) o["value"] = r.Value; return o; } + case Rule.Length r: + { + var o = FieldRule("length", r.Source, r.Field); + o["op"] = OpName(r.Op); + o["value"] = r.Value; + return o; + } + case Rule.BoolEquals r: + { + var o = FieldRule("boolEquals", r.Source, r.Field); + o["value"] = r.Value; + return o; + } + case Rule.ArrayOf r: + { + var o = FieldRule("arrayOf", r.Source, r.Field); + o["type"] = TypeName(r.ElementType); + return o; + } case Rule.All r: return new JsonObject { ["rule"] = "all", ["rules"] = WriteRules(r.Rules) }; case Rule.Any r: diff --git a/src/Trax.Effect.StateMachine/Rules/Rule.cs b/src/Trax.Effect.StateMachine/Rules/Rule.cs index 6350b45..2bdd6ea 100644 --- a/src/Trax.Effect.StateMachine/Rules/Rule.cs +++ b/src/Trax.Effect.StateMachine/Rules/Rule.cs @@ -62,6 +62,15 @@ public sealed record Compare(RuleSource Source, string Field, CompareOp Op, doub /// An array field whose length is compared against a constant. public sealed record Count(RuleSource Source, string Field, CompareOp Op, int Value) : Rule; + /// A string field whose length is compared against a constant. + public sealed record Length(RuleSource Source, string Field, CompareOp Op, int Value) : Rule; + + /// A boolean field equal to a constant. + public sealed record BoolEquals(RuleSource Source, string Field, bool Value) : Rule; + + /// An array field whose every element is of the given JSON type. + public sealed record ArrayOf(RuleSource Source, string Field, JsonFieldType ElementType) : Rule; + /// All sub-rules hold (logical AND). An empty list is vacuously true. public sealed record All(IReadOnlyList Rules) : Rule; diff --git a/src/Trax.Effect.StateMachine/Rules/RuleEvaluator.cs b/src/Trax.Effect.StateMachine/Rules/RuleEvaluator.cs index 509af64..e707ba9 100644 --- a/src/Trax.Effect.StateMachine/Rules/RuleEvaluator.cs +++ b/src/Trax.Effect.StateMachine/Rules/RuleEvaluator.cs @@ -29,6 +29,13 @@ public static bool Evaluate( && Compare(d, r.Op, r.Value), Rule.Count r => Read(r.Source, r.Field, context, input) is JsonArray a && Compare(a.Count, r.Op, r.Value), + Rule.Length r => Read(r.Source, r.Field, context, input) is { } n + && n.GetValueKind() == JsonValueKind.String + && Compare(n.GetValue().Length, r.Op, r.Value), + Rule.BoolEquals r => Read(r.Source, r.Field, context, input) is { } b + && b.GetValueKind() == (r.Value ? JsonValueKind.True : JsonValueKind.False), + Rule.ArrayOf r => Read(r.Source, r.Field, context, input) is JsonArray arr + && arr.All(e => MatchesType(e, r.ElementType)), Rule.All r => r.Rules.All(x => Evaluate(x, context, input, customGuards)), Rule.Any r => r.Rules.Any(x => Evaluate(x, context, input, customGuards)), Rule.Custom r => customGuards is not null diff --git a/src/Trax.Effect.StateMachine/Rules/Rules.cs b/src/Trax.Effect.StateMachine/Rules/Rules.cs index b2e1947..8afbdb6 100644 --- a/src/Trax.Effect.StateMachine/Rules/Rules.cs +++ b/src/Trax.Effect.StateMachine/Rules/Rules.cs @@ -73,6 +73,23 @@ public Rule CountGreaterThan(int value) => /// An array field with at least elements. public Rule CountAtLeast(int value) => new Rule.Count(source, field, CompareOp.GreaterOrEqual, value); + + /// A string field whose length is greater than a constant. + public Rule LengthGreaterThan(int value) => + new Rule.Length(source, field, CompareOp.GreaterThan, value); + + /// A string field whose length is at least a constant. + public Rule LengthAtLeast(int value) => + new Rule.Length(source, field, CompareOp.GreaterOrEqual, value); + + /// A boolean field equal to true. + public Rule IsTrue() => new Rule.BoolEquals(source, field, true); + + /// A boolean field equal to false. + public Rule IsFalse() => new Rule.BoolEquals(source, field, false); + + /// An array field whose every element is of the given JSON type. + public Rule ArrayOf(JsonFieldType type) => new Rule.ArrayOf(source, field, type); } /// Completes a : where the field's new value comes from. diff --git a/src/Trax.Effect.StateMachine/Rules/SchemaReflection.cs b/src/Trax.Effect.StateMachine/Rules/SchemaReflection.cs index 0844378..1445df2 100644 --- a/src/Trax.Effect.StateMachine/Rules/SchemaReflection.cs +++ b/src/Trax.Effect.StateMachine/Rules/SchemaReflection.cs @@ -35,17 +35,52 @@ public static ContextSchema For(Type contextType) return new ContextSchema(fields); } - // Maps validation attributes to declarative constraint rules over the field. Kept small on purpose: - // [MinLength(>=1)] on a string/array is the non-empty constraint the real machines use. More can be - // added as real machines need them. + // Maps a property's type and validation attributes to declarative constraint rules over the field: + // [MinLength(>=1)] -> non-empty, a typed collection -> every element is that JSON type, [AllowedValues] + // -> one of a fixed set (the enum domain). More can be added as real machines need them. private static IReadOnlyList Constraints(PropertyInfo property, string jsonName) { var rules = new List(); if (property.GetCustomAttribute() is { Length: >= 1 }) rules.Add(new Rule.NonEmpty(RuleSource.Context, jsonName)); + if (ArrayElementType(property.PropertyType) is { } element) + rules.Add(new Rule.ArrayOf(RuleSource.Context, jsonName, element)); + if (property.GetCustomAttribute() is { Values.Length: > 0 } allowed) + rules.Add( + new Rule.OneOf( + RuleSource.Context, + jsonName, + allowed.Values.Select(v => v?.ToString() ?? string.Empty).ToArray() + ) + ); return rules; } + // The JSON type of a typed collection's elements (int[] -> Number, string[] -> String), or null when the + // property is not a scalar-element collection (a string, a scalar, or an array of objects). + private static JsonFieldType? ArrayElementType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + if (type == typeof(string) || !typeof(IEnumerable).IsAssignableFrom(type)) + return null; + + var element = + type.IsArray ? type.GetElementType() + : type.IsGenericType ? type.GetGenericArguments().FirstOrDefault() + : null; + if (element is null) + return null; + element = Nullable.GetUnderlyingType(element) ?? element; + + if (element == typeof(string)) + return JsonFieldType.String; + if (element == typeof(bool)) + return JsonFieldType.Boolean; + if (IsNumeric(element)) + return JsonFieldType.Number; + return null; // arrays of objects: the field is Array, but elements aren't further type-checked + } + private static JsonFieldType JsonTypeOf(Type type) { type = Nullable.GetUnderlyingType(type) ?? type; diff --git a/tests/Trax.Effect.StateMachine.Tests/UnitTests/RuleEvaluatorTests.cs b/tests/Trax.Effect.StateMachine.Tests/UnitTests/RuleEvaluatorTests.cs index bdf2784..41aad03 100644 --- a/tests/Trax.Effect.StateMachine.Tests/UnitTests/RuleEvaluatorTests.cs +++ b/tests/Trax.Effect.StateMachine.Tests/UnitTests/RuleEvaluatorTests.cs @@ -214,6 +214,93 @@ public void Count_compares_array_length_and_is_false_for_non_arrays() #endregion + #region Length (string length) + + [Test] + public void Length_compares_string_length_and_is_false_for_non_strings() + { + var ctx = new JsonObject { ["body"] = "123456", ["n"] = 6 }; + + Eval(new Rule.Length(RuleSource.Context, "body", CompareOp.GreaterOrEqual, 6), ctx) + .Should() + .BeTrue(); + Eval(new Rule.Length(RuleSource.Context, "body", CompareOp.GreaterThan, 6), ctx) + .Should() + .BeFalse(); + Eval(new Rule.Length(RuleSource.Context, "body", CompareOp.LessThan, 6), ctx) + .Should() + .BeFalse(); + Eval(new Rule.Length(RuleSource.Context, "n", CompareOp.GreaterOrEqual, 0), ctx) + .Should() + .BeFalse("a number has no string length"); + Eval(new Rule.Length(RuleSource.Context, "missing", CompareOp.GreaterOrEqual, 0), ctx) + .Should() + .BeFalse(); + } + + #endregion + + #region BoolEquals + + [Test] + public void BoolEquals_matches_a_boolean_value_only() + { + var ctx = new JsonObject + { + ["guided"] = true, + ["off"] = false, + ["s"] = "true", + }; + + Eval(new Rule.BoolEquals(RuleSource.Context, "guided", true), ctx).Should().BeTrue(); + Eval(new Rule.BoolEquals(RuleSource.Context, "guided", false), ctx).Should().BeFalse(); + Eval(new Rule.BoolEquals(RuleSource.Context, "off", false), ctx).Should().BeTrue(); + Eval(new Rule.BoolEquals(RuleSource.Context, "s", true), ctx) + .Should() + .BeFalse("the string \"true\" is not the boolean true"); + Eval(new Rule.BoolEquals(RuleSource.Context, "missing", false), ctx) + .Should() + .BeFalse("a missing field is absent, not false"); + } + + #endregion + + #region ArrayOf + + [Test] + public void ArrayOf_requires_every_element_to_match_the_type() + { + var ctx = new JsonObject + { + ["nums"] = new JsonArray(1, 2, 3), + ["strs"] = new JsonArray("a", "b"), + ["mixed"] = new JsonArray(1, "b"), + ["empty"] = new JsonArray(), + ["scalar"] = 5, + }; + + Eval(new Rule.ArrayOf(RuleSource.Context, "nums", JsonFieldType.Number), ctx) + .Should() + .BeTrue(); + Eval(new Rule.ArrayOf(RuleSource.Context, "strs", JsonFieldType.String), ctx) + .Should() + .BeTrue(); + Eval(new Rule.ArrayOf(RuleSource.Context, "empty", JsonFieldType.Number), ctx) + .Should() + .BeTrue("an empty array vacuously satisfies the element type"); + Eval(new Rule.ArrayOf(RuleSource.Context, "mixed", JsonFieldType.Number), ctx) + .Should() + .BeFalse("a string element breaks a number array"); + Eval(new Rule.ArrayOf(RuleSource.Context, "nums", JsonFieldType.String), ctx) + .Should() + .BeFalse(); + Eval(new Rule.ArrayOf(RuleSource.Context, "scalar", JsonFieldType.Number), ctx) + .Should() + .BeFalse("a scalar is not an array"); + } + + #endregion + #region All / Any [Test] diff --git a/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesTests.cs b/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesTests.cs index 487766c..2fb674f 100644 --- a/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesTests.cs +++ b/tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesTests.cs @@ -16,6 +16,7 @@ private sealed record Ctx public string[] Items { get; init; } = []; public int Total { get; init; } public string Name { get; init; } = ""; + public bool Flag { get; init; } } private sealed record In @@ -79,6 +80,31 @@ public void The_array_count_matchers_build_count_rules() .Be(new Rule.Count(RuleSource.Context, "items", CompareOp.GreaterOrEqual, 2)); } + [Test] + public void The_length_bool_and_array_matchers_build_their_rules() + { + Field((Ctx c) => c.Name) + .LengthGreaterThan(5) + .Should() + .Be(new Rule.Length(RuleSource.Context, "name", CompareOp.GreaterThan, 5)); + Field((Ctx c) => c.Name) + .LengthAtLeast(6) + .Should() + .Be(new Rule.Length(RuleSource.Context, "name", CompareOp.GreaterOrEqual, 6)); + Field((Ctx c) => c.Flag) + .IsTrue() + .Should() + .Be(new Rule.BoolEquals(RuleSource.Context, "flag", true)); + Field((Ctx c) => c.Flag) + .IsFalse() + .Should() + .Be(new Rule.BoolEquals(RuleSource.Context, "flag", false)); + Field((Ctx c) => c.Items) + .ArrayOf(JsonFieldType.String) + .Should() + .Be(new Rule.ArrayOf(RuleSource.Context, "items", JsonFieldType.String)); + } + [Test] public void All_and_Any_combine_subrules() { diff --git a/tests/Trax.Effect.StateMachine.Tests/UnitTests/StateInvariantTests.cs b/tests/Trax.Effect.StateMachine.Tests/UnitTests/StateInvariantTests.cs new file mode 100644 index 0000000..ef90d97 --- /dev/null +++ b/tests/Trax.Effect.StateMachine.Tests/UnitTests/StateInvariantTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using static Trax.Effect.StateMachine.Rules; + +namespace Trax.Effect.StateMachine.Tests.UnitTests; + +/// +/// A state's declarative validator is its context schema AND its .Requires(...) policy, composed. This +/// is what lets a state demand more than its shape (a complete draft) while sharing one context record across +/// states, and it is what the IR carries as the per-state invariants block. +/// +public class StateInvariantTests +{ + private enum S + { + Draft, + Done, + } + + private enum T + { + Finish, + } + + private sealed record DraftContext + { + public string Body { get; init; } = ""; + public bool Guided { get; init; } + public int[] Ids { get; init; } = []; + } + + private static JsonObject Fresh() => + new() + { + ["body"] = "", + ["guided"] = false, + ["ids"] = new JsonArray(), + }; + + private static BuiltMachine Build() + { + var m = new MachineBuilder(); + m.Id("inv").StartsAt(S.Draft, Fresh); + m.In(S.Draft).Context().On(T.Finish).To(S.Done); + m.In(S.Done) + .Context() + .Requires(Field((DraftContext d) => d.Body).LengthAtLeast(6)) + .Requires(Field((DraftContext d) => d.Guided).IsTrue()); + return m.Build(); + } + + private static JsonObject DoneContext(string body, bool guided) => + new() + { + ["body"] = body, + ["guided"] = guided, + ["ids"] = new JsonArray(), + }; + + [Test] + public void A_requirement_composes_with_the_schema_in_the_state_validator() + { + var validate = Build().Definition.ContextValidators[S.Done]; + + validate(DoneContext("123456", guided: true)) + .Should() + .BeNull("a complete, guided draft is valid in Done"); + validate(DoneContext("short", guided: true)) + .Should() + .NotBeNull("Done requires a body of length >= 6"); + validate(DoneContext("123456", guided: false)) + .Should() + .NotBeNull("Done requires guided = true"); + + var wrongType = DoneContext("123456", guided: true); + wrongType["body"] = 5; + validate(wrongType) + .Should() + .NotBeNull("the schema is still enforced: body must be a string"); + } + + [Test] + public void Requirements_export_as_a_per_state_invariants_block() + { + var ir = IrExporter.Export(Build()); + + ir.Should() + .Contain("\"invariants\"") + .And.Contain("\"Done\"") + .And.Contain("\"length\"") + .And.Contain("\"boolEquals\"") + .And.Contain("\"arrayOf\"", "the int[] field's element type is a schema constraint"); + // A shape-only machine emits no invariants block. + DeclarativeTurnstileIrHasNoInvariants(); + } + + private static void DeclarativeTurnstileIrHasNoInvariants() => + IrExporter + .Export(Fakes.DeclarativeTurnstile.Built) + .Should() + .NotContain("\"invariants\"", "turnstile has no .Requires policy"); +} diff --git a/tests/Trax.Effect.StateMachine.Tests/UnitTests/TypedSchemaTests.cs b/tests/Trax.Effect.StateMachine.Tests/UnitTests/TypedSchemaTests.cs index 2923da8..617ba81 100644 --- a/tests/Trax.Effect.StateMachine.Tests/UnitTests/TypedSchemaTests.cs +++ b/tests/Trax.Effect.StateMachine.Tests/UnitTests/TypedSchemaTests.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using FluentAssertions; namespace Trax.Effect.StateMachine.Tests.UnitTests; @@ -106,5 +107,53 @@ public void SchemaReflection_maps_a_complex_property_to_object_and_a_nullable_va limit.Nullable.Should().BeTrue("int? is a nullable value type"); } + private sealed record TypedCollections + { + public int[] Ids { get; init; } = []; + public List Tags { get; init; } = []; + public bool[] Flags { get; init; } = []; + public object[] Things { get; init; } = []; + public System.Collections.ArrayList Raw { get; init; } = []; + + [AllowedValues("federal", "state", "unsure")] + public string Legislature { get; init; } = "federal"; + } + + [Test] + public void SchemaReflection_derives_array_element_and_allowed_value_constraints() + { + var schema = SchemaReflection.For(); + Rule.ArrayOf? ArrayConstraint(string name) => + schema + .Fields.Single(f => f.Name == name) + .Constraints.OfType() + .SingleOrDefault(); + + ArrayConstraint("ids") + .Should() + .Be(new Rule.ArrayOf(RuleSource.Context, "ids", JsonFieldType.Number)); + ArrayConstraint("tags") + .Should() + .Be(new Rule.ArrayOf(RuleSource.Context, "tags", JsonFieldType.String)); + ArrayConstraint("flags") + .Should() + .Be(new Rule.ArrayOf(RuleSource.Context, "flags", JsonFieldType.Boolean)); + + // Element types that are not string/bool/number get no ArrayOf: an array of objects, and a + // non-generic collection whose element type can't be read. + ArrayConstraint("things") + .Should() + .BeNull("an array of objects has no element constraint"); + ArrayConstraint("raw") + .Should() + .BeNull("a non-generic collection has no element constraint"); + + var legislature = schema + .Fields.Single(f => f.Name == "legislature") + .Constraints.OfType() + .Single(); + legislature.Values.Should().Equal("federal", "state", "unsure"); + } + #endregion }