Skip to content
Open
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
16 changes: 16 additions & 0 deletions Documentation/screenplay/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Declarative validation covers the common cases without code:
| `matches <regex>` | `email matches email` |
| `matches "<pattern>"` | `invoiceNumber matches "^INV-[0-9]{6}$"` |
| `all > <value>` (on collection) | `lines.quantity all > 0` |
| `rule <Name>` | `orgNumber rule BeAValidOrganizationNumber` |

Every rule carries a `message` shown when it fails:

Expand All @@ -87,6 +88,21 @@ validate
dueDate > today message "Due date must be in the future"
```

### Rules whose logic is not expressible

Not every rule is a comparison. A predicate — "is this a valid organization number", "is this still available" — has logic that lives in code, and the declarative shapes above cannot express it.

Leaving it out is the worst option, because it makes the document lie: a property with two declarative rules and three predicates reads as a property with two rules, and a reader cannot tell "nothing further constrains this" from "the rest could not be written down". Name the rule instead:

```screenplay
validate
orgNumber not empty message "Organization number is required"
orgNumber rule BeAValidOrganizationNumber message "Must be a valid organization number"
orgNumber rule BeUnique
```

The name is a reference into the implementation, not a declared construct — nothing resolves it, and the compiler does not check that anything called `BeAValidOrganizationNumber` exists. It is there so the document is honest about how constrained a value is, and so a reader has something more useful than "a rule was omitted".

Cross-field or complex rules drop into C#:

````screenplay
Expand Down
3 changes: 2 additions & 1 deletion Documentation/screenplay/grammar.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,8 @@ RuleOp = "not empty"
| "length", "==", Number
| "matches", ( "email" | StringLiteral )
| "all", ">", Value
| "all", ">=", Value ;
| "all", ">=", Value
| "rule", Ident ;

Value = Number | StringLiteral | "today" | "true" | "false" ;

Expand Down
33 changes: 32 additions & 1 deletion Source/DotNET/Screenplay/Parsing/ValidationRuleParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ internal static partial class ValidationRuleParser
return (null, null);
}

if (operand.Groups[1].Value == "rule")
{
return ParseNamedRule(context, operand.Groups[2].Value.Trim(), line);
}

var value = ExpressionParser.ParseMappingSource(operand.Groups[2].Value, line.Location);
ValidationRuleKind? kind = operand.Groups[1].Value switch
{
Expand All @@ -110,12 +115,38 @@ internal static partial class ValidationRuleParser
return (kind, value);
}

/// <summary>
/// Parses a <c>rule &lt;Name&gt;</c> - a named predicate whose logic the document does not express.
/// </summary>
/// <param name="context">The <see cref="ParserContext"/> to report diagnostics to.</param>
/// <param name="name">The predicate name.</param>
/// <param name="line">The <see cref="SourceLine"/> the rule came from.</param>
/// <returns>The rule kind and the name, or <c>null</c> when the name is not an identifier.</returns>
/// <remarks>
/// The name is a reference into the implementation, not a declared entity - nothing resolves it. It is
/// there so a reader can tell that a constraint exists and what it is called, rather than seeing a
/// property that appears to carry no further rules.
/// </remarks>
static (ValidationRuleKind? Kind, ExpressionSyntax? Value) ParseNamedRule(ParserContext context, string name, SourceLine line)
{
if (!NameRegex().IsMatch(name))
{
context.Error($"Invalid rule name '{name}' in '{line.Content}' - expected 'rule <Name>' with an identifier", line.Location);
return (null, null);
}

return (ValidationRuleKind.Rule, new PathExpressionSyntax(name, line.Location));
}

[GeneratedRegex("\\bmessage\\s+(?:\"(" + StringLiteral.BodyPattern + ")\"|(\\$strings\\.\\w+(?:\\.\\w+)*))$", RegexOptions.None, 1000)]
private static partial Regex MessageRegex();

[GeneratedRegex(@"^([\w.]+)\s+(.+)$", RegexOptions.None, 1000)]
private static partial Regex RuleRegex();

[GeneratedRegex(@"^(not empty|length ==|all >=|all >|matches|max|min|>=|<=|==|>|<)\s*(.*)$", RegexOptions.None, 1000)]
[GeneratedRegex(@"^(not empty|length ==|all >=|all >|matches|max|min|rule|>=|<=|==|>|<)\s*(.*)$", RegexOptions.None, 1000)]
private static partial Regex OperandRegex();

[GeneratedRegex(@"^[A-Za-z_]\w*$", RegexOptions.None, 1000)]
private static partial Regex NameRegex();
}
1 change: 1 addition & 0 deletions Source/DotNET/Screenplay/Printing/ScreenplaySyntaxText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ static string ValidationRuleBody(ValidationRuleSyntax rule)
ValidationRuleKind.Matches => $"matches {value}",
ValidationRuleKind.AllGreaterThan => $"all > {value}",
ValidationRuleKind.AllGreaterThanOrEqual => $"all >= {value}",
ValidationRuleKind.Rule => $"rule {value}",
_ => value
};
}
Expand Down
7 changes: 6 additions & 1 deletion Source/DotNET/Screenplay/Syntax/CommandSyntax.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ public enum ValidationRuleKind
/// <summary>
/// Every element of a collection must be greater than or equal to the operand.
/// </summary>
AllGreaterThanOrEqual = 11
AllGreaterThanOrEqual = 11,

/// <summary>
/// The value must satisfy a named predicate whose logic lives outside the document.
/// </summary>
Rule = 12
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ module Invoicing
lines.unitPrice all >= 0 message "Line prices cannot be negative"
currency length == 3 message "Currency must be a 3-letter ISO code"
dueDate > today message "Due date must be in the future"
invoiceNumber rule BeUnusedInvoiceNumber message "Invoice number is already in use"

validate csharp
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Syntax;

namespace Cratis.Screenplay.for_ScreenplayCompiler;

public class when_compiling_a_named_predicate_rule : given.a_compiler
{
const string Source =
"""
module Customers
feature Approval
slice StateChange ApproveCustomer
command ApproveCustomer
orgNumber String

validate
orgNumber not empty message "Organization number is required"
orgNumber rule BeAValidOrganizationNumber message $strings.customers.orgNumberRequired
orgNumber rule BeUnique
""";

CompilationResult<ApplicationSyntax> _result;
IEnumerable<ValidationRuleSyntax> _rules;

void Because()
{
_result = _compiler.Compile(Source);
_rules = _result.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single()
.Validations.OfType<DeclarativeValidateSyntax>().Single().Rules;
}

[Fact] void should_succeed() => _result.Success.ShouldBeTrue();
[Fact] void should_have_no_diagnostics() => _result.Diagnostics.ShouldBeEmpty();
[Fact] void should_keep_every_rule() => _rules.Count().ShouldEqual(3);
[Fact] void should_recognize_the_named_rule() => Rule(1).Rule.ShouldEqual(ValidationRuleKind.Rule);
[Fact] void should_carry_the_predicate_name() => ((PathExpressionSyntax)Rule(1).Value!).Path.ShouldEqual("BeAValidOrganizationNumber");
[Fact] void should_keep_the_localized_message() => Rule(1).Message.ShouldEqual("$strings.customers.orgNumberRequired");
[Fact] void should_allow_a_named_rule_without_a_message() => Rule(2).Message.ShouldBeNull();
[Fact] void should_keep_the_subject_of_the_named_rule() => Rule(2).Property.ShouldEqual("orgNumber");

ValidationRuleSyntax Rule(int index) => _rules.ElementAt(index);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Diagnostics;
using Cratis.Screenplay.Syntax;

namespace Cratis.Screenplay.for_ScreenplayCompiler;

public class when_compiling_a_named_predicate_rule_without_a_name : given.a_compiler
{
const string Source =
"""
module Customers
feature Approval
slice StateChange ApproveCustomer
command ApproveCustomer
orgNumber String

validate
orgNumber rule
orgNumber rule "not an identifier"
""";

CompilationResult<ApplicationSyntax> _result;

void Because() => _result = _compiler.Compile(Source);

[Fact] void should_not_succeed() => _result.Success.ShouldBeFalse();
[Fact] void should_report_both_rules() => _result.Diagnostics.Count().ShouldEqual(2);
[Fact] void should_report_them_as_errors() => _result.Diagnostics.All(_ => _.Severity == DiagnosticSeverity.Error).ShouldBeTrue();
[Fact] void should_not_keep_any_rule() => _result.Value!.Modules.Single().Features.Single().Slices.Single()
.Commands.Single().Validations.OfType<DeclarativeValidateSyntax>().Single().Rules.ShouldBeEmpty();
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ void Because()
[Fact] void should_parse_the_event_tags() => RegisteredEvent.Tags!.Count().ShouldEqual(3);
[Fact] void should_parse_the_static_event_tags() => RegisteredEvent.Tags!.Take(2).Select(_ => ((LiteralExpressionSyntax)_.Value).Value).ShouldContainOnly("invoicing", "billing");
[Fact] void should_parse_the_dynamic_event_tag() => ((ContextExpressionSyntax)RegisteredEvent.Tags!.Last().Value).Path.ShouldEqual("identity.id");
[Fact] void should_parse_the_validation_rules() => RegisterCommand.Validations.OfType<DeclarativeValidateSyntax>().Single().Rules.Count().ShouldEqual(7);
[Fact] void should_parse_the_named_predicate_rule() => RegisterCommand.Validations.OfType<DeclarativeValidateSyntax>().Single().Rules.Count(_ => _.Rule == ValidationRuleKind.Rule).ShouldEqual(1);
[Fact] void should_parse_the_validation_rules() => RegisterCommand.Validations.OfType<DeclarativeValidateSyntax>().Single().Rules.Count().ShouldEqual(8);
[Fact] void should_parse_the_code_validation() => RegisterCommand.Validations.OfType<CodeValidateSyntax>().Count().ShouldEqual(1);
[Fact] void should_parse_the_authorize_policies() => RegisterCommand.Authorize!.Policies.Select(_ => _.Name).ShouldContainOnly("CanManageInvoice", "IsAdultCustomer");
[Fact] void should_parse_the_concurrency_event_source() => RegisterCommand.Concurrency!.EventSource.ShouldBeTrue();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Cratis.Screenplay.Syntax;

namespace Cratis.Screenplay.for_ScreenplayPrinter;

public class when_printing_a_named_predicate_rule : given.a_printer
{
const string Source =
"""
concept OrganizationNumber : String
validate
rule BeAValidOrganizationNumber message "Must be a valid organization number"

module Customers
feature Approval
slice StateChange ApproveCustomer
command ApproveCustomer
orgNumber String

validate
orgNumber not empty
orgNumber rule BeAValidOrganizationNumber message $strings.customers.orgNumberRequired
orgNumber rule BeUnique
""";

given.a_printer.RoundTripResult _roundtrip;

void Because() => _roundtrip = RoundTrip(Source);

[Fact] void should_compile_without_diagnostics() => _roundtrip.Original!.Diagnostics.ShouldBeEmpty();
[Fact] void should_reparse_without_diagnostics() => _roundtrip.Reparsed.Diagnostics.ShouldBeEmpty();
[Fact] void should_print_the_same_text_on_a_second_pass() => _roundtrip.PrintedAgain.ShouldEqual(_roundtrip.Printed);
[Fact] void should_print_the_named_rule() => _roundtrip.Printed.ShouldContain("orgNumber rule BeAValidOrganizationNumber message $strings.customers.orgNumberRequired");
[Fact] void should_print_a_named_rule_without_a_message() => _roundtrip.Printed.ShouldContain("orgNumber rule BeUnique\n");
[Fact] void should_print_the_implied_subject_form() => _roundtrip.Printed.ShouldContain("rule BeAValidOrganizationNumber message \"Must be a valid organization number\"");
[Fact] void should_preserve_the_predicate_name() => ((PathExpressionSyntax)Rule(1).Value!).Path.ShouldEqual("BeAValidOrganizationNumber");
[Fact] void should_preserve_the_concept_rule() => ConceptRule().Rule.ShouldEqual(ValidationRuleKind.Rule);

ValidationRuleSyntax Rule(int index) =>
_roundtrip.Reparsed.Value!.Modules.Single().Features.Single().Slices.Single().Commands.Single()
.Validations.OfType<DeclarativeValidateSyntax>().Single().Rules.ElementAt(index);

ValidationRuleSyntax ConceptRule() =>
_roundtrip.Reparsed.Value!.Concepts.Single().Validations!.OfType<DeclarativeValidateSyntax>().Single().Rules.Single();
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const validateItems: CompletionEntry[] = [
{ label: 'max', insertText: '${1:property} max ${2:500} message "${3:message}"', documentation: 'Maximum length or value.' },
{ label: 'min', insertText: '${1:property} min ${2:1} message "${3:message}"', documentation: 'Minimum length or value.' },
{ label: 'matches', insertText: '${1:property} matches "${2:pattern}" message "${3:message}"', documentation: 'The property must match a regular expression.' },
{ label: 'rule', insertText: '${1:property} rule ${2:PredicateName} message "${3:message}"', documentation: 'Names a predicate whose logic lives in code — states that a constraint exists without expressing it.' },
{ label: 'length ==', insertText: '${1:property} length == ${2:3} message "${3:message}"', documentation: 'The property must have an exact length.' },
{ label: 'all >', insertText: '${1:collection}.${2:property} all > ${3:0} message "${4:message}"', documentation: 'Every element of a collection must satisfy the comparison.' },
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const keywordDocs: Record<string, string> = {
produces: 'Declares the events a command emits — single, multiple, or conditional.',
handler: 'A fully imperative command implementation — a `file <Path>` reference or an inline `csharp` block, instead of `produces`.',
when: 'Guards a produced event or a capture append with a condition.',
rule: 'Names a predicate whose logic lives in code — states that a constraint exists without expressing what it computes. Nothing resolves the name.',
require: 'A policy condition: `authenticated`, `role "..."`, or `claim "..." matches ...`.',
authenticated: 'Requires an authenticated caller.',
role: 'Requires the caller to have the given role.',
Expand Down
1 change: 1 addition & 0 deletions Source/Screenplay/Monaco/screenplay-language/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const clauseKeywords = [
'message',
'not',
'empty',
'rule',
'matches',
'all',
'max',
Expand Down
Loading