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
49 changes: 47 additions & 2 deletions src/Trax.Effect.StateMachine/Fluent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ public interface IStateBuilder<TState, TTrigger>
/// <summary>Declare that this state carries no context (an empty schema).</summary>
IStateBuilder<TState, TTrigger> Context();

/// <summary>
/// Add a per-state context requirement, composed (ANDed) with the schema from
/// <see cref="Context{TContext}"/> and with any other requirements. The declarative, exportable peer of a
/// policy check in <see cref="Holds"/>: use it for what a state demands beyond its shape (a complete draft,
/// an absent receipt). Chain several; each is one small rule.
/// </summary>
IStateBuilder<TState, TTrigger> Requires(Rule constraint);

/// <summary>Mark this state committed: a soft autosave may not move a draft out of it (the guarded path).</summary>
IStateBuilder<TState, TTrigger> Committed();

Expand Down Expand Up @@ -138,6 +146,7 @@ public sealed class MachineBuilder<TState, TTrigger> : IMachineBuilder<TState, T
private readonly Dictionary<int, Func<string, JsonObject, MigrationResult>> _migrations = [];
private readonly Dictionary<TState, ContextSchema> _contextSchemas = [];
private readonly Dictionary<TTrigger, ContextSchema> _triggerInputs = [];
private readonly Dictionary<TState, List<Rule>> _stateInvariants = [];
private readonly List<DeclarativeTransition<TState, TTrigger>> _declarativeTransitions = [];
private bool _usedDeclarative;

Expand Down Expand Up @@ -198,7 +207,11 @@ public BuiltMachine<TState, TTrigger> Build()
? new DeclarativeModel<TState, TTrigger>(
_contextSchemas,
_triggerInputs,
_declarativeTransitions
_declarativeTransitions,
_stateInvariants.ToDictionary(
kv => kv.Key,
kv => kv.Value.Count == 1 ? kv.Value[0] : (Rule)new Rule.All(kv.Value)
)
)
: null;

Expand All @@ -223,10 +236,42 @@ private IStateBuilder<TState, TTrigger> SetSchema(ContextSchema schema)
{
owner._usedDeclarative = true;
owner._contextSchemas[state] = schema;
owner._validators[state] = ctx => SchemaValidator.Validate(schema, ctx);
RebuildValidator();
return this;
}

public IStateBuilder<TState, TTrigger> 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<TState, TTrigger> Committed()
{
owner._committed.Add(state);
Expand Down
3 changes: 2 additions & 1 deletion src/Trax.Effect.StateMachine/Rules/DeclarativeModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public sealed record DeclarativeTransition<TState, TTrigger>(
public sealed record DeclarativeModel<TState, TTrigger>(
IReadOnlyDictionary<TState, ContextSchema> ContextSchemas,
IReadOnlyDictionary<TTrigger, ContextSchema> TriggerInputs,
IReadOnlyList<DeclarativeTransition<TState, TTrigger>> Transitions
IReadOnlyList<DeclarativeTransition<TState, TTrigger>> Transitions,
IReadOnlyDictionary<TState, Rule> StateInvariants
)
where TState : struct, Enum
where TTrigger : struct, Enum;
29 changes: 29 additions & 0 deletions src/Trax.Effect.StateMachine/Rules/IrExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ public static string Export<TState, TTrigger>(BuiltMachine<TState, TTrigger> 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);
}

Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/Trax.Effect.StateMachine/Rules/Rule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ public sealed record Compare(RuleSource Source, string Field, CompareOp Op, doub
/// <summary>An array field whose length is compared against a constant.</summary>
public sealed record Count(RuleSource Source, string Field, CompareOp Op, int Value) : Rule;

/// <summary>A string field whose length is compared against a constant.</summary>
public sealed record Length(RuleSource Source, string Field, CompareOp Op, int Value) : Rule;

/// <summary>A boolean field equal to a constant.</summary>
public sealed record BoolEquals(RuleSource Source, string Field, bool Value) : Rule;

/// <summary>An array field whose every element is of the given JSON type.</summary>
public sealed record ArrayOf(RuleSource Source, string Field, JsonFieldType ElementType) : Rule;

/// <summary>All sub-rules hold (logical AND). An empty list is vacuously true.</summary>
public sealed record All(IReadOnlyList<Rule> Rules) : Rule;

Expand Down
7 changes: 7 additions & 0 deletions src/Trax.Effect.StateMachine/Rules/RuleEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>().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
Expand Down
17 changes: 17 additions & 0 deletions src/Trax.Effect.StateMachine/Rules/Rules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ public Rule CountGreaterThan(int value) =>
/// <summary>An array field with at least <paramref name="value"/> elements.</summary>
public Rule CountAtLeast(int value) =>
new Rule.Count(source, field, CompareOp.GreaterOrEqual, value);

/// <summary>A string field whose length is greater than a constant.</summary>
public Rule LengthGreaterThan(int value) =>
new Rule.Length(source, field, CompareOp.GreaterThan, value);

/// <summary>A string field whose length is at least a constant.</summary>
public Rule LengthAtLeast(int value) =>
new Rule.Length(source, field, CompareOp.GreaterOrEqual, value);

/// <summary>A boolean field equal to <c>true</c>.</summary>
public Rule IsTrue() => new Rule.BoolEquals(source, field, true);

/// <summary>A boolean field equal to <c>false</c>.</summary>
public Rule IsFalse() => new Rule.BoolEquals(source, field, false);

/// <summary>An array field whose every element is of the given JSON type.</summary>
public Rule ArrayOf(JsonFieldType type) => new Rule.ArrayOf(source, field, type);
}

/// <summary>Completes a <see cref="Rules.Set{TContext,TField}"/>: where the field's new value comes from.</summary>
Expand Down
41 changes: 38 additions & 3 deletions src/Trax.Effect.StateMachine/Rules/SchemaReflection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Rule> Constraints(PropertyInfo property, string jsonName)
{
var rules = new List<Rule>();
if (property.GetCustomAttribute<MinLengthAttribute>() 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<AllowedValuesAttribute>() 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
26 changes: 26 additions & 0 deletions tests/Trax.Effect.StateMachine.Tests/UnitTests/RulesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
{
Expand Down
Loading
Loading