Skip to content

Commit 52b96ec

Browse files
authored
fix: hooks integration (#236)
1 parent 1cc107c commit 52b96ec

File tree

7 files changed

+295
-5
lines changed

7 files changed

+295
-5
lines changed

src/Facet/FacetTarget.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ internal sealed class FacetTargetModel : IEquatable<FacetTargetModel>
2121
public string SourceTypeName { get; }
2222
public ImmutableArray<string> SourceContainingTypes { get; }
2323
public string? ConfigurationTypeName { get; }
24+
public string? BeforeMapConfigurationTypeName { get; }
25+
public string? AfterMapConfigurationTypeName { get; }
2426
public ImmutableArray<FacetMember> Members { get; }
2527
public bool HasExistingPrimaryConstructor { get; }
2628
public bool SourceHasPositionalConstructor { get; }
@@ -61,7 +63,9 @@ public FacetTargetModel(
6163
int maxDepth = 0,
6264
bool preserveReferences = false,
6365
ImmutableArray<string> baseClassMemberNames = default,
64-
ImmutableArray<string> flattenToTypes = default)
66+
ImmutableArray<string> flattenToTypes = default,
67+
string? beforeMapConfigurationTypeName = null,
68+
string? afterMapConfigurationTypeName = null)
6569
{
6670
Name = name;
6771
Namespace = @namespace;
@@ -76,6 +80,8 @@ public FacetTargetModel(
7680
SourceTypeName = sourceTypeName;
7781
SourceContainingTypes = sourceContainingTypes.IsDefault ? ImmutableArray<string>.Empty : sourceContainingTypes;
7882
ConfigurationTypeName = configurationTypeName;
83+
BeforeMapConfigurationTypeName = beforeMapConfigurationTypeName;
84+
AfterMapConfigurationTypeName = afterMapConfigurationTypeName;
7985
Members = members;
8086
HasExistingPrimaryConstructor = hasExistingPrimaryConstructor;
8187
SourceHasPositionalConstructor = sourceHasPositionalConstructor;
@@ -108,6 +114,8 @@ public bool Equals(FacetTargetModel? other)
108114
&& SourceTypeName == other.SourceTypeName
109115
&& SourceContainingTypes.SequenceEqual(other.SourceContainingTypes)
110116
&& ConfigurationTypeName == other.ConfigurationTypeName
117+
&& BeforeMapConfigurationTypeName == other.BeforeMapConfigurationTypeName
118+
&& AfterMapConfigurationTypeName == other.AfterMapConfigurationTypeName
111119
&& HasExistingPrimaryConstructor == other.HasExistingPrimaryConstructor
112120
&& SourceHasPositionalConstructor == other.SourceHasPositionalConstructor
113121
&& TypeXmlDocumentation == other.TypeXmlDocumentation
@@ -141,6 +149,8 @@ public override int GetHashCode()
141149
hash = hash * 31 + GenerateExpressionProjection.GetHashCode();
142150
hash = hash * 31 + (SourceTypeName?.GetHashCode() ?? 0);
143151
hash = hash * 31 + (ConfigurationTypeName?.GetHashCode() ?? 0);
152+
hash = hash * 31 + (BeforeMapConfigurationTypeName?.GetHashCode() ?? 0);
153+
hash = hash * 31 + (AfterMapConfigurationTypeName?.GetHashCode() ?? 0);
144154
hash = hash * 31 + HasExistingPrimaryConstructor.GetHashCode();
145155
hash = hash * 31 + SourceHasPositionalConstructor.GetHashCode();
146156
hash = hash * 31 + (TypeXmlDocumentation?.GetHashCode() ?? 0);

src/Facet/Generators/FacetGenerators/AttributeParser.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,38 @@ public static (HashSet<string> includedMembers, bool isIncludeMode) ExtractInclu
165165
.ToString();
166166
}
167167

168+
/// <summary>
169+
/// Extracts the BeforeMapConfiguration type name from the attribute.
170+
/// </summary>
171+
public static string? ExtractBeforeMapConfigurationTypeName(AttributeData attribute)
172+
{
173+
var arg = attribute.NamedArguments
174+
.FirstOrDefault(kvp => kvp.Key == FacetConstants.AttributeNames.BeforeMapConfiguration);
175+
176+
if (arg.Value.Value is INamedTypeSymbol typeSymbol)
177+
{
178+
return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
179+
}
180+
181+
return null;
182+
}
183+
184+
/// <summary>
185+
/// Extracts the AfterMapConfiguration type name from the attribute.
186+
/// </summary>
187+
public static string? ExtractAfterMapConfigurationTypeName(AttributeData attribute)
188+
{
189+
var arg = attribute.NamedArguments
190+
.FirstOrDefault(kvp => kvp.Key == FacetConstants.AttributeNames.AfterMapConfiguration);
191+
192+
if (arg.Value.Value is INamedTypeSymbol typeSymbol)
193+
{
194+
return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
195+
}
196+
197+
return null;
198+
}
199+
168200
/// <summary>
169201
/// Extracts the FlattenTo types from the FlattenTo parameter.
170202
/// Returns a list of fully qualified type names of flatten target types.

src/Facet/Generators/FacetGenerators/ConstructorGenerator.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,17 @@ private static void GenerateMainConstructorBody(
163163
bool hasInitOnlyProperties,
164164
bool hasCustomMapping)
165165
{
166+
var hasBeforeMap = !string.IsNullOrWhiteSpace(model.BeforeMapConfigurationTypeName);
167+
var hasAfterMap = !string.IsNullOrWhiteSpace(model.AfterMapConfigurationTypeName);
168+
166169
if (!isPositional && !model.HasExistingPrimaryConstructor)
167170
{
171+
// Call BeforeMap before property assignment
172+
if (hasBeforeMap)
173+
{
174+
sb.AppendLine($" {model.BeforeMapConfigurationTypeName}.BeforeMap(source, this);");
175+
}
176+
168177
if (hasCustomMapping && hasInitOnlyProperties)
169178
{
170179
// For types with init-only properties and custom mapping,
@@ -195,11 +204,37 @@ private static void GenerateMainConstructorBody(
195204
sb.AppendLine($" this.{m.Name} = {sourceValue};");
196205
}
197206
}
207+
208+
// Call AfterMap after property assignment (and after Configuration.Map if present)
209+
if (hasAfterMap)
210+
{
211+
sb.AppendLine($" {model.AfterMapConfigurationTypeName}.AfterMap(source, this);");
212+
}
198213
}
199214
else if (hasCustomMapping && !model.HasExistingPrimaryConstructor)
200215
{
201216
// For positional records/record structs with custom mapping
217+
if (hasBeforeMap)
218+
{
219+
sb.AppendLine($" {model.BeforeMapConfigurationTypeName}.BeforeMap(source, this);");
220+
}
202221
sb.AppendLine($" global::{model.ConfigurationTypeName}.Map(source, this);");
222+
if (hasAfterMap)
223+
{
224+
sb.AppendLine($" {model.AfterMapConfigurationTypeName}.AfterMap(source, this);");
225+
}
226+
}
227+
else if (!model.HasExistingPrimaryConstructor)
228+
{
229+
// No custom mapping but may have hooks
230+
if (hasBeforeMap)
231+
{
232+
sb.AppendLine($" {model.BeforeMapConfigurationTypeName}.BeforeMap(source, this);");
233+
}
234+
if (hasAfterMap)
235+
{
236+
sb.AppendLine($" {model.AfterMapConfigurationTypeName}.AfterMap(source, this);");
237+
}
203238
}
204239
}
205240

@@ -263,6 +298,15 @@ private static void GenerateDepthAwareConstructorBody(
263298
bool hasInitOnlyProperties,
264299
bool hasCustomMapping)
265300
{
301+
var hasBeforeMap = !string.IsNullOrWhiteSpace(model.BeforeMapConfigurationTypeName);
302+
var hasAfterMap = !string.IsNullOrWhiteSpace(model.AfterMapConfigurationTypeName);
303+
304+
// Call BeforeMap first
305+
if (hasBeforeMap)
306+
{
307+
sb.AppendLine($" {model.BeforeMapConfigurationTypeName}.BeforeMap(source, this);");
308+
}
309+
266310
if (hasCustomMapping && hasInitOnlyProperties)
267311
{
268312
sb.AppendLine($" // This constructor should not be used for types with init-only properties and custom mapping");
@@ -292,6 +336,12 @@ private static void GenerateDepthAwareConstructorBody(
292336
sb.AppendLine($" this.{m.Name} = {sourceValue};");
293337
}
294338
}
339+
340+
// Call AfterMap after property assignment (and after Configuration.Map if present)
341+
if (hasAfterMap)
342+
{
343+
sb.AppendLine($" {model.AfterMapConfigurationTypeName}.AfterMap(source, this);");
344+
}
295345
}
296346

297347
private static void GenerateFromSourceFactoryMethod(StringBuilder sb, FacetTargetModel model, bool hasCustomMapping, bool needsDepthTracking)

src/Facet/Generators/FacetGenerators/ModelBuilder.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ internal static class ModelBuilder
3838
var generateProjection = AttributeParser.GetNamedArg(attribute.NamedArguments, FacetConstants.AttributeNames.GenerateProjection, true);
3939
var generateToSource = AttributeParser.GetNamedArg(attribute.NamedArguments, FacetConstants.AttributeNames.GenerateToSource, false);
4040
var configurationTypeName = AttributeParser.ExtractConfigurationTypeName(attribute);
41+
var beforeMapConfigurationTypeName = AttributeParser.ExtractBeforeMapConfigurationTypeName(attribute);
42+
var afterMapConfigurationTypeName = AttributeParser.ExtractAfterMapConfigurationTypeName(attribute);
4143

4244
// Infer the type kind and whether it's a record from the target type declaration
4345
var (typeKind, isRecord) = TypeAnalyzer.InferTypeKind(targetSymbol);
@@ -186,7 +188,9 @@ internal static class ModelBuilder
186188
maxDepth,
187189
preserveReferences,
188190
baseClassMemberNames,
189-
flattenToTypes);
191+
flattenToTypes,
192+
beforeMapConfigurationTypeName,
193+
afterMapConfigurationTypeName);
190194
}
191195

192196
#region Private Helper Methods

src/Facet/Generators/Shared/FacetConstants.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ public static class AttributeNames
9090
public const string FlattenTo = "FlattenTo";
9191
public const string Include = "Include";
9292
public const string Configuration = "Configuration";
93+
public const string BeforeMapConfiguration = "BeforeMapConfiguration";
94+
public const string AfterMapConfiguration = "AfterMapConfiguration";
9395
public const string IncludeFields = "IncludeFields";
9496
public const string GenerateConstructor = "GenerateConstructor";
9597
public const string GenerateParameterlessConstructor = "GenerateParameterlessConstructor";
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
using Facet.Mapping;
2+
3+
namespace Facet.Tests.UnitTests.Core.Facet.MappingHooksIntegration;
4+
5+
// Test entity for generated hooks
6+
public class GeneratedHooksEntity
7+
{
8+
public int Id { get; set; }
9+
public string FirstName { get; set; } = string.Empty;
10+
public string LastName { get; set; } = string.Empty;
11+
public DateTime DateOfBirth { get; set; }
12+
public bool IsActive { get; set; }
13+
}
14+
15+
// BeforeMap hook configuration
16+
public class GeneratedBeforeMapConfig : IFacetBeforeMapConfiguration<GeneratedHooksEntity, GeneratedBeforeMapFacet>
17+
{
18+
public static void BeforeMap(GeneratedHooksEntity source, GeneratedBeforeMapFacet target)
19+
{
20+
target.MappedAt = DateTime.UtcNow;
21+
}
22+
}
23+
24+
// AfterMap hook configuration
25+
public class GeneratedAfterMapConfig : IFacetAfterMapConfiguration<GeneratedHooksEntity, GeneratedAfterMapFacet>
26+
{
27+
public static void AfterMap(GeneratedHooksEntity source, GeneratedAfterMapFacet target)
28+
{
29+
target.FullName = $"{target.FirstName} {target.LastName}";
30+
}
31+
}
32+
33+
// Combined hooks configuration
34+
public class GeneratedCombinedConfig : IFacetMapHooksConfiguration<GeneratedHooksEntity, GeneratedCombinedFacet>
35+
{
36+
public static void BeforeMap(GeneratedHooksEntity source, GeneratedCombinedFacet target)
37+
{
38+
target.MappedAt = DateTime.UtcNow;
39+
}
40+
41+
public static void AfterMap(GeneratedHooksEntity source, GeneratedCombinedFacet target)
42+
{
43+
target.FullName = $"{target.FirstName} {target.LastName}";
44+
}
45+
}
46+
47+
// Generated facet with BeforeMap
48+
[Facet(typeof(GeneratedHooksEntity), BeforeMapConfiguration = typeof(GeneratedBeforeMapConfig))]
49+
public partial class GeneratedBeforeMapFacet
50+
{
51+
public DateTime MappedAt { get; set; }
52+
}
53+
54+
// Generated facet with AfterMap
55+
[Facet(typeof(GeneratedHooksEntity), AfterMapConfiguration = typeof(GeneratedAfterMapConfig))]
56+
public partial class GeneratedAfterMapFacet
57+
{
58+
public string FullName { get; set; } = string.Empty;
59+
}
60+
61+
// Generated facet with both hooks
62+
[Facet(typeof(GeneratedHooksEntity),
63+
BeforeMapConfiguration = typeof(GeneratedCombinedConfig),
64+
AfterMapConfiguration = typeof(GeneratedCombinedConfig))]
65+
public partial class GeneratedCombinedFacet
66+
{
67+
public DateTime MappedAt { get; set; }
68+
public string FullName { get; set; } = string.Empty;
69+
}
70+
71+
/// <summary>
72+
/// Integration tests for Before/After mapping hooks with generated facets.
73+
/// </summary>
74+
public class MappingHooksIntegrationTests
75+
{
76+
[Fact]
77+
public void GeneratedFacet_WithBeforeMap_ShouldSetMappedAt()
78+
{
79+
// Arrange
80+
var entity = new GeneratedHooksEntity
81+
{
82+
Id = 1,
83+
FirstName = "John",
84+
LastName = "Doe",
85+
DateOfBirth = DateTime.Today.AddYears(-30),
86+
IsActive = true
87+
};
88+
var beforeCall = DateTime.UtcNow;
89+
90+
// Act
91+
var facet = new GeneratedBeforeMapFacet(entity);
92+
var afterCall = DateTime.UtcNow;
93+
94+
// Assert
95+
facet.Id.Should().Be(1);
96+
facet.FirstName.Should().Be("John");
97+
facet.LastName.Should().Be("Doe");
98+
facet.MappedAt.Should().BeOnOrAfter(beforeCall);
99+
facet.MappedAt.Should().BeOnOrBefore(afterCall);
100+
}
101+
102+
[Fact]
103+
public void GeneratedFacet_WithAfterMap_ShouldComputeFullName()
104+
{
105+
// Arrange
106+
var entity = new GeneratedHooksEntity
107+
{
108+
Id = 2,
109+
FirstName = "Jane",
110+
LastName = "Smith",
111+
DateOfBirth = DateTime.Today.AddYears(-25),
112+
IsActive = true
113+
};
114+
115+
// Act
116+
var facet = new GeneratedAfterMapFacet(entity);
117+
118+
// Assert
119+
facet.Id.Should().Be(2);
120+
facet.FirstName.Should().Be("Jane");
121+
facet.LastName.Should().Be("Smith");
122+
facet.FullName.Should().Be("Jane Smith");
123+
}
124+
125+
[Fact]
126+
public void GeneratedFacet_WithCombinedHooks_ShouldCallBothBeforeAndAfter()
127+
{
128+
// Arrange
129+
var entity = new GeneratedHooksEntity
130+
{
131+
Id = 3,
132+
FirstName = "Bob",
133+
LastName = "Johnson",
134+
DateOfBirth = DateTime.Today.AddYears(-40),
135+
IsActive = false
136+
};
137+
var beforeCall = DateTime.UtcNow;
138+
139+
// Act
140+
var facet = new GeneratedCombinedFacet(entity);
141+
var afterCall = DateTime.UtcNow;
142+
143+
// Assert
144+
facet.Id.Should().Be(3);
145+
facet.FirstName.Should().Be("Bob");
146+
facet.LastName.Should().Be("Johnson");
147+
facet.MappedAt.Should().BeOnOrAfter(beforeCall);
148+
facet.MappedAt.Should().BeOnOrBefore(afterCall);
149+
facet.FullName.Should().Be("Bob Johnson");
150+
}
151+
152+
[Fact]
153+
public void GeneratedFacet_WithBeforeMap_ShouldWorkWithFromSource()
154+
{
155+
// Arrange
156+
var entity = new GeneratedHooksEntity
157+
{
158+
Id = 4,
159+
FirstName = "Alice",
160+
LastName = "Brown",
161+
DateOfBirth = DateTime.Today.AddYears(-28),
162+
IsActive = true
163+
};
164+
var beforeCall = DateTime.UtcNow;
165+
166+
// Act
167+
var facet = GeneratedBeforeMapFacet.FromSource(entity);
168+
var afterCall = DateTime.UtcNow;
169+
170+
// Assert
171+
facet.FirstName.Should().Be("Alice");
172+
facet.MappedAt.Should().BeOnOrAfter(beforeCall);
173+
facet.MappedAt.Should().BeOnOrBefore(afterCall);
174+
}
175+
176+
[Fact]
177+
public void GeneratedFacet_WithAfterMap_ShouldWorkWithProjection()
178+
{
179+
// Arrange
180+
var entities = new[]
181+
{
182+
new GeneratedHooksEntity { Id = 1, FirstName = "John", LastName = "Doe" },
183+
new GeneratedHooksEntity { Id = 2, FirstName = "Jane", LastName = "Smith" }
184+
}.AsQueryable();
185+
186+
// Act - Projection doesn't call hooks (they're runtime-only)
187+
var facets = entities.Select(GeneratedAfterMapFacet.Projection).ToList();
188+
189+
// Assert - Properties are mapped but FullName is NOT computed (hooks don't run in projections)
190+
facets.Should().HaveCount(2);
191+
facets[0].FirstName.Should().Be("John");
192+
facets[1].FirstName.Should().Be("Jane");
193+
}
194+
}

0 commit comments

Comments
 (0)