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
3 changes: 2 additions & 1 deletion Documentation/architecture/guarded-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ A comparison contains `kind: "comparison"`, a `left` subject reference, an `oper
- Equality is type-strict. Missing fields never match. A **present null** matches a null literal with `Equal`; `NotEqual` involving null is false.
- Ordering requires numeric values; strings are not coerced to numbers.
- `Contains` supports a string substring or scalar array membership; `StartsWith` requires strings. String matching is case-sensitive. Opt-in strict operands narrow `Contains` to strings.
- An optional `enumeration` lists member names in ordinal order. A subject ordinal or member name resolves to the member name. Enum names compare case-insensitively; unknown names and out-of-range ordinals do not match.
- An optional `enumeration` lists member names in declaration order. An optional `enumerationNumbers` supplies the declared whole-number value for each entry, for example `"enumeration": ["open", "closed"], "enumerationNumbers": [1, 0]`. Both numeric subject values and numeric literals resolve through these numbers to the member name. Without `enumerationNumbers`, numbers resolve by position as before. String member names compare case-insensitively; unknown names and numbers do not match, including for `NotEqual`.
- When present, `enumerationNumbers` must be an array of distinct whole numbers with the same length as `enumeration`; otherwise the condition is unsupported, just like an invalid `enumeration`.
- A logical node is `{"kind":"logical","operator":"And","left":condition,"right":condition}`, or uses `Or`. Both branches must be understood, even if one branch would determine the answer.

Unknown operators, node kinds and legacy string guards fail closed with `SCENE-GUARD-001`. A guarded action renders a disabled button and diagnostic rather than using its fallback. There is no JavaScript expression execution.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ void should_evaluate_a_typed_enum_comparison()
GuardConditions.Evaluate(condition, new { status = 1 }).ShouldEqual(true);
}

[Fact]
void should_evaluate_a_typed_enum_comparison_with_declared_numbers()
{
var condition = new ComparisonGuardCondition(new("item.status"), GuardOperator.Equal, "open", ["open", "closed"])
{
EnumerationNumbers = [10, 20]
};
GuardConditions.Evaluate(condition, new { status = 10 }).ShouldEqual(true);
}

[Fact]
void should_evaluate_every_shared_predicate()
{
Expand Down
26 changes: 19 additions & 7 deletions Source/DotNET/Engine/Guards/GuardConditions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,27 @@ static bool ValidComparison(JsonElement condition, string operation)
if (!ValidPath(Text(Get(Get(condition, "left"), "subject")))) return false;
if (!Enum.TryParse<GuardOperator>(operation, out var parsed)) return false;
if (!Enum.IsDefined(parsed) || operation != parsed.ToString()) return false;
return ValidEnumeration(Get(condition, "enumeration")) && ValidLiteral(parsed, Get(condition, "right"));
return ValidEnumeration(Get(condition, "enumeration"), Get(condition, "enumerationNumbers")) && ValidLiteral(parsed, Get(condition, "right"));
}

static bool ValidEnumeration(JsonElement value)
static bool ValidEnumeration(JsonElement value, JsonElement enumerationNumbers)
{
if (enumerationNumbers.ValueKind != JsonValueKind.Undefined && !ValidEnumerationNumbers(enumerationNumbers, value)) return false;
if (value.ValueKind == JsonValueKind.Undefined) return true;
if (value.ValueKind != JsonValueKind.Array || value.GetArrayLength() == 0) return false;
if (value.EnumerateArray().Any(member => member.ValueKind != JsonValueKind.String)) return false;
return value.EnumerateArray().Select(member => member.GetString()).Distinct().Count() == value.GetArrayLength();
}

static bool ValidEnumerationNumbers(JsonElement value, JsonElement enumeration)
{
if (value.ValueKind != JsonValueKind.Array || enumeration.ValueKind != JsonValueKind.Array) return false;
if (value.GetArrayLength() != enumeration.GetArrayLength() || !value.EnumerateArray().All(WholeNumber)) return false;
return value.EnumerateArray().Select(member => member.GetDouble()).Distinct().Count() == value.GetArrayLength();
}

static bool WholeNumber(JsonElement value) => value.ValueKind == JsonValueKind.Number && value.TryGetDouble(out var number) && double.IsFinite(number) && number == Math.Truncate(number);

static bool ValidLiteral(GuardOperator operation, JsonElement right)
{
if (!Scalar(right)) return false;
Expand All @@ -90,8 +100,9 @@ static JsonElement Resolve(JsonElement reference, JsonElement item)
static bool Compare(JsonElement condition, JsonElement item, string operation)
{
var enumeration = Get(condition, "enumeration");
var left = Normalize(Resolve(Get(condition, "left"), item), enumeration);
var right = Normalize(Get(condition, "right"), enumeration);
var enumerationNumbers = Get(condition, "enumerationNumbers");
var left = Normalize(Resolve(Get(condition, "left"), item), enumeration, enumerationNumbers);
var right = Normalize(Get(condition, "right"), enumeration, enumerationNumbers);
if (left.ValueKind == JsonValueKind.Undefined || right.ValueKind == JsonValueKind.Undefined) return false;
if (left.ValueKind == JsonValueKind.Null || right.ValueKind == JsonValueKind.Null) return operation == "Equal" && left.ValueKind == JsonValueKind.Null && right.ValueKind == JsonValueKind.Null;
return CompareValues(left, right, operation);
Expand All @@ -106,19 +117,20 @@ static bool Compare(JsonElement condition, JsonElement item, string operation)
_ => Numeric(left, right, operation)
};

static JsonElement Normalize(JsonElement value, JsonElement enumeration)
static JsonElement Normalize(JsonElement value, JsonElement enumeration, JsonElement enumerationNumbers)
{
if (enumeration.ValueKind != JsonValueKind.Array || value.ValueKind == JsonValueKind.Null) return value;
if (Ordinal(value, enumeration.GetArrayLength(), out var ordinal)) return enumeration[ordinal];
if (Ordinal(value, enumerationNumbers, enumeration.GetArrayLength(), out var ordinal)) return enumeration[ordinal];
if (value.ValueKind == JsonValueKind.String) return enumeration.EnumerateArray().FirstOrDefault(member => string.Equals(member.GetString(), value.GetString(), StringComparison.OrdinalIgnoreCase));
return default;
}

static bool Ordinal(JsonElement value, int length, out int ordinal)
static bool Ordinal(JsonElement value, JsonElement enumerationNumbers, int length, out int ordinal)
{
ordinal = 0;
if (value.ValueKind != JsonValueKind.Number) return false;
var number = value.GetDouble();
if (enumerationNumbers.ValueKind == JsonValueKind.Array) number = enumerationNumbers.EnumerateArray().Select(member => member.GetDouble()).ToList().IndexOf(number);
if (number != Math.Truncate(number)) return false;
if (number < 0 || number >= length) return false;
ordinal = (int)number;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Cratis.Scene.Model.for_GuardedActionProperties;
public class when_round_tripping_stage_properties : Specification
{
const string StageProperties = """
{"label":"Approve","alternatives":[{"command":"Approve","condition":{"kind":"logical","operator":"And","left":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Pending","enumeration":["Pending","Approved"]},"right":{"kind":"comparison","left":{"subject":"item.amount"},"operator":"GreaterThan","right":0}},"arguments":{"id":{"subject":"item.id"}}}],"otherwise":{"outcome":"Hidden"},"navigateToScreen":"detail","navigateByParameter":"id"}
{"label":"Approve","alternatives":[{"command":"Approve","condition":{"kind":"logical","operator":"And","left":{"kind":"comparison","left":{"subject":"item.status"},"operator":"Equal","right":"Pending","enumeration":["Pending","Approved"],"enumerationNumbers":[10,20]},"right":{"kind":"comparison","left":{"subject":"item.amount"},"operator":"GreaterThan","right":0}},"arguments":{"id":{"subject":"item.id"}}}],"otherwise":{"outcome":"Hidden"},"navigateToScreen":"detail","navigateByParameter":"id"}
""";
readonly JsonSerializerOptions _options = new(JsonSerializerDefaults.Web);
GuardedActionProperties _properties = null!;
Expand Down Expand Up @@ -53,10 +53,24 @@ void should_write_canonical_guard_enumeration_values()
json.GetProperty("otherwise").GetProperty("outcome").GetString().ShouldEqual("Execute");
}

[Fact]
void should_preserve_the_four_member_constructor_and_deconstruction()
{
var (left, operation, right, enumeration) = new ComparisonGuardCondition(new("item.status"), GuardOperator.Equal, "Pending", ["Pending", "Approved"])
{
EnumerationNumbers = [10, 20]
};
left.Subject.ShouldEqual("item.status");
operation.ShouldEqual(GuardOperator.Equal);
right.ShouldEqual("Pending");
enumeration.ShouldEqual("Pending", "Approved");
}

[Fact] void should_preserve_the_command() => _roundTrip.Alternatives[0].Command.ShouldEqual("Approve");
[Fact] void should_accept_stage_properties_without_a_subject_binding() => _roundTrip.Item.ShouldBeNull();
[Fact] void should_preserve_explicit_arguments() => _roundTrip.Alternatives[0].Arguments!["id"].Subject.ShouldEqual("item.id");
[Fact] void should_preserve_the_nested_guard() => _roundTrip.Alternatives[0].Condition.ShouldBeOfExactType<LogicalGuardCondition>();
[Fact] void should_preserve_declared_enumeration_numbers() => ((ComparisonGuardCondition)((LogicalGuardCondition)_roundTrip.Alternatives[0].Condition).Left).EnumerationNumbers.ShouldEqual(10d, 20d);
[Fact] void should_preserve_interaction_branches() => _interaction.GetProperty("alternatives").GetArrayLength().ShouldEqual(1);
[Fact] void should_preserve_an_empty_fallback() => _interaction.GetProperty("otherwise").GetArrayLength().ShouldEqual(0);
}
11 changes: 9 additions & 2 deletions Source/DotNET/Model/Guards/ComparisonGuardCondition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@ namespace Cratis.Scene.Model.Guards;
/// <param name="Left">The subject field.</param>
/// <param name="Operator">The comparison.</param>
/// <param name="Right">A string, number, boolean or null literal.</param>
/// <param name="Enumeration">The member names, in ordinal order.</param>
/// <param name="Enumeration">The member names, in declaration order.</param>
public record ComparisonGuardCondition(
SubjectReference Left,
GuardOperator Operator,
[property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] object? Right,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<string>? Enumeration = null) : GuardCondition;
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<string>? Enumeration = null) : GuardCondition
{
/// <summary>
/// Gets the distinct whole-number value for each member; absent means positional numbering.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public IReadOnlyList<double>? EnumerationNumbers { get; init; }
}
21 changes: 14 additions & 7 deletions Source/JavaScript/engine/guards/evaluateGuardCondition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,18 @@ export function resolveSubjectReference(reference: SubjectReference, item: unkno
return value;
}

function validEnumeration(value: unknown): boolean {
function validEnumeration(value: unknown, enumerationNumbers: unknown): boolean {
if (enumerationNumbers !== undefined && !validEnumerationNumbers(enumerationNumbers, value)) return false;
if (value === undefined) return true;
if (!Array.isArray(value) || value.length === 0) return false;
return value.every(member => typeof member === 'string') && new Set(value).size === value.length;
}

function validEnumerationNumbers(value: unknown, enumeration: unknown): boolean {
if (!Array.isArray(value) || !Array.isArray(enumeration)) return false;
return value.length === enumeration.length && value.every(Number.isInteger) && new Set(value).size === value.length;
}

function strictContains(operator: unknown, options?: GuardEvaluationOptions): boolean {
return operator === 'Contains' && options?.strictOperands === true;
}
Expand All @@ -85,7 +91,7 @@ function validComparison(condition: Record<string, unknown>, options?: GuardEval
if (!subjectPath(condition.left)) return false;
if (typeof condition.operator !== 'string') return false;
if (!Object.hasOwn(comparisons, condition.operator)) return false;
return validEnumeration(condition.enumeration) && validLiteral(condition.operator, condition.right, options);
return validEnumeration(condition.enumeration, condition.enumerationNumbers) && validLiteral(condition.operator, condition.right, options);
}

export function canEvaluateGuard(condition: unknown, options?: GuardEvaluationOptions): boolean {
Expand All @@ -101,9 +107,10 @@ function ordinal(value: unknown, length: number): value is number {
return value >= 0 && value < length;
}

function normalize(value: unknown, enumeration: unknown): unknown {
function normalize(value: unknown, enumeration: unknown, enumerationNumbers: unknown): unknown {
if (!Array.isArray(enumeration) || value === null) return value;
if (ordinal(value, enumeration.length)) return enumeration[value];
const index = typeof value === 'number' && Array.isArray(enumerationNumbers) ? enumerationNumbers.indexOf(value) : value;
if (ordinal(index, enumeration.length)) return enumeration[index];
if (typeof value === 'string') return enumeration.find(member => member.toLowerCase() === value.toLowerCase());
return undefined;
}
Expand All @@ -115,10 +122,10 @@ function unmatchedOperands(left: unknown, right: unknown): boolean {
function compare(condition: Record<string, unknown>, item: unknown, options?: GuardEvaluationOptions): boolean | undefined {
const value = resolveSubjectReference(condition.left as SubjectReference, item);
if (value === undefined) return false;
if (value === null) return condition.operator === 'Equal' && value === normalize(condition.right, condition.enumeration);
if (value === null) return condition.operator === 'Equal' && value === normalize(condition.right, condition.enumeration, condition.enumerationNumbers);
if (strictContains(condition.operator, options) && !stringOperands(value, condition.right)) return undefined;
const right = normalize(condition.right, condition.enumeration);
const left = normalize(value, condition.enumeration);
const right = normalize(condition.right, condition.enumeration, condition.enumerationNumbers);
const left = normalize(value, condition.enumeration, condition.enumerationNumbers);
if (unmatchedOperands(left, right)) return false;
return comparisons[condition.operator as string](left, right);
}
Expand Down
3 changes: 2 additions & 1 deletion Source/JavaScript/model/guards/ComparisonGuardCondition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface ComparisonGuardCondition {
operator: GuardOperator;
right: string | number | boolean | null;
enumeration?: string[];
enumerationNumbers?: number[];
}

export const ComparisonGuardConditionPropertyNames: (keyof ComparisonGuardCondition)[] = ['left', 'operator', 'right', 'enumeration'];
export const ComparisonGuardConditionPropertyNames: (keyof ComparisonGuardCondition)[] = ['left', 'operator', 'right', 'enumeration', 'enumerationNumbers'];
Loading