diff --git a/Source/Rendering.Cratis/Expressions/CommandContextAccess.cs b/Source/Rendering.Cratis/Expressions/CommandContextAccess.cs new file mode 100644 index 0000000..72abca1 --- /dev/null +++ b/Source/Rendering.Cratis/Expressions/CommandContextAccess.cs @@ -0,0 +1,150 @@ +// 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; +using Cratis.Screenplay.Syntax.Projections; +using Cratis.Stage.Rendering.Cratis.CodeGeneration; +using Cratis.Stage.Rendering.Cratis.Naming; + +namespace Cratis.Stage.Rendering.Cratis.Expressions; + +/// +/// Renders the context expressions inside a rendered command handler, and records the collaborators the handler +/// has to take a parameter for to reach them. +/// +/// +/// +/// A rendered command handler is an Arc model-bound Handle(). Arc's own CommandContext carries the +/// correlation id, the command instance and the dependencies — and none of what the Screenplay language names: +/// no Occurred, no Identity, no Tenant, no CausedBy, no Causation. Screenplay +/// defines those on its own Cratis.Screenplay.Contexts.CommandContext, which is a different type that a +/// rendered application never receives. Rendering $context.occurred as context.Occurred therefore +/// produced an application that did not compile. +/// +/// +/// Every path is rendered instead against something the Cratis runtime really offers, asked for as a handler +/// parameter — which is how a hand-written slice reaches the same values. A path with no such equivalent is +/// reported and rendered as a missing value rather than as a member that does not exist. +/// +/// +/// What is being rendered, for diagnostics (for example Command 'RegisterInvoice'). +/// Collects anything that could not be rendered faithfully. +public sealed class CommandContextAccess(string subject, ICollection diagnostics) : IExpressionContext +{ + readonly List _collaborators = []; + readonly SortedSet _namespaces = new(StringComparer.Ordinal); + + /// + /// Gets the collaborators the rendered handler needs, in the order they were first asked for. + /// + public IReadOnlyList Collaborators => _collaborators; + + /// + /// Gets every namespace the rendered expressions need in scope. + /// + public IEnumerable Namespaces => _namespaces; + + /// + /// The C# type a context value is rendered as, by path — so a caller can tell whether what the document maps + /// it onto can hold it. A path the language does not name has no type, and neither has one that renders to a + /// missing value. + /// + /// The $context path, without the $context. prefix. + /// The C# type name, or when the path resolves to nothing typed. + public static string? ValueTypeOf(string path) => path.Split('.') switch + { + ["occurred"] => "DateTimeOffset", + ["tenant"] => "string", + ["causedBy", "subject" or "name" or "userName"] => "string", + ["causation", "type"] => "string", + ["identity", "id" or "name" or "userName"] => "string", + ["identity", "isAuthenticated"] => "bool", + ["identity", "claims", ..] => "string", + _ => null, + }; + + /// + public string Render(ContextExpressionSyntax context) => Resolve(context.Path); + + /// + public string Render(EventContextExpressionSyntax eventContext) => + Unrenderable($"$eventContext.{eventContext.Path}", "a command handler runs before anything is appended, so there is no event context"); + + /// + public string Render(CausedByExpressionSyntax causedBy) => + causedBy.Property is null ? Identity(null) : Resolve($"causedBy.{causedBy.Property}"); + + /// + public string RenderEventSourceId() => + Unrenderable("$eventSourceId", "the event source id is resolved from the command's own identifier rather than read in the handler"); + + static string Property(string[] segments) => string.Join('.', segments.Skip(1).Select(Identifiers.ToPascalCase)); + + string Resolve(string path) + { + var segments = path.Split('.'); + return segments switch + { + ["occurred"] => "DateTimeOffset.UtcNow", + ["tenant"] => $"{Use(HandlerCollaborator.Tenants)}.Current.Value", + ["command", ..] when segments.Length > 1 => Property(segments), + ["causedBy", var value] => CausedBy(value, path), + ["causation", "type"] => $"{Use(HandlerCollaborator.Causations)}.GetCurrentChain()[^1].Type.Value", + ["identity", ..] => Identity(segments, path), + _ => Unrenderable($"$context.{path}", "the language names no such value"), + }; + } + + string CausedBy(string value, string path) => value switch + { + "subject" => Identity("Subject"), + "name" => Identity("Name"), + "userName" => Identity("UserName"), + _ => Unrenderable($"$context.{path}", "the language names no such value"), + }; + + string Identity(string[] segments, string path) => segments switch + { + ["identity", "id"] => Identity("Subject"), + ["identity", "name"] => Identity("Name"), + ["identity", "userName"] => Identity("UserName"), + ["identity", "isAuthenticated"] => $"{Use(HandlerCollaborator.Principals)}.Current?.Identity?.IsAuthenticated == true", + ["identity", "roles"] => Roles(), + ["identity", "claims", .. var claim] when claim.Length > 0 => Claim(string.Join('.', claim)), + _ => Unrenderable($"$context.{path}", "the language names no such value"), + }; + + string Identity(string? property) => + property is null ? $"{Use(HandlerCollaborator.Identities)}.GetCurrent()" : $"{Use(HandlerCollaborator.Identities)}.GetCurrent().{property}"; + + string Claim(string name) => + $"{Use(HandlerCollaborator.Principals)}.Current?.FindFirst({CSharpCodeBuilder.StringLiteral(name)})?.Value ?? string.Empty"; + + string Roles() + { + var principals = Use(HandlerCollaborator.Principals); + _namespaces.Add("System.Security.Claims"); + return $"({principals}.Current?.FindAll(ClaimTypes.Role) ?? []).Select(claim => claim.Value)"; + } + + string Unrenderable(string expression, string reason) + { + diagnostics.Add($"{subject} reads '{expression}', which the rendered handler cannot reach — {reason}; rendered as a missing value."); + return "default!"; + } + + string Use(HandlerCollaborator collaborator) + { + if (!_collaborators.Contains(collaborator)) + { + _collaborators.Add(collaborator); + + if (collaborator.Namespace.Length > 0) + { + _namespaces.Add(collaborator.Namespace); + } + } + + return collaborator.ParameterName; + } +} diff --git a/Source/Rendering.Cratis/Expressions/EventContextAccess.cs b/Source/Rendering.Cratis/Expressions/EventContextAccess.cs new file mode 100644 index 0000000..0c821eb --- /dev/null +++ b/Source/Rendering.Cratis/Expressions/EventContextAccess.cs @@ -0,0 +1,39 @@ +// 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; +using Cratis.Screenplay.Syntax.Projections; +using Cratis.Stage.Rendering.Cratis.Naming; + +namespace Cratis.Stage.Rendering.Cratis.Expressions; + +/// +/// Renders the context expressions against Chronicle's EventContext, for the artifacts that receive one — +/// a reactor method and a projection. +/// +/// +/// EventContext carries Occurred, CausedBy and EventSourceId under those names, so +/// PascalCasing the declared path resolves for the paths a document actually uses here. +/// +public sealed class EventContextAccess : IExpressionContext +{ + /// + /// Gets the shared instance — the rendering carries no state. + /// + public static readonly EventContextAccess Instance = new(); + + /// + public string Render(ContextExpressionSyntax context) => Path(context.Path); + + /// + public string Render(EventContextExpressionSyntax eventContext) => Path(eventContext.Path); + + /// + public string Render(CausedByExpressionSyntax causedBy) => + causedBy.Property is null ? "context.CausedBy" : $"context.CausedBy.{Identifiers.ToPascalCase(causedBy.Property)}"; + + /// + public string RenderEventSourceId() => "context.EventSourceId"; + + static string Path(string path) => $"context.{string.Join('.', path.Split('.').Select(Identifiers.ToPascalCase))}"; +} diff --git a/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs b/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs index 84d48e4..d56d188 100644 --- a/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs +++ b/Source/Rendering.Cratis/Expressions/ExpressionRenderer.cs @@ -16,41 +16,47 @@ namespace Cratis.Stage.Rendering.Cratis.Expressions; /// and their guarding conditions all resolve through the same set of rules. /// /// -/// Every rendered expression that reaches beyond the command/event's own properties ($context.*, -/// $eventContext.*, $causedBy, $eventSourceId) assumes the enclosing method declares its -/// context parameter as context — the same fixed name Screenplay's own authored csharp code blocks -/// assume (see HandlerSyntax/ReactorTriggerSyntax code blocks). Root-specific semantics of -/// $context.<root>.* beyond that are best-effort (PascalCase every path segment) since no confirmed -/// Arc API mapping exists for every root. +/// An expression that reaches beyond the artifact's own properties ($context.*, $eventContext.*, +/// $causedBy, $eventSourceId) has no single C# rendering — what it becomes depends on what the +/// enclosing artifact receives. The overloads taking an let the caller say; +/// the ones without assume Chronicle's EventContext is in scope as context, which holds for a +/// reactor method and a projection and for nothing else. /// public static class ExpressionRenderer { /// - /// Renders an expression as C# expression text. + /// Renders an expression as C# expression text, against Chronicle's EventContext. /// /// The expression to render. /// The rendered C# expression text. /// Thrown when the expression has no C# rendering. - public static string Render(ExpressionSyntax expression) => expression switch + public static string Render(ExpressionSyntax expression) => Render(expression, EventContextAccess.Instance); + + /// + /// Renders an expression as C# expression text, against the surroundings the enclosing artifact provides. + /// + /// The expression to render. + /// The rendering what reaches outside the artifact. + /// The rendered C# expression text. + /// Thrown when the expression has no C# rendering. + public static string Render(ExpressionSyntax expression, IExpressionContext context) => expression switch { LiteralExpressionSyntax literal => RenderLiteral(literal.Value), PathExpressionSyntax path => RenderPath(path.Path), SourceItemExpressionSyntax sourceItem => RenderPath(sourceItem.Path), - ContextExpressionSyntax context => RenderContextPath(context.Path), + ContextExpressionSyntax contextExpression => context.Render(contextExpression), EnvironmentExpressionSyntax environment => $"Environment.GetEnvironmentVariable({CSharpCodeBuilder.StringLiteral(environment.Name)})", StringsExpressionSyntax strings =>$"{CSharpCodeBuilder.StringLiteral(strings.Key)} /* TODO: resolve localized string */", RawExpressionSyntax raw => raw.Text, - EventSourceIdExpressionSyntax => "context.EventSourceId", - EventContextExpressionSyntax eventContext => RenderContextPath(eventContext.Path), - CausedByExpressionSyntax causedBy => causedBy.Property is null - ? "context.CausedBy" - : $"context.CausedBy.{Identifiers.ToPascalCase(causedBy.Property)}", - TemplateExpressionSyntax template => RenderTemplate(template), + EventSourceIdExpressionSyntax => context.RenderEventSourceId(), + EventContextExpressionSyntax eventContext => context.Render(eventContext), + CausedByExpressionSyntax causedBy => context.Render(causedBy), + TemplateExpressionSyntax template => RenderTemplate(template, context), _ => throw new UnsupportedExpression(expression), }; /// - /// Renders a condition as a C# boolean expression. + /// Renders a condition as a C# boolean expression, against Chronicle's EventContext. /// /// The condition to render. /// @@ -60,19 +66,30 @@ public static class ExpressionRenderer /// /// The rendered C# boolean expression text. /// Thrown when the condition has no C# rendering. - public static string Render(ConditionSyntax condition, Func? enumTypeOf = null) => condition switch + public static string Render(ConditionSyntax condition, Func? enumTypeOf = null) => + Render(condition, EventContextAccess.Instance, enumTypeOf); + + /// + /// Renders a condition as a C# boolean expression, against the surroundings the enclosing artifact provides. + /// + /// The condition to render. + /// The rendering what reaches outside the artifact. + /// Resolves the enum type name of a path being compared, when it has one. + /// The rendered C# boolean expression text. + /// Thrown when the condition has no C# rendering. + public static string Render(ConditionSyntax condition, IExpressionContext context, Func? enumTypeOf = null) => condition switch { ComparisonConditionSyntax comparison => - $"{RenderPath(comparison.Left)} {Operator(comparison.Operator)} {RenderComparand(comparison, enumTypeOf)}", + $"{RenderPath(comparison.Left)} {Operator(comparison.Operator)} {RenderComparand(comparison, context, enumTypeOf)}", LogicalConditionSyntax logical => - $"({Render(logical.Left, enumTypeOf)}) {Operator(logical.Operator)} ({Render(logical.Right, enumTypeOf)})", + $"({Render(logical.Left, context, enumTypeOf)}) {Operator(logical.Operator)} ({Render(logical.Right, context, enumTypeOf)})", _ => throw new UnsupportedCondition(condition), }; - static string RenderComparand(ComparisonConditionSyntax comparison, Func? enumTypeOf) => + static string RenderComparand(ComparisonConditionSyntax comparison, IExpressionContext context, Func? enumTypeOf) => comparison.Right is LiteralExpressionSyntax { Value: string text } && enumTypeOf?.Invoke(comparison.Left) is { } enumName ? $"{enumName}.{Identifiers.ToPascalCase(text)}" - : Render(comparison.Right); + : Render(comparison.Right, context); static string RenderLiteral(object? value) => value switch { @@ -88,9 +105,7 @@ static string RenderComparand(ComparisonConditionSyntax comparison, Func string.Join('.', path.Split('.').Select(Identifiers.ToPascalCase)); - static string RenderContextPath(string path) => $"context.{RenderPath(path)}"; - - static string RenderTemplate(TemplateExpressionSyntax template) + static string RenderTemplate(TemplateExpressionSyntax template, IExpressionContext context) { var builder = new StringBuilder("$\""); foreach (var part in template.Parts) @@ -101,7 +116,7 @@ static string RenderTemplate(TemplateExpressionSyntax template) } else if (part is TemplateInterpolationSyntax interpolation) { - builder.Append('{').Append(Render(interpolation.Expression)).Append('}'); + builder.Append('{').Append(Render(interpolation.Expression, context)).Append('}'); } } diff --git a/Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs b/Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs new file mode 100644 index 0000000..a557b66 --- /dev/null +++ b/Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.Stage.Rendering.Cratis.Expressions; + +/// +/// Represents a collaborator a rendered handler takes as a parameter to reach a value the Screenplay document +/// asks for from the surrounding context. +/// +/// The collaborator's C# type name. +/// The parameter name the rendered expressions refer to it by. +/// The namespace to import for the type, or empty when the type name is already qualified. +/// +/// Arc resolves a command handler's parameters from dependency injection by type, so asking for a collaborator is +/// how a rendered handler reaches anything beyond the command's own properties. Each one here is a type the Cratis +/// runtime registers by convention. +/// +public sealed record HandlerCollaborator(string TypeName, string ParameterName, string Namespace) +{ + /// + /// Gets the collaborator giving the tenant the command executes for. + /// + public static readonly HandlerCollaborator Tenants = new("ITenantIdAccessor", "tenants", "Cratis.Arc.Tenancy"); + + /// + /// Gets the collaborator giving the identity recorded as having caused what the command appends. + /// + /// + /// Named in full: the Cratis package's global usings bring in both Cratis.Chronicle.Identities and + /// Cratis.Arc.Identity, and each declares an IIdentityProvider, so the short name is ambiguous + /// in every rendered file whether or not this one adds a using of its own. + /// + public static readonly HandlerCollaborator Identities = new("Cratis.Chronicle.Identities.IIdentityProvider", "identities", string.Empty); + + /// + /// Gets the collaborator giving the calling principal — what the caller can prove, rather than who they are. + /// + public static readonly HandlerCollaborator Principals = new("ICurrentPrincipalAccessor", "principals", "Cratis.Arc.Authorization"); + + /// + /// Gets the collaborator giving what caused the command to run. + /// + public static readonly HandlerCollaborator Causations = new("ICausationManager", "causations", "Cratis.Chronicle.Auditing"); + + /// + /// Renders the collaborator as a method parameter. + /// + /// The parameter declaration. + public string ToParameter() => $"{TypeName} {ParameterName}"; +} diff --git a/Source/Rendering.Cratis/Expressions/IExpressionContext.cs b/Source/Rendering.Cratis/Expressions/IExpressionContext.cs new file mode 100644 index 0000000..ce90382 --- /dev/null +++ b/Source/Rendering.Cratis/Expressions/IExpressionContext.cs @@ -0,0 +1,48 @@ +// 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; +using Cratis.Screenplay.Syntax.Projections; + +namespace Cratis.Stage.Rendering.Cratis.Expressions; + +/// +/// Defines how the expressions that reach outside the artifact being rendered — $context, +/// $eventContext, $causedBy and $eventSourceId — are rendered where they land. +/// +/// +/// The same Screenplay expression has different C# in different places, because the surroundings differ: a +/// reactor method receives Chronicle's EventContext and can read Occurred straight off it, while a +/// command handler receives no such thing and has to ask a collaborator. Rendering both the same way is what made +/// a command handler emit members Arc's CommandContext does not have, so the enclosing artifact supplies +/// the rendering rather than the expression assuming one. +/// +public interface IExpressionContext +{ + /// + /// Renders a $context.<path> expression. + /// + /// The expression to render. + /// The rendered C# expression text. + string Render(ContextExpressionSyntax context); + + /// + /// Renders a $eventContext.<path> expression. + /// + /// The expression to render. + /// The rendered C# expression text. + string Render(EventContextExpressionSyntax eventContext); + + /// + /// Renders a $causedBy expression. + /// + /// The expression to render. + /// The rendered C# expression text. + string Render(CausedByExpressionSyntax causedBy); + + /// + /// Renders a $eventSourceId expression. + /// + /// The rendered C# expression text. + string RenderEventSourceId(); +} diff --git a/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs b/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs index dc74408..dd21764 100644 --- a/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs +++ b/Source/Rendering.Cratis/Renderers/StateChangeSliceRenderer.cs @@ -58,14 +58,9 @@ static void RenderCommand(CSharpCodeBuilder builder, CommandSyntax command, Appl { var typeName = Identifiers.ToPascalCase(command.Name); var parameters = string.Join(", ", command.Properties.Select(property => RenderParameter(property, command.Name, applicationSet, diagnostics))); - var requiresContext = RequiresContext(command); var authorization = AuthorizationRenderer.Render(command.Authorize, applicationSet, $"Command '{command.Name}'", diagnostics); builder.Using("Cratis.Arc.Commands.ModelBound").Using(AuthorizationRenderer.Namespace); - if (requiresContext || command.Handler?.Code is not null) - { - builder.Using("Cratis.Arc.Commands"); - } if (command.Properties.Any(property => property.IsIdentifier && TypeResolver.Resolve(property.Type, applicationSet).Kind != ResolvedTypeKind.Concept)) { @@ -75,18 +70,23 @@ static void RenderCommand(CSharpCodeBuilder builder, CommandSyntax command, Appl builder.BlankLine().Attribute("Command").Attribute(authorization).OpenBlock($"public record {typeName}({parameters})"); CommandValidatorRenderer.Render(builder, command, typeName, applicationSet, diagnostics); - RenderHandle(builder, command, requiresContext, applicationSet, diagnostics); + RenderHandle(builder, command, applicationSet, diagnostics); builder.EndBlock(); } - static void RenderHandle(CSharpCodeBuilder builder, CommandSyntax command, bool requiresContext, ApplicationSet applicationSet, ICollection diagnostics) + static void RenderHandle(CSharpCodeBuilder builder, CommandSyntax command, ApplicationSet applicationSet, ICollection diagnostics) { - var contextParameter = requiresContext ? "CommandContext context" : string.Empty; - if (command.Handler?.Code is not null) { - builder.BlankLine().OpenBlock("public IEnumerable Handle(CommandContext context)").Raw(command.Handler.Code.Code).EndBlock(); + diagnostics.Add( + $"Command '{command.Name}' handles with an authored {command.Handler.Code.Language} block, which is written against Screenplay's " + + "own CommandContext — a rendered Arc handler receives Arc's, so the block is emitted as written and compiles only where the two agree."); + builder.Using("Cratis.Arc.Commands") + .BlankLine() + .OpenBlock("public IEnumerable Handle(CommandContext context)") + .Raw(command.Handler.Code.Code) + .EndBlock(); return; } @@ -94,35 +94,45 @@ static void RenderHandle(CSharpCodeBuilder builder, CommandSyntax command, bool if (produces.Length == 0) { - builder.BlankLine().OpenBlock($"public void Handle({contextParameter})").EndBlock(); + builder.BlankLine().OpenBlock("public void Handle()").EndBlock(); return; } - if (produces.Length == 1 && produces[0].When is null) + // Every produced event is rendered before the signature is written, because rendering is what discovers + // which collaborators the handler has to ask for — a `$context` path is reachable only through one. + var context = new CommandContextAccess($"Command '{command.Name}'", diagnostics); + var rendered = produces.Select(produced => ( + Event: Identifiers.ToPascalCase(produced.Event), + Arguments: RenderEventArguments(produced, command, context, applicationSet, diagnostics), + Condition: produced.When is null + ? null + : ExpressionRenderer.Render(produced.When, context, path => EnumTypeOfCommandProperty(path, command, applicationSet)))) + .ToArray(); + + foreach (var @namespace in context.Namespaces) { - var eventTypeName = Identifiers.ToPascalCase(produces[0].Event); - builder.BlankLine().ExpressionMember( - $"public {eventTypeName} Handle({contextParameter})", - $"new({RenderEventArguments(produces[0], command, applicationSet, diagnostics)})"); - return; + builder.Using(@namespace); } - builder.BlankLine().OpenBlock($"public IEnumerable Handle({contextParameter})").Line("var events = new List();"); + var parameters = string.Join(", ", context.Collaborators.Select(collaborator => collaborator.ToParameter())); - foreach (var produced in produces) + if (rendered.Length == 1 && rendered[0].Condition is null) { - var eventTypeName = Identifiers.ToPascalCase(produced.Event); - var arguments = RenderEventArguments(produced, command, applicationSet, diagnostics); + builder.BlankLine().ExpressionMember($"public {rendered[0].Event} Handle({parameters})", $"new({rendered[0].Arguments})"); + return; + } + + builder.BlankLine().OpenBlock($"public IEnumerable Handle({parameters})").Line("var events = new List();"); - if (produced.When is not null) + foreach (var (@event, arguments, condition) in rendered) + { + if (condition is not null) { - builder.OpenBlock($"if ({ExpressionRenderer.Render(produced.When, path => EnumTypeOfCommandProperty(path, command, applicationSet))})") - .Line($"events.Add(new {eventTypeName}({arguments}));") - .EndBlock(); + builder.OpenBlock($"if ({condition})").Line($"events.Add(new {@event}({arguments}));").EndBlock(); } else { - builder.Line($"events.Add(new {eventTypeName}({arguments}));"); + builder.Line($"events.Add(new {@event}({arguments}));"); } } @@ -137,11 +147,12 @@ static void RenderHandle(CSharpCodeBuilder builder, CommandSyntax command, bool /// /// The produces declaration to render arguments for. /// The command producing the event — the scope a mapping source has to resolve against. + /// The rendering what reaches outside the command. /// The to resolve the event and its property types against. /// Collects anything that could not be rendered faithfully. /// The rendered argument list. static string RenderEventArguments( - ProducesSyntax produces, CommandSyntax command, ApplicationSet applicationSet, ICollection diagnostics) + ProducesSyntax produces, CommandSyntax command, CommandContextAccess context, ApplicationSet applicationSet, ICollection diagnostics) { var targetEvent = applicationSet.Events.GetValueOrDefault(produces.Event); if (targetEvent is null) @@ -161,7 +172,7 @@ static string RenderEventArguments( } var declared = properties?.FirstOrDefault(property => string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)); - return RenderEventArgument(mapping, declared, command, applicationSet, diagnostics); + return RenderEventArgument(mapping, declared, command, context, applicationSet, diagnostics); }); return string.Join(", ", arguments); @@ -176,6 +187,7 @@ static string RenderEventArguments( /// The property mapping to render. /// The declared event property, when the event is known. /// The command producing the event. + /// The rendering what reaches outside the command. /// The to resolve the property type against. /// Collects anything that could not be rendered faithfully. /// The rendered argument. @@ -183,6 +195,7 @@ static string RenderEventArgument( PropertyMappingSyntax mapping, PropertySyntax? declared, CommandSyntax command, + CommandContextAccess context, ApplicationSet applicationSet, ICollection diagnostics) { @@ -201,7 +214,62 @@ static string RenderEventArgument( return "default!"; } - return ExpressionRenderer.Render(mapping.Source); + if (mapping.Source is ContextExpressionSyntax contextExpression && Mismatch(contextExpression, declared, applicationSet) is { } mismatch) + { + diagnostics.Add( + $"'{mapping.Property}' is mapped from '$context.{contextExpression.Path}', {mismatch} — rendered as a missing value."); + return "default!"; + } + + return ExpressionRenderer.Render(mapping.Source, context); + } + + /// + /// Describes why a context value cannot fill the property the document maps it onto, or + /// when it can. The runtime carries every one of these as a fixed type — the tenant as a string, the caller's + /// subject as a string — and a document is free to declare the property it fills as anything, so a + /// Uuid concept fed from a string identifier is a mapping no rendering can honor. + /// + /// The context expression being mapped. + /// The declared event property, when the event is known. + /// The to resolve the property type against. + /// The description, or when the value fits. + static string? Mismatch(ContextExpressionSyntax context, PropertySyntax? declared, ApplicationSet applicationSet) + { + if (declared is null || CommandContextAccess.ValueTypeOf(context.Path) is not { } value) + { + return null; + } + + var underlying = UnderlyingType(declared.Type, applicationSet); + return underlying is null || underlying == value + ? null + : $"a {value} the runtime supplies, which the event declares as '{declared.Type.Name}' — a {underlying}"; + } + + /// + /// Resolves the C# type a declared property ultimately holds — the primitive itself, or the one a concept + /// wraps. An enum, a composite type or an unresolved name has none. + /// + /// The declared type reference. + /// The to resolve against. + /// The C# type name, or when the property holds no single primitive. + static string? UnderlyingType(TypeRefSyntax type, ApplicationSet applicationSet) + { + var resolved = TypeResolver.Resolve(type, applicationSet); + if (resolved.IsCollection) + { + return null; + } + + return resolved.Kind switch + { + ResolvedTypeKind.Primitive => resolved.ClrTypeName, + ResolvedTypeKind.Concept when applicationSet.Concepts.TryGetValue(type.Name, out var concept) => + TypeResolver.Resolve(new TypeRefSyntax(concept.Type, false, false, concept.Location), applicationSet) is + { Kind: ResolvedTypeKind.Primitive } underlying ? underlying.ClrTypeName : null, + _ => null, + }; } static PropertySyntax? CommandProperty(string name, CommandSyntax command) => @@ -231,22 +299,4 @@ static string RenderParameter(PropertySyntax property, string commandName, Appli var prefix = property.IsIdentifier && resolved.Kind != ResolvedTypeKind.Concept ? "[Key] " : string.Empty; return $"{prefix}{resolved.ToTypeSyntax()} {Identifiers.ToPascalCase(property.Name)}"; } - - static bool RequiresContext(CommandSyntax command) => command.Produces.Any(produces => - (produces.When is not null && ConditionReferencesContext(produces.When)) || - produces.Mappings.Any(mapping => ExpressionReferencesContext(mapping.Source))); - - static bool ExpressionReferencesContext(ExpressionSyntax expression) => expression switch - { - ContextExpressionSyntax or EventContextExpressionSyntax or CausedByExpressionSyntax or EventSourceIdExpressionSyntax => true, - TemplateExpressionSyntax template => template.Parts.OfType().Any(part => ExpressionReferencesContext(part.Expression)), - _ => false, - }; - - static bool ConditionReferencesContext(ConditionSyntax condition) => condition switch - { - ComparisonConditionSyntax comparison => ExpressionReferencesContext(comparison.Right), - LogicalConditionSyntax logical => ConditionReferencesContext(logical.Left) || ConditionReferencesContext(logical.Right), - _ => false, - }; } diff --git a/Source/Rendering.Cratis/for_CommandContextAccess/when_rendering_the_paths_a_command_can_reach.cs b/Source/Rendering.Cratis/for_CommandContextAccess/when_rendering_the_paths_a_command_can_reach.cs new file mode 100644 index 0000000..15240c4 --- /dev/null +++ b/Source/Rendering.Cratis/for_CommandContextAccess/when_rendering_the_paths_a_command_can_reach.cs @@ -0,0 +1,70 @@ +// 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; +using Cratis.Screenplay.Syntax.Projections; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.Expressions; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_CommandContextAccess; + +public class when_rendering_the_paths_a_command_can_reach : Specification +{ + readonly List _diagnostics = []; + CommandContextAccess _access = null!; + string _occurred = null!; + string _tenant = null!; + string _identityId = null!; + string _identityName = null!; + string _isAuthenticated = null!; + string _claim = null!; + string _roles = null!; + string _causedBySubject = null!; + string _causationType = null!; + string _commandProperty = null!; + + void Establish() => _access = new CommandContextAccess("Command 'RegisterInvoice'", _diagnostics); + + void Because() + { + _occurred = _access.Render(Context("occurred")); + _tenant = _access.Render(Context("tenant")); + _identityId = _access.Render(Context("identity.id")); + _identityName = _access.Render(Context("identity.name")); + _isAuthenticated = _access.Render(Context("identity.isAuthenticated")); + _claim = _access.Render(Context("identity.claims.department")); + _roles = _access.Render(Context("identity.roles")); + _causedBySubject = _access.Render(new CausedByExpressionSyntax("subject", SourceLocation.Start)); + _causationType = _access.Render(Context("causation.type")); + _commandProperty = _access.Render(Context("command.invoiceNumber")); + } + + [Fact] void should_render_occurred_as_the_time_the_handler_runs() => _occurred.ShouldEqual("DateTimeOffset.UtcNow"); + [Fact] void should_render_the_tenant_as_its_value() => _tenant.ShouldEqual("tenants.Current.Value"); + [Fact] void should_render_the_identity_id_as_the_subject() => _identityId.ShouldEqual("identities.GetCurrent().Subject"); + [Fact] void should_render_the_identity_name() => _identityName.ShouldEqual("identities.GetCurrent().Name"); + [Fact] void should_render_whether_the_caller_is_authenticated() => + _isAuthenticated.ShouldEqual("principals.Current?.Identity?.IsAuthenticated == true"); + [Fact] void should_render_a_claim_by_name() => + _claim.ShouldEqual("principals.Current?.FindFirst(\"department\")?.Value ?? string.Empty"); + [Fact] void should_render_the_roles_the_caller_holds() => + _roles.ShouldEqual("(principals.Current?.FindAll(ClaimTypes.Role) ?? []).Select(claim => claim.Value)"); + + // The language says Identity.Id and CausedBy.Subject are the same value seen from the decision and the audit + // side, so they resolve to one expression rather than to two collaborators that could disagree. + [Fact] void should_render_the_causing_subject_as_the_same_value_as_the_identity_id() => _causedBySubject.ShouldEqual(_identityId); + + [Fact] void should_render_the_causation_type_as_its_value() => + _causationType.ShouldEqual("causations.GetCurrentChain()[^1].Type.Value"); + [Fact] void should_render_a_command_property_as_the_command_s_own() => _commandProperty.ShouldEqual("InvoiceNumber"); + [Fact] void should_report_nothing_as_unreachable() => _diagnostics.ShouldBeEmpty(); + [Fact] void should_ask_for_each_collaborator_once() => _access.Collaborators.Count.ShouldEqual(4); + [Fact] void should_name_the_identity_provider_in_full_because_the_short_name_is_ambiguous() => + _access.Collaborators.ShouldContain(HandlerCollaborator.Identities); + [Fact] void should_import_what_the_rendered_expressions_need() => + _access.Namespaces.ShouldContainOnly(["Cratis.Arc.Authorization", "Cratis.Arc.Tenancy", "Cratis.Chronicle.Auditing", "System.Security.Claims"]); + + static ContextExpressionSyntax Context(string path) => new(path, SourceLocation.Start); +} diff --git a/Source/Rendering.Cratis/for_CommandContextAccess/when_the_path_is_out_of_the_handler_s_reach.cs b/Source/Rendering.Cratis/for_CommandContextAccess/when_the_path_is_out_of_the_handler_s_reach.cs new file mode 100644 index 0000000..6332db0 --- /dev/null +++ b/Source/Rendering.Cratis/for_CommandContextAccess/when_the_path_is_out_of_the_handler_s_reach.cs @@ -0,0 +1,37 @@ +// 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; +using Cratis.Screenplay.Syntax.Projections; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.Expressions; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_CommandContextAccess; + +public class when_the_path_is_out_of_the_handler_s_reach : Specification +{ + readonly List _diagnostics = []; + CommandContextAccess _access = null!; + string _unnamed = null!; + string _eventContext = null!; + string _eventSourceId = null!; + + void Establish() => _access = new CommandContextAccess("Command 'RegisterInvoice'", _diagnostics); + + void Because() + { + _unnamed = _access.Render(new ContextExpressionSyntax("weather.today", SourceLocation.Start)); + _eventContext = _access.Render(new EventContextExpressionSyntax("occurred", SourceLocation.Start)); + _eventSourceId = _access.RenderEventSourceId(); + } + + [Fact] void should_render_a_path_the_language_does_not_name_as_a_missing_value() => _unnamed.ShouldEqual("default!"); + [Fact] void should_render_an_event_context_read_as_a_missing_value() => _eventContext.ShouldEqual("default!"); + [Fact] void should_render_an_event_source_id_read_as_a_missing_value() => _eventSourceId.ShouldEqual("default!"); + [Fact] void should_report_every_one_of_them() => _diagnostics.Count.ShouldEqual(3); + [Fact] void should_say_which_command_could_not_reach_it() => + _diagnostics.ShouldContain("Command 'RegisterInvoice' reads '$context.weather.today', which the rendered handler cannot reach — the language names no such value; rendered as a missing value."); + [Fact] void should_ask_for_no_collaborator_it_cannot_use() => _access.Collaborators.ShouldBeEmpty(); +} diff --git a/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs b/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs index 4d1dc52..3c0cf3b 100644 --- a/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs +++ b/Source/Rendering.Cratis/for_CratisRenderer/RenderedOutput.cs @@ -18,9 +18,16 @@ namespace Cratis.Stage.Rendering.Cratis.for_CratisRenderer; internal static class RenderedOutput { /// - /// The implicit usings the scaffolded project enables (ImplicitUsings in the Cratis template), mirrored + /// The implicit usings the scaffolded project enables (ImplicitUsings in the Cratis template), together + /// with the global usings the Cratis package itself contributes through its Cratis.props, mirrored /// here so the compilation sees the same ambient namespaces the rendered application really builds with. /// + /// + /// The package's set is load-bearing in both directions: without it the rendered validators look broken because + /// FluentValidation is missing, and with it a short type name that is unambiguous on its own becomes + /// ambiguous — IIdentityProvider is declared by both Cratis.Arc.Identity and + /// Cratis.Chronicle.Identities. A compilation that omits them sees neither. + /// const string ImplicitUsings = """ global using System; global using System.Collections.Generic; @@ -29,6 +36,30 @@ internal static class RenderedOutput global using System.Net.Http; global using System.Threading; global using System.Threading.Tasks; + global using Cratis.Arc; + global using Cratis.Arc.Authentication; + global using Cratis.Arc.Authorization; + global using Cratis.Arc.Chronicle.Aggregates; + global using Cratis.Arc.Commands; + global using Cratis.Arc.Commands.ModelBound; + global using Cratis.Arc.Identity; + global using Cratis.Arc.Queries; + global using Cratis.Arc.Queries.ModelBound; + global using Cratis.Arc.Swagger; + global using Cratis.Arc.Validation; + global using Cratis.Chronicle; + global using Cratis.Chronicle.Events; + global using Cratis.Chronicle.Events.Constraints; + global using Cratis.Chronicle.EventSequences; + global using Cratis.Chronicle.Observation; + global using Cratis.Chronicle.Projections; + global using Cratis.Chronicle.Projections.ModelBound; + global using Cratis.Chronicle.Reactors; + global using Cratis.Chronicle.ReadModels; + global using Cratis.Chronicle.Reducers; + global using Cratis.Chronicle.Transactions; + global using Cratis.Concepts; + global using FluentValidation; """; static readonly MetadataReference[] _references = diff --git a/Source/Rendering.Cratis/for_CratisRenderer/given/an_application_reading_the_context.cs b/Source/Rendering.Cratis/for_CratisRenderer/given/an_application_reading_the_context.cs new file mode 100644 index 0000000..518cc7e --- /dev/null +++ b/Source/Rendering.Cratis/for_CratisRenderer/given/an_application_reading_the_context.cs @@ -0,0 +1,105 @@ +// 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; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.Emission; +using Cratis.Stage.Rendering.Cratis.Renderers; + +namespace Cratis.Stage.Rendering.Cratis.for_CratisRenderer.given; + +/// +/// A command that fills its event from every $context path the language names, onto event properties +/// declared as the types those values actually are. +/// +/// +/// A command handler is the one place these have nowhere to come from: it receives no event context and Arc's +/// CommandContext carries none of them. Everything the document can say here therefore has to be reached +/// through a collaborator, and the only assertion that proves the reach is real is compiling the result. +/// +public class an_application_reading_the_context : Specification +{ + protected InMemoryCodeOutput _codeOutput = null!; + protected CratisRenderer _renderer = null!; + protected ApplicationSyntax _application = null!; + protected DirectoryInfo _targetDirectory = null!; + protected StringWriter _output = null!; + protected StringWriter _error = null!; + + void Establish() + { + var invoiceNumber = Property("invoiceNumber", "String", isIdentifier: true); + + var registered = new EventSyntax( + "InvoiceRegistered", + [ + invoiceNumber, + Property("registeredAt", "DateTime"), + Property("registeredFor", "String"), + Property("registeredBy", "String"), + Property("registeredByName", "String"), + Property("registeredByUserName", "String"), + Property("causedBySubject", "String"), + Property("causedByName", "String"), + Property("causedByUserName", "String"), + Property("causedVia", "String"), + Property("wasAuthenticated", "Bool"), + Property("department", "String"), + ], + SourceLocation.Start); + + var register = new CommandSyntax( + "RegisterInvoice", + [invoiceNumber], + null, + [], + [ + new ProducesSyntax( + "InvoiceRegistered", + null, + [ + Maps("invoiceNumber", new PathExpressionSyntax("invoiceNumber", SourceLocation.Start)), + Maps("registeredAt", Context("occurred")), + Maps("registeredFor", Context("tenant")), + Maps("registeredBy", Context("identity.id")), + Maps("registeredByName", Context("identity.name")), + Maps("registeredByUserName", Context("identity.userName")), + Maps("causedBySubject", Context("causedBy.subject")), + Maps("causedByName", Context("causedBy.name")), + Maps("causedByUserName", Context("causedBy.userName")), + Maps("causedVia", Context("causation.type")), + Maps("wasAuthenticated", Context("identity.isAuthenticated")), + Maps("department", Context("identity.claims.department")), + ], + SourceLocation.Start) + ], + null, + SourceLocation.Start); + + var slice = new SliceSyntax( + SliceType.StateChange, "Register", [registered], [register], [], [], [], [], [], [], [], SourceLocation.Start); + + var feature = new FeatureSyntax("Invoicing", [], [slice], SourceLocation.Start); + var module = new ModuleSyntax("Billing", [], [feature], SourceLocation.Start); + + _application = new ApplicationSyntax([], [], [], [module], SourceLocation.Start); + + _codeOutput = new InMemoryCodeOutput(); + _renderer = new CratisRenderer( + new a_stub_scaffolder(), + new Dictionary { [SliceType.StateChange] = new StateChangeSliceRenderer() }, + _codeOutput); + + _targetDirectory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "AcmeBilling")); + _output = new StringWriter(); + _error = new StringWriter(); + } + + static PropertySyntax Property(string name, string type, bool isIdentifier = false) => + new(name, new TypeRefSyntax(type, false, false, SourceLocation.Start), SourceLocation.Start, IsIdentifier: isIdentifier); + + static ContextExpressionSyntax Context(string path) => new(path, SourceLocation.Start); + + static PropertyMappingSyntax Maps(string property, ExpressionSyntax source) => new(property, source, SourceLocation.Start); +} diff --git a/Source/Rendering.Cratis/for_CratisRenderer/when_rendering_a_command_that_reads_the_context.cs b/Source/Rendering.Cratis/for_CratisRenderer/when_rendering_a_command_that_reads_the_context.cs new file mode 100644 index 0000000..28346f0 --- /dev/null +++ b/Source/Rendering.Cratis/for_CratisRenderer/when_rendering_a_command_that_reads_the_context.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Linq; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.for_CratisRenderer.given; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_CratisRenderer; + +public class when_rendering_a_command_that_reads_the_context : an_application_reading_the_context +{ + IReadOnlyList _errors = null!; + string _handler = null!; + + async Task Because() + { + await _renderer.Render([_application], _targetDirectory, _output, _error); + _errors = RenderedOutput.Errors(_codeOutput.Files); + _handler = _codeOutput.Files.Single(file => file.RelativePath.EndsWith("Register.cs", StringComparison.Ordinal)).Content; + } + + [Fact] void should_render_an_application_that_compiles() => _errors.ShouldBeEmpty(); + [Fact] void should_not_report_anything_as_unreachable() => _error.ToString().ShouldEqual(string.Empty); + [Fact] void should_ask_for_the_identity_by_its_full_name() => + _handler.ShouldContain("Cratis.Chronicle.Identities.IIdentityProvider identities"); + [Fact] void should_ask_for_each_collaborator_once_however_often_it_is_read() => + _handler.ShouldContain( + "public InvoiceRegistered Handle(ITenantIdAccessor tenants, Cratis.Chronicle.Identities.IIdentityProvider identities, " + + "ICausationManager causations, ICurrentPrincipalAccessor principals)"); + [Fact] void should_not_ask_for_arcs_command_context() => _handler.ShouldNotContain("CommandContext"); + [Fact] void should_read_the_time_the_command_was_handled() => _handler.ShouldContain("DateTimeOffset.UtcNow"); + [Fact] void should_read_the_tenant_from_the_tenant_accessor() => _handler.ShouldContain("tenants.Current.Value"); + [Fact] void should_read_a_claim_from_the_calling_principal() => + _handler.ShouldContain("principals.Current?.FindFirst(\"department\")?.Value"); +} diff --git a/Source/Rendering.Cratis/for_ExpressionRenderer/when_rendering_expressions.cs b/Source/Rendering.Cratis/for_ExpressionRenderer/when_rendering_expressions.cs index ada446f..d0545a0 100644 --- a/Source/Rendering.Cratis/for_ExpressionRenderer/when_rendering_expressions.cs +++ b/Source/Rendering.Cratis/for_ExpressionRenderer/when_rendering_expressions.cs @@ -10,6 +10,11 @@ namespace Cratis.Stage.Rendering.Cratis.for_ExpressionRenderer; +/// +/// The renderings that hold when Chronicle's EventContext is in scope as context — a reactor method +/// and a projection. What $context becomes elsewhere is the enclosing artifact's to say; a command handler +/// receives no such parameter, so its renderings are specified against CommandContextAccess instead. +/// public class when_rendering_expressions : Specification { string _stringLiteral = null!; @@ -52,11 +57,11 @@ void Because() [Fact] void should_render_a_null_literal_as_null() => _nullLiteral.ShouldEqual("null"); [Fact] void should_render_a_path_as_a_pascal_case_property_reference() => _path.ShouldEqual("InvoiceId"); [Fact] void should_render_a_dotted_path_segment_by_segment() => _dottedPath.ShouldEqual("BillingContact.Email"); - [Fact] void should_render_a_context_path_against_the_fixed_context_parameter() => _context.ShouldEqual("context.Identity.Id"); + [Fact] void should_render_a_context_path_against_the_event_context() => _context.ShouldEqual("context.Identity.Id"); [Fact] void should_render_an_environment_expression_as_environment_get_environment_variable() => _environment.ShouldEqual("Environment.GetEnvironmentVariable(\"SERVICE_NAME\")"); [Fact] void should_render_caused_by_with_a_property() => _causedByWithProperty.ShouldEqual("context.CausedBy.Name"); [Fact] void should_render_caused_by_without_a_property() => _causedByWithoutProperty.ShouldEqual("context.CausedBy"); - [Fact] void should_render_event_source_id_against_the_fixed_context_parameter() => _eventSourceId.ShouldEqual("context.EventSourceId"); + [Fact] void should_render_event_source_id_against_the_event_context() => _eventSourceId.ShouldEqual("context.EventSourceId"); [Fact] void should_render_a_template_as_an_interpolated_string() => _template.ShouldEqual("$\"Invoice {InvoiceNumber}\""); } diff --git a/Source/Rendering.Cratis/for_StateChangeSliceRenderer/when_the_context_value_cannot_fill_what_it_is_mapped_onto.cs b/Source/Rendering.Cratis/for_StateChangeSliceRenderer/when_the_context_value_cannot_fill_what_it_is_mapped_onto.cs new file mode 100644 index 0000000..ed4eb62 --- /dev/null +++ b/Source/Rendering.Cratis/for_StateChangeSliceRenderer/when_the_context_value_cannot_fill_what_it_is_mapped_onto.cs @@ -0,0 +1,64 @@ +// 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; +using Cratis.Specifications; +using Cratis.Stage.Rendering.Cratis.Renderers; +using Xunit; + +namespace Cratis.Stage.Rendering.Cratis.for_StateChangeSliceRenderer; + +/// +/// The runtime supplies the tenant as a string. A document is free to declare the property it fills as anything — +/// here a Uuid concept — and nothing can turn one into the other, so the mapping is dropped and reported +/// rather than rendered into a conversion that does not exist. +/// +public class when_the_context_value_cannot_fill_what_it_is_mapped_onto : Specification +{ + CodeGeneration.RenderedFile _file = null!; + + void Because() + { + var invoiceNumber = new PropertySyntax( + "invoiceNumber", new TypeRefSyntax("String", false, false, SourceLocation.Start), SourceLocation.Start, IsIdentifier: true); + var registeredFor = new PropertySyntax( + "registeredFor", new TypeRefSyntax("TenantId", false, false, SourceLocation.Start), SourceLocation.Start); + + var registered = new EventSyntax("InvoiceRegistered", [invoiceNumber, registeredFor], SourceLocation.Start); + + var register = new CommandSyntax( + "RegisterInvoice", + [invoiceNumber], + null, + [], + [ + new ProducesSyntax( + "InvoiceRegistered", + null, + [ + new PropertyMappingSyntax("invoiceNumber", new PathExpressionSyntax("invoiceNumber", SourceLocation.Start), SourceLocation.Start), + new PropertyMappingSyntax("registeredFor", new ContextExpressionSyntax("tenant", SourceLocation.Start), SourceLocation.Start), + ], + SourceLocation.Start) + ], + null, + SourceLocation.Start); + + var slice = new SliceSyntax( + SliceType.StateChange, "Register", [registered], [register], [], [], [], [], [], [], [], SourceLocation.Start); + var feature = new FeatureSyntax("Invoicing", [], [slice], SourceLocation.Start); + var module = new ModuleSyntax("Billing", [], [feature], SourceLocation.Start); + var application = new ApplicationSyntax( + [], [new ConceptSyntax("TenantId", "Uuid", [], [], SourceLocation.Start)], [], [module], SourceLocation.Start); + + _file = new StateChangeSliceRenderer().Render( + new LocatedSlice(slice, ["Billing", "Invoicing"]), new ApplicationSet([application]), "Acme"); + } + + [Fact] void should_render_the_mapping_as_a_missing_value() => _file.Content.ShouldContain("public InvoiceRegistered Handle() => new(InvoiceNumber, default!);"); + [Fact] void should_not_ask_for_a_collaborator_it_does_not_use() => _file.Content.ShouldNotContain("ITenantIdAccessor"); + [Fact] void should_say_what_the_document_asked_for_and_what_it_declared() => + _file.Diagnostics.ShouldContain( + "'registeredFor' is mapped from '$context.tenant', a string the runtime supplies, which the event declares as 'TenantId' — a Guid — rendered as a missing value."); +}