From b592c5af58aec7778c31ed1920057020d68151f2 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 10:41:43 +0200 Subject: [PATCH 1/2] Render a context path against what the enclosing artifact receives A rendered command handler is an Arc model-bound Handle(), and Arc's CommandContext carries none of Occurred, Identity, Tenant, CausedBy or Causation - those are on Screenplay's own CommandContext, a different type a rendered application never receives. Rendering every context expression as 'context.' therefore emitted members that do not exist, and the application did not compile. The enclosing artifact now supplies the rendering. An event context is still read straight off 'context'; a command handler asks the runtime for what it needs through handler parameters, and reports the paths it cannot reach rather than naming a member for them. --- .../Expressions/CommandContextAccess.cs | 150 ++++++++++++++++++ .../Expressions/EventContextAccess.cs | 39 +++++ .../Expressions/ExpressionRenderer.cs | 65 +++++--- .../Expressions/HandlerCollaborator.cs | 50 ++++++ .../Expressions/IExpressionContext.cs | 48 ++++++ .../Renderers/StateChangeSliceRenderer.cs | 144 +++++++++++------ 6 files changed, 424 insertions(+), 72 deletions(-) create mode 100644 Source/Rendering.Cratis/Expressions/CommandContextAccess.cs create mode 100644 Source/Rendering.Cratis/Expressions/EventContextAccess.cs create mode 100644 Source/Rendering.Cratis/Expressions/HandlerCollaborator.cs create mode 100644 Source/Rendering.Cratis/Expressions/IExpressionContext.cs 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, - }; } From fc2f9737b675b3f4eda2c3c8b193822a9878ce33 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 13 Aug 2026 10:41:51 +0200 Subject: [PATCH 2/2] Compile a rendered handler that reads every context path the language names The spec harness compiled rendered files against the implicit usings alone, so it saw neither the FluentValidation the Cratis package brings in nor the IIdentityProvider ambiguity that same set creates. It now mirrors the package's global usings, which is what makes the new compile assertion mean anything. --- ...rendering_the_paths_a_command_can_reach.cs | 70 ++++++++++++ ..._the_path_is_out_of_the_handler_s_reach.cs | 37 ++++++ .../for_CratisRenderer/RenderedOutput.cs | 33 +++++- .../an_application_reading_the_context.cs | 105 ++++++++++++++++++ ...dering_a_command_that_reads_the_context.cs | 36 ++++++ .../when_rendering_expressions.cs | 9 +- ...alue_cannot_fill_what_it_is_mapped_onto.cs | 64 +++++++++++ 7 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 Source/Rendering.Cratis/for_CommandContextAccess/when_rendering_the_paths_a_command_can_reach.cs create mode 100644 Source/Rendering.Cratis/for_CommandContextAccess/when_the_path_is_out_of_the_handler_s_reach.cs create mode 100644 Source/Rendering.Cratis/for_CratisRenderer/given/an_application_reading_the_context.cs create mode 100644 Source/Rendering.Cratis/for_CratisRenderer/when_rendering_a_command_that_reads_the_context.cs create mode 100644 Source/Rendering.Cratis/for_StateChangeSliceRenderer/when_the_context_value_cannot_fill_what_it_is_mapped_onto.cs 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."); +}