├── .editorconfig
├── .gitignore
├── DomainModeling.Example
├── CharacterSet.cs
├── Color.cs
├── Description.cs
├── DomainModeling.Example.csproj
├── Payment.cs
├── PaymentDummyBuilder.cs
└── Program.cs
├── DomainModeling.Generator
├── AssemblyInspectionExtensions.cs
├── Common
│ ├── SimpleLocation.cs
│ └── StructuralList.cs
├── Configurators
│ ├── DomainModelConfiguratorGenerator.DomainEvents.cs
│ ├── DomainModelConfiguratorGenerator.Entities.cs
│ ├── DomainModelConfiguratorGenerator.Identities.cs
│ ├── DomainModelConfiguratorGenerator.WrapperValueObjects.cs
│ ├── DomainModelConfiguratorGenerator.cs
│ └── EntityFrameworkConfigurationGenerator.cs
├── Constants.cs
├── DomainEventGenerator.cs
├── DomainModeling.Generator.csproj
├── DummyBuilderGenerator.cs
├── EntityGenerator.cs
├── EnumExtensions.cs
├── IGeneratable.cs
├── IdentityGenerator.cs
├── IncrementalValueProviderExtensions.cs
├── JsonSerializationGenerator.cs
├── NamespaceSymbolExtensions.cs
├── SourceGenerator.cs
├── SourceProductionContextExtensions.cs
├── StringExtensions.cs
├── TypeDeclarationSyntaxExtensions.cs
├── TypeSymbolExtensions.cs
├── TypeSyntaxExtensions.cs
├── ValueObjectGenerator.cs
└── WrapperValueObjectGenerator.cs
├── DomainModeling.Tests
├── Common
│ └── StructuralListTests.cs
├── Comparisons
│ ├── DictionaryComparerTests.cs
│ ├── EnumerableComparerTests.cs
│ └── LookupComparerTests.cs
├── DomainModeling.Tests.csproj
├── DummyBuilderTests.cs
├── Entities
│ ├── Entity.SourceGeneratedIdentityTests.cs
│ └── EntityTests.cs
├── EntityFramework
│ └── EntityFrameworkConfigurationGeneratorTests.cs
├── FileScopedNamespaceTests.cs
├── IdentityTests.cs
├── ValueObjectTests.cs
└── WrapperValueObjectTests.cs
├── DomainModeling.sln
├── DomainModeling
├── Attributes
│ ├── DomainEventAttribute.cs
│ ├── DummyBuilderAttribute.cs
│ ├── EntityAttribute.cs
│ ├── IdentityValueObjectAttribute.cs
│ ├── SourceGeneratedAttribute.cs
│ ├── ValueObjectAttribute.cs
│ └── WrapperValueObjectAttribute.cs
├── Comparisons
│ ├── DictionaryComparer.cs
│ ├── EnumerableComparer.cs
│ └── LookupComparer.cs
├── Configuration
│ ├── IDomainEventConfigurator.cs
│ ├── IEntityConfigurator.cs
│ ├── IIdentityConfigurator.cs
│ └── IWrapperValueObjectConfigurator.cs
├── Conversions
│ ├── DomainObjectSerializer.cs
│ ├── FormattingExtensions.cs
│ ├── FormattingHelper.cs
│ ├── ObjectInstantiator.cs
│ ├── ParsingHelper.cs
│ └── Utf8JsonReaderExtensions.cs
├── DomainModeling.csproj
├── DomainObject.cs
├── DummyBuilder.cs
├── Entity.cs
├── IApplicationService.cs
├── IDomainObject.cs
├── IDomainService.cs
├── IEntity.cs
├── IIdentity.cs
├── ISerializableDomainObject.cs
├── IValueObject.cs
├── IWrapperValueObject.cs
├── ValueObject.ValidationHelpers.cs
├── ValueObject.cs
└── WrapperValueObject.cs
├── LICENSE
├── README.md
├── pipeline-publish-preview.yml
├── pipeline-publish-stable.yml
└── pipeline-verify.yml
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Aa][Rr][Mm]/
27 | [Aa][Rr][Mm]64/
28 | bld/
29 | [Bb]in/
30 | [Oo]bj/
31 | [Ll]og/
32 |
33 | # Visual Studio 2015/2017 cache/options directory
34 | .vs/
35 | # Uncomment if you have tasks that create the project's static files in wwwroot
36 | #wwwroot/
37 |
38 | # Visual Studio 2017 auto generated files
39 | Generated\ Files/
40 |
41 | # MSTest test Results
42 | [Tt]est[Rr]esult*/
43 | [Bb]uild[Ll]og.*
44 |
45 | # NUnit
46 | *.VisualState.xml
47 | TestResult.xml
48 | nunit-*.xml
49 |
50 | # Build Results of an ATL Project
51 | [Dd]ebugPS/
52 | [Rr]eleasePS/
53 | dlldata.c
54 |
55 | # Benchmark Results
56 | BenchmarkDotNet.Artifacts/
57 |
58 | # .NET Core
59 | project.lock.json
60 | project.fragment.lock.json
61 | artifacts/
62 |
63 | # StyleCop
64 | StyleCopReport.xml
65 |
66 | # Files built by Visual Studio
67 | *_i.c
68 | *_p.c
69 | *_h.h
70 | *.ilk
71 | *.meta
72 | *.obj
73 | *.iobj
74 | *.pch
75 | *.pdb
76 | *.ipdb
77 | *.pgc
78 | *.pgd
79 | *.rsp
80 | *.sbr
81 | *.tlb
82 | *.tli
83 | *.tlh
84 | *.tmp
85 | *.tmp_proj
86 | *_wpftmp.csproj
87 | *.log
88 | *.vspscc
89 | *.vssscc
90 | .builds
91 | *.pidb
92 | *.svclog
93 | *.scc
94 |
95 | # Chutzpah Test files
96 | _Chutzpah*
97 |
98 | # Visual C++ cache files
99 | ipch/
100 | *.aps
101 | *.ncb
102 | *.opendb
103 | *.opensdf
104 | *.sdf
105 | *.cachefile
106 | *.VC.db
107 | *.VC.VC.opendb
108 |
109 | # Visual Studio profiler
110 | *.psess
111 | *.vsp
112 | *.vspx
113 | *.sap
114 |
115 | # Visual Studio Trace Files
116 | *.e2e
117 |
118 | # TFS 2012 Local Workspace
119 | $tf/
120 |
121 | # Guidance Automation Toolkit
122 | *.gpState
123 |
124 | # ReSharper is a .NET coding add-in
125 | _ReSharper*/
126 | *.[Rr]e[Ss]harper
127 | *.DotSettings.user
128 |
129 | # JustCode is a .NET coding add-in
130 | .JustCode
131 |
132 | # TeamCity is a build add-in
133 | _TeamCity*
134 |
135 | # DotCover is a Code Coverage Tool
136 | *.dotCover
137 |
138 | # AxoCover is a Code Coverage Tool
139 | .axoCover/*
140 | !.axoCover/settings.json
141 |
142 | # Visual Studio code coverage results
143 | *.coverage
144 | *.coveragexml
145 |
146 | # NCrunch
147 | _NCrunch_*
148 | .*crunch*.local.xml
149 | nCrunchTemp_*
150 |
151 | # MightyMoose
152 | *.mm.*
153 | AutoTest.Net/
154 |
155 | # Web workbench (sass)
156 | .sass-cache/
157 |
158 | # Installshield output folder
159 | [Ee]xpress/
160 |
161 | # DocProject is a documentation generator add-in
162 | DocProject/buildhelp/
163 | DocProject/Help/*.HxT
164 | DocProject/Help/*.HxC
165 | DocProject/Help/*.hhc
166 | DocProject/Help/*.hhk
167 | DocProject/Help/*.hhp
168 | DocProject/Help/Html2
169 | DocProject/Help/html
170 |
171 | # Click-Once directory
172 | publish/
173 |
174 | # Publish Web Output
175 | *.[Pp]ublish.xml
176 | *.azurePubxml
177 | # Note: Comment the next line if you want to checkin your web deploy settings,
178 | # but database connection strings (with potential passwords) will be unencrypted
179 | *.pubxml
180 | *.publishproj
181 |
182 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
183 | # checkin your Azure Web App publish settings, but sensitive information contained
184 | # in these scripts will be unencrypted
185 | PublishScripts/
186 |
187 | # NuGet Packages
188 | *.nupkg
189 | # NuGet Symbol Packages
190 | *.snupkg
191 | # The packages folder can be ignored because of Package Restore
192 | **/[Pp]ackages/*
193 | # except build/, which is used as an MSBuild target.
194 | !**/[Pp]ackages/build/
195 | # Uncomment if necessary however generally it will be regenerated when needed
196 | #!**/[Pp]ackages/repositories.config
197 | # NuGet v3's project.json files produces more ignorable files
198 | *.nuget.props
199 | *.nuget.targets
200 |
201 | # Microsoft Azure Build Output
202 | csx/
203 | *.build.csdef
204 |
205 | # Microsoft Azure Emulator
206 | ecf/
207 | rcf/
208 |
209 | # Windows Store app package directories and files
210 | AppPackages/
211 | BundleArtifacts/
212 | Package.StoreAssociation.xml
213 | _pkginfo.txt
214 | *.appx
215 | *.appxbundle
216 | *.appxupload
217 |
218 | # Visual Studio cache files
219 | # files ending in .cache can be ignored
220 | *.[Cc]ache
221 | # but keep track of directories ending in .cache
222 | !?*.[Cc]ache/
223 |
224 | # Others
225 | ClientBin/
226 | ~$*
227 | *~
228 | *.dbmdl
229 | *.dbproj.schemaview
230 | *.jfm
231 | *.pfx
232 | *.publishsettings
233 | orleans.codegen.cs
234 |
235 | # Including strong name files can present a security risk
236 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
237 | #*.snk
238 |
239 | # Since there are multiple workflows, uncomment next line to ignore bower_components
240 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
241 | #bower_components/
242 |
243 | # RIA/Silverlight projects
244 | Generated_Code/
245 |
246 | # Backup & report files from converting an old project file
247 | # to a newer Visual Studio version. Backup files are not needed,
248 | # because we have git ;-)
249 | _UpgradeReport_Files/
250 | Backup*/
251 | UpgradeLog*.XML
252 | UpgradeLog*.htm
253 | ServiceFabricBackup/
254 | *.rptproj.bak
255 |
256 | # SQL Server files
257 | *.mdf
258 | *.ldf
259 | *.ndf
260 |
261 | # Business Intelligence projects
262 | *.rdl.data
263 | *.bim.layout
264 | *.bim_*.settings
265 | *.rptproj.rsuser
266 | *- [Bb]ackup.rdl
267 | *- [Bb]ackup ([0-9]).rdl
268 | *- [Bb]ackup ([0-9][0-9]).rdl
269 |
270 | # Microsoft Fakes
271 | FakesAssemblies/
272 |
273 | # GhostDoc plugin setting file
274 | *.GhostDoc.xml
275 |
276 | # Node.js Tools for Visual Studio
277 | .ntvs_analysis.dat
278 | node_modules/
279 |
280 | # Visual Studio 6 build log
281 | *.plg
282 |
283 | # Visual Studio 6 workspace options file
284 | *.opt
285 |
286 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
287 | *.vbw
288 |
289 | # Visual Studio LightSwitch build output
290 | **/*.HTMLClient/GeneratedArtifacts
291 | **/*.DesktopClient/GeneratedArtifacts
292 | **/*.DesktopClient/ModelManifest.xml
293 | **/*.Server/GeneratedArtifacts
294 | **/*.Server/ModelManifest.xml
295 | _Pvt_Extensions
296 |
297 | # Paket dependency manager
298 | .paket/paket.exe
299 | paket-files/
300 |
301 | # FAKE - F# Make
302 | .fake/
303 |
304 | # CodeRush personal settings
305 | .cr/personal
306 |
307 | # Python Tools for Visual Studio (PTVS)
308 | __pycache__/
309 | *.pyc
310 |
311 | # Cake - Uncomment if you are using it
312 | # tools/**
313 | # !tools/packages.config
314 |
315 | # Tabs Studio
316 | *.tss
317 |
318 | # Telerik's JustMock configuration file
319 | *.jmconfig
320 |
321 | # BizTalk build output
322 | *.btp.cs
323 | *.btm.cs
324 | *.odx.cs
325 | *.xsd.cs
326 |
327 | # OpenCover UI analysis results
328 | OpenCover/
329 |
330 | # Azure Stream Analytics local run output
331 | ASALocalRun/
332 |
333 | # MSBuild Binary and Structured Log
334 | *.binlog
335 |
336 | # NVidia Nsight GPU debugger configuration file
337 | *.nvuser
338 |
339 | # MFractors (Xamarin productivity tool) working folder
340 | .mfractor/
341 |
342 | # Local History for Visual Studio
343 | .localhistory/
344 |
345 | # BeatPulse healthcheck temp database
346 | healthchecksdb
347 |
348 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
349 | MigrationBackup/
350 |
351 | # Ionide (cross platform F# VS Code tools) working folder
352 | .ionide/
353 |
354 |
355 |
356 |
357 | *.swp
358 | *.*~
359 | project.lock.json
360 | .DS_Store
361 | *.pyc
362 | nupkg/
363 |
364 | # Visual Studio Code
365 | .vscode
366 |
367 | # Rider
368 | .idea
369 |
370 | # User-specific files
371 | *.suo
372 | *.user
373 | *.userosscache
374 | *.sln.docstates
375 |
376 | # Build results
377 | [Dd]ebug/
378 | [Dd]ebugPublic/
379 | [Rr]elease/
380 | [Rr]eleases/
381 | x64/
382 | x86/
383 | build/
384 | bld/
385 | [Bb]in/
386 | [Oo]bj/
387 | [Oo]ut/
388 | msbuild.log
389 | msbuild.err
390 | msbuild.wrn
391 |
392 | # Visual Studio 2015
393 | .vs/
--------------------------------------------------------------------------------
/DomainModeling.Example/CharacterSet.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Example;
2 |
3 | ///
4 | /// Demonstrates structural equality with collections.
5 | ///
6 | [ValueObject]
7 | public partial class CharacterSet
8 | {
9 | public override string ToString() => $"[{String.Join(", ", this.Characters)}]";
10 |
11 | public IReadOnlySet Characters { get; private init; }
12 |
13 | public CharacterSet(IEnumerable characters)
14 | {
15 | this.Characters = characters.Distinct().ToHashSet();
16 | }
17 |
18 | public bool ContainsCharacter(char character)
19 | {
20 | return this.Characters.Contains(character);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/DomainModeling.Example/Color.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Example;
2 |
3 | // Use "Go To Definition" on the type to view the source-generated partial
4 | // Uncomment the IComparable interface to see how the generated code changes
5 | [ValueObject]
6 | public partial class Color //: IComparable
7 | {
8 | public static Color RedColor { get; } = new Color(red: UInt16.MaxValue, green: 0, blue: 0);
9 | public static Color GreenColor { get; } = new Color(red: 0, green: UInt16.MaxValue, blue: 0);
10 | public static Color BlueColor { get; } = new Color(red: 0, green: 0, blue: UInt16.MaxValue);
11 |
12 | public ushort Red { get; private init; }
13 | public ushort Green { get; private init; }
14 | public ushort Blue { get; private init; }
15 |
16 | public Color(ushort red, ushort green, ushort blue)
17 | {
18 | this.Red = red;
19 | this.Green = green;
20 | this.Blue = blue;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/DomainModeling.Example/Description.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Example;
2 |
3 | // Use "Go To Definition" on the type to view the source-generated partial
4 | // Uncomment the IComparable interface to see how the generated code changes
5 | [WrapperValueObject]
6 | public partial class Description //: IComparable
7 | {
8 | // For string wrappers, we must define how they are compared
9 | protected override StringComparison StringComparison => StringComparison.OrdinalIgnoreCase;
10 |
11 | // Any component that we define manually is omitted by the generated code
12 | // For example, we can explicitly define the Value property to have greater clarity, since it is quintessential
13 | public string Value { get; private init; }
14 |
15 | // An explicitly defined constructor allows us to enforce the domain rules and invariants
16 | public Description(string value)
17 | {
18 | this.Value = value ?? throw new ArgumentNullException(nameof(value));
19 |
20 | if (this.Value.Length > 255) throw new ArgumentException("Too long.");
21 |
22 | if (ContainsNonWordCharacters(this.Value)) throw new ArgumentException("Nonsense.");
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/DomainModeling.Example/DomainModeling.Example.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Exe
5 | net6.0
6 | Architect.DomainModeling.Example
7 | Architect.DomainModeling.Example
8 | Enable
9 | Enable
10 | False
11 | True
12 | 12
13 |
14 |
15 |
16 |
17 | IDE0290
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/DomainModeling.Example/Payment.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Example;
2 |
3 | // Use "Go To Definition" on the PaymentId type to view its source-generated implementation
4 | public class Payment : Entity // Entity: An Entity identified by a PaymentId, which is a source-generated struct wrapping a string
5 | {
6 | // A default ToString() property based on the type and the Id value is provided by the base class
7 | // Hash code and equality implementations based on the Id value are provided by the base class
8 |
9 | // The Id property is provided by the base class
10 |
11 | public string Currency { get; } // Note that Currency deserves its own value object in practice
12 | public decimal Amount { get; }
13 |
14 | public Payment(string currency, decimal amount)
15 | : base(new PaymentId(Guid.NewGuid().ToString("N"))) // ID generated on construction (see also: https://github.com/TheArchitectDev/Architect.Identities#distributed-ids)
16 | {
17 | this.Currency = currency ?? throw new ArgumentNullException(nameof(currency));
18 | this.Amount = amount;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/DomainModeling.Example/PaymentDummyBuilder.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Example;
2 |
3 | // The source-generated partial provides an appropriate type summary
4 | [DummyBuilder]
5 | public sealed partial class PaymentDummyBuilder
6 | {
7 | // The source-generated partial defines a default value for each property, along with a fluent method to change it
8 |
9 | private string Currency { get; set; } = "EUR"; // Since the source generator cannot guess a decent default currency, we specify it manually
10 |
11 | // The source-generated partial defines a Build() method that invokes the most visible, simplest parameterized constructor
12 | }
13 |
--------------------------------------------------------------------------------
/DomainModeling.Example/Program.cs:
--------------------------------------------------------------------------------
1 | using System.Reflection;
2 | using Newtonsoft.Json;
3 |
4 | namespace Architect.DomainModeling.Example;
5 |
6 | public static class Program
7 | {
8 | public static void Main()
9 | {
10 | // ValueObject
11 | {
12 | Console.WriteLine("Demonstrating ValueObject:");
13 |
14 | var green = new Color(red: 0, green: UInt16.MaxValue, blue: 0);
15 |
16 | Console.WriteLine($"{Color.RedColor == Color.GreenColor}: {Color.RedColor} == {Color.GreenColor} (different values)");
17 | Console.WriteLine($"{Color.GreenColor == green}: {Color.GreenColor} == {green} (different instances, same values)"); // ValueObjects have structural equality
18 |
19 | Console.WriteLine();
20 | }
21 |
22 | // WrapperValueObject
23 | {
24 | Console.WriteLine("Demonstrating WrapperValueObject:");
25 |
26 | var constructedDescription = new Description("Constructed");
27 | var castDescription = (Description)"Cast";
28 |
29 | Console.WriteLine($"Constructed from string: {constructedDescription}");
30 | Console.WriteLine($"Cast from string: {castDescription}");
31 | Console.WriteLine($"Description object cast to string: {(string)constructedDescription}");
32 |
33 | var upper = new Description("CASING");
34 | var lower = new Description("casing");
35 |
36 | Console.WriteLine($"{constructedDescription == castDescription}: {constructedDescription} == {castDescription} (different values)");
37 | Console.WriteLine($"{upper == lower}: {upper} == {lower} (different only in casing, with ignore-case value object)"); // ValueObjects have structural equality, and this one ignores casing
38 |
39 | var serialized = JsonConvert.SerializeObject(new Description("PrettySerializable"));
40 | var deserialized = JsonConvert.DeserializeObject(serialized);
41 |
42 | Console.WriteLine($"JSON-serialized: {serialized}"); // Generated serializers for System.Text.Json and Newtonsoft provide serialization as if there was no wrapper object
43 | Console.WriteLine($"JSON-deserialized: {deserialized}");
44 |
45 | Console.WriteLine();
46 | }
47 |
48 | // Entity
49 | {
50 | Console.WriteLine("Demonstrating Entity:");
51 |
52 | var payment = new Payment("EUR", 1.00m);
53 | var similarPayment = new Payment("EUR", 1.00m);
54 |
55 | Console.WriteLine($"Default ToString() implementation: {payment}");
56 | Console.WriteLine($"{payment.Equals(payment)}: {payment}.Equals({payment}) (same obj)");
57 | Console.WriteLine($"{payment.Equals(similarPayment)}: {payment}.Equals({similarPayment}) (other obj)"); // Entities have ID-based equality
58 |
59 | // Demonstrate two different instances with the same ID, to simulate the entity being loaded from a database twice
60 | typeof(Entity).GetField("k__BackingField", BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(similarPayment, payment.Id);
61 |
62 | Console.WriteLine($"{payment.Equals(similarPayment)}: {payment}.Equals({similarPayment}) (same ID)"); // Entities have ID-based equality
63 |
64 | Console.WriteLine();
65 | }
66 |
67 | // DummyBuilder
68 | {
69 | Console.WriteLine("Demonstrating DummyBuilder:");
70 |
71 | // The builder pattern prevents tight coupling between test methods and constructor signatures, permitting constructor changes without breaking dozens of tests
72 | var defaultPayment = new PaymentDummyBuilder().Build();
73 | var usdPayment = new PaymentDummyBuilder().WithCurrency("USD").Build();
74 |
75 | Console.WriteLine($"Default Payment from builder: {defaultPayment}, {defaultPayment.Currency}, {defaultPayment.Amount}");
76 | Console.WriteLine($"Customized Payment from builder: {usdPayment}, {usdPayment.Currency}, {usdPayment.Amount}");
77 |
78 | Console.WriteLine();
79 | }
80 |
81 | // Collection equality
82 | {
83 | Console.WriteLine("Demonstrating structural equality for collections:");
84 |
85 | var abc = new CharacterSet(new[] { 'a', 'b', 'c', });
86 | var abcd = new CharacterSet(new[] { 'a', 'b', 'c', 'd', });
87 | var abcClone = new CharacterSet(new[] { 'a', 'b', 'c', });
88 |
89 | Console.WriteLine($"{abc == abcd}: {abc} == {abcd} (different values)");
90 | Console.WriteLine($"{abc == abcClone}: {abc} == {abcClone} (different instances, same values in collection)"); // ValueObjects have structural equality
91 |
92 | Console.WriteLine();
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/AssemblyInspectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Generator;
4 |
5 | ///
6 | /// Provides extension methods that help inspect assemblies.
7 | ///
8 | public static class AssemblyInspectionExtensions
9 | {
10 | ///
11 | /// Enumerates the given and all of its referenced instances, recursively.
12 | /// Does not deduplicate.
13 | ///
14 | /// A predicate that can filter out assemblies and prevent further recursion into them.
15 | public static IEnumerable EnumerateAssembliesRecursively(this IAssemblySymbol assemblySymbol, Func? predicate = null)
16 | {
17 | if (predicate is not null && !predicate(assemblySymbol))
18 | yield break;
19 |
20 | yield return assemblySymbol;
21 |
22 | foreach (var module in assemblySymbol.Modules)
23 | foreach (var assembly in module.ReferencedAssemblySymbols)
24 | foreach (var nestedAssembly in EnumerateAssembliesRecursively(assembly, predicate))
25 | yield return nestedAssembly;
26 | }
27 |
28 | ///
29 | /// Enumerates all non-nested types in the given , recursively.
30 | ///
31 | public static IEnumerable EnumerateNonNestedTypes(this INamespaceSymbol namespaceSymbol)
32 | {
33 | foreach (var type in namespaceSymbol.GetTypeMembers())
34 | yield return type;
35 |
36 | foreach (var childNamespace in namespaceSymbol.GetNamespaceMembers())
37 | foreach (var type in EnumerateNonNestedTypes(childNamespace))
38 | yield return type;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Common/SimpleLocation.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 | using Microsoft.CodeAnalysis.Text;
3 |
4 | namespace Architect.DomainModeling.Generator.Common;
5 |
6 | ///
7 | /// Represents a as a simple, serializable structure.
8 | ///
9 | internal sealed record class SimpleLocation
10 | {
11 | public string FilePath { get; }
12 | public TextSpan TextSpan { get; }
13 | public LinePositionSpan LineSpan { get; }
14 |
15 | public SimpleLocation(Location location)
16 | {
17 | var lineSpan = location.GetLineSpan();
18 | this.FilePath = lineSpan.Path;
19 | this.TextSpan = location.SourceSpan;
20 | this.LineSpan = lineSpan.Span;
21 | }
22 |
23 | public SimpleLocation(string filePath, TextSpan textSpan, LinePositionSpan lineSpan)
24 | {
25 | this.FilePath = filePath;
26 | this.TextSpan = textSpan;
27 | this.LineSpan = lineSpan;
28 | }
29 |
30 | #nullable disable
31 | public static implicit operator SimpleLocation(Location location) => location is null ? null : new SimpleLocation(location);
32 | public static implicit operator Location(SimpleLocation location) => location is null ? null : Location.Create(location.FilePath, location.TextSpan, location.LineSpan);
33 | #nullable enable
34 | }
35 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Common/StructuralList.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Generator.Common;
2 |
3 | ///
4 | /// Wraps an in a wrapper with structural equality using the collection's elements.
5 | ///
6 | /// The type of the collection to wrap.
7 | /// The type of the collection's elements.
8 | internal sealed class StructuralList(
9 | TCollection value)
10 | : IEquatable>
11 | where TCollection : IReadOnlyList
12 | {
13 | public TCollection Value { get; } = value ?? throw new ArgumentNullException(nameof(value));
14 |
15 | public override int GetHashCode() => this.Value is TCollection value && value.Count > 0
16 | ? CombineHashCodes(
17 | value.Count,
18 | value[0]?.GetHashCode() ?? 0,
19 | value[value.Count - 1]?.GetHashCode() ?? 0)
20 | : 0;
21 | public override bool Equals(object obj) => obj is StructuralList other && this.Equals(other);
22 |
23 | public bool Equals(StructuralList other)
24 | {
25 | if (other is null)
26 | return false;
27 |
28 | var left = this.Value;
29 | var right = other.Value;
30 |
31 | if (right.Count != left.Count)
32 | return false;
33 |
34 | for (var i = 0; i < left.Count; i++)
35 | if (left[i] is not TElement leftElement ? right[i] is not null : !leftElement.Equals(right[i]))
36 | return false;
37 |
38 | return true;
39 | }
40 |
41 | private static int CombineHashCodes(int count, int firstHashCode, int lastHashCode)
42 | {
43 | var countInHighBits = (ulong)count << 16;
44 |
45 | // In the upper half, combine the count with the first hash code
46 | // In the lower half, combine the count with the last hash code
47 | var combined = ((ulong)firstHashCode ^ countInHighBits) << 33; // Offset by 1 additional bit, because UInt64.GetHashCode() XORs its halves, which would cause 0 for identical first and last (e.g. single element)
48 | combined |= (ulong)lastHashCode ^ countInHighBits;
49 |
50 | return combined.GetHashCode();
51 | }
52 |
53 | public static implicit operator TCollection(StructuralList instance) => instance.Value;
54 | public static implicit operator StructuralList(TCollection value) => new StructuralList(value);
55 | }
56 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.DomainEvents.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Immutable;
2 | using Microsoft.CodeAnalysis;
3 |
4 | namespace Architect.DomainModeling.Generator.Configurators;
5 |
6 | public partial class DomainModelConfiguratorGenerator
7 | {
8 | internal static void GenerateSourceForDomainEvents(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input)
9 | {
10 | context.CancellationToken.ThrowIfCancellationRequested();
11 |
12 | // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called
13 | if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions)
14 | return;
15 |
16 | var targetNamespace = input.Metadata.AssemblyName;
17 |
18 | var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable =>
19 | $"configurator.ConfigureDomainEvent<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(DomainEventGenerator.DomainEventTypeComponents.DefaultConstructor) ? "true" : "false")} }});"));
20 |
21 | var source = $@"
22 | using {Constants.DomainModelingNamespace};
23 |
24 | #nullable enable
25 |
26 | namespace {targetNamespace}
27 | {{
28 | public static class DomainEventDomainModelConfigurator
29 | {{
30 | ///
31 | ///
32 | /// Invokes a callback on the given for each marked domain event type in the current assembly.
33 | ///
34 | ///
35 | /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way.
36 | ///
37 | ///
38 | public static void ConfigureDomainEvents({Constants.DomainModelingNamespace}.Configuration.IDomainEventConfigurator configurator)
39 | {{
40 | {configurationText}
41 | }}
42 | }}
43 | }}
44 | ";
45 |
46 | AddSource(context, source, "DomainEventDomainModelConfigurator", targetNamespace);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Entities.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Immutable;
2 | using Microsoft.CodeAnalysis;
3 |
4 | namespace Architect.DomainModeling.Generator.Configurators;
5 |
6 | public partial class DomainModelConfiguratorGenerator
7 | {
8 | internal static void GenerateSourceForEntities(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input)
9 | {
10 | context.CancellationToken.ThrowIfCancellationRequested();
11 |
12 | // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called
13 | if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions)
14 | return;
15 |
16 | var targetNamespace = input.Metadata.AssemblyName;
17 |
18 | var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable =>
19 | $"configurator.ConfigureEntity<{generatable.ContainingNamespace}.{generatable.TypeName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator.Args() {{ HasDefaultConstructor = {(generatable.ExistingComponents.HasFlag(EntityGenerator.EntityTypeComponents.DefaultConstructor) ? "true" : "false")} }});"));
20 |
21 | var source = $@"
22 | using {Constants.DomainModelingNamespace};
23 |
24 | #nullable enable
25 |
26 | namespace {targetNamespace}
27 | {{
28 | public static class EntityDomainModelConfigurator
29 | {{
30 | ///
31 | ///
32 | /// Invokes a callback on the given for each marked type in the current assembly.
33 | ///
34 | ///
35 | /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way.
36 | ///
37 | ///
38 | public static void ConfigureEntities({Constants.DomainModelingNamespace}.Configuration.IEntityConfigurator configurator)
39 | {{
40 | {configurationText}
41 | }}
42 | }}
43 | }}
44 | ";
45 |
46 | AddSource(context, source, "EntityDomainModelConfigurator", targetNamespace);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.Identities.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Immutable;
2 | using Microsoft.CodeAnalysis;
3 |
4 | namespace Architect.DomainModeling.Generator.Configurators;
5 |
6 | public partial class DomainModelConfiguratorGenerator
7 | {
8 | internal static void GenerateSourceForIdentities(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input)
9 | {
10 | context.CancellationToken.ThrowIfCancellationRequested();
11 |
12 | // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called
13 | if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions)
14 | return;
15 |
16 | var targetNamespace = input.Metadata.AssemblyName;
17 |
18 | var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => $"""
19 | configurator.ConfigureIdentity<{generatable.ContainingNamespace}.{generatable.IdTypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator.Args());
20 | """));
21 |
22 | var source = $@"
23 | using {Constants.DomainModelingNamespace};
24 |
25 | #nullable enable
26 |
27 | namespace {targetNamespace}
28 | {{
29 | public static class IdentityDomainModelConfigurator
30 | {{
31 | ///
32 | ///
33 | /// Invokes a callback on the given for each marked type in the current assembly.
34 | ///
35 | ///
36 | /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way.
37 | ///
38 | ///
39 | public static void ConfigureIdentities({Constants.DomainModelingNamespace}.Configuration.IIdentityConfigurator configurator)
40 | {{
41 | {configurationText}
42 | }}
43 | }}
44 | }}
45 | ";
46 |
47 | AddSource(context, source, "IdentityDomainModelConfigurator", targetNamespace);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.WrapperValueObjects.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Immutable;
2 | using Microsoft.CodeAnalysis;
3 |
4 | namespace Architect.DomainModeling.Generator.Configurators;
5 |
6 | public partial class DomainModelConfiguratorGenerator
7 | {
8 | internal static void GenerateSourceForWrapperValueObjects(SourceProductionContext context, (ImmutableArray Generatables, (bool HasConfigureConventions, string AssemblyName) Metadata) input)
9 | {
10 | context.CancellationToken.ThrowIfCancellationRequested();
11 |
12 | // Generate the method only if we have any generatables, or if we are an assembly in which ConfigureConventions() is called
13 | if (!input.Generatables.Any() && !input.Metadata.HasConfigureConventions)
14 | return;
15 |
16 | var targetNamespace = input.Metadata.AssemblyName;
17 |
18 | var configurationText = String.Join($"{Environment.NewLine}\t\t\t", input.Generatables.Select(generatable => $"""
19 | configurator.ConfigureWrapperValueObject<{generatable.ContainingNamespace}.{generatable.TypeName}, {generatable.UnderlyingTypeFullyQualifiedName}>({Environment.NewLine} new {Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator.Args());
20 | """));
21 |
22 | var source = $@"
23 | using {Constants.DomainModelingNamespace};
24 |
25 | #nullable enable
26 |
27 | namespace {targetNamespace}
28 | {{
29 | public static class WrapperValueObjectDomainModelConfigurator
30 | {{
31 | ///
32 | ///
33 | /// Invokes a callback on the given for each marked type in the current assembly.
34 | ///
35 | ///
36 | /// For example, this can be used to have Entity Framework configure a convention for every matching type in the domain model, in a trim-safe way.
37 | ///
38 | ///
39 | public static void ConfigureWrapperValueObjects({Constants.DomainModelingNamespace}.Configuration.IWrapperValueObjectConfigurator configurator)
40 | {{
41 | {configurationText}
42 | }}
43 | }}
44 | }}
45 | ";
46 |
47 | AddSource(context, source, "WrapperValueObjectDomainModelConfigurator", targetNamespace);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Configurators/DomainModelConfiguratorGenerator.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Generator.Configurators;
4 |
5 | ///
6 | /// Generates DomainModelConfigurators, types intended to configure miscellaneous components when it comes to certain types of domain objects.
7 | ///
8 | public partial class DomainModelConfiguratorGenerator : SourceGenerator
9 | {
10 | public override void Initialize(IncrementalGeneratorInitializationContext context)
11 | {
12 | // Only invoked from other generators
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/Constants.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Generator;
2 |
3 | internal static class Constants
4 | {
5 | public const string DomainModelingNamespace = "Architect.DomainModeling";
6 | public const string DomainObjectInterfaceName = "IDomainObject";
7 | public const string ValueObjectInterfaceTypeName = "IValueObject";
8 | public const string ValueObjectTypeName = "ValueObject";
9 | public const string WrapperValueObjectInterfaceTypeName = "IWrapperValueObject";
10 | public const string WrapperValueObjectTypeName = "WrapperValueObject";
11 | public const string IdentityInterfaceTypeName = "IIdentity";
12 | public const string EntityTypeName = "Entity";
13 | public const string EntityInterfaceName = "IEntity";
14 | public const string DummyBuilderTypeName = "DummyBuilder";
15 | public const string SerializableDomainObjectInterfaceTypeName = "ISerializableDomainObject";
16 | public const string SerializeDomainObjectMethodName = "Serialize";
17 | public const string DeserializeDomainObjectMethodName = "Deserialize";
18 | }
19 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/DomainEventGenerator.cs:
--------------------------------------------------------------------------------
1 | using Architect.DomainModeling.Generator.Common;
2 | using Architect.DomainModeling.Generator.Configurators;
3 | using Microsoft.CodeAnalysis;
4 | using Microsoft.CodeAnalysis.CSharp;
5 | using Microsoft.CodeAnalysis.CSharp.Syntax;
6 |
7 | namespace Architect.DomainModeling.Generator;
8 |
9 | [Generator]
10 | public class DomainEventGenerator : SourceGenerator
11 | {
12 | public override void Initialize(IncrementalGeneratorInitializationContext context)
13 | {
14 | var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode)
15 | .Where(generatable => generatable is not null)
16 | .DeduplicatePartials();
17 |
18 | context.RegisterSourceOutput(provider, GenerateSource!);
19 |
20 | var aggregatedProvider = provider
21 | .Collect()
22 | .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context));
23 |
24 | context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForDomainEvents!);
25 | }
26 |
27 | private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default)
28 | {
29 | // Class or record class
30 | if (node is TypeDeclarationSyntax tds && tds is ClassDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "class" })
31 | {
32 | // With relevant attribute
33 | if (tds.HasAttributeWithPrefix("DomainEvent"))
34 | return true;
35 | }
36 |
37 | return false;
38 | }
39 |
40 | private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default)
41 | {
42 | var model = context.SemanticModel;
43 | var tds = (TypeDeclarationSyntax)context.Node;
44 | var type = model.GetDeclaredSymbol(tds);
45 |
46 | if (type is null)
47 | return null;
48 |
49 | // Only with the attribute
50 | if (type.GetAttribute("DomainEventAttribute", Constants.DomainModelingNamespace, arity: 0) is null)
51 | return null;
52 |
53 | // Only concrete
54 | if (type.IsAbstract)
55 | return null;
56 |
57 | // Only non-generic
58 | if (type.IsGenericType)
59 | return null;
60 |
61 | // Only non-nested
62 | if (type.IsNested())
63 | return null;
64 |
65 | var result = new Generatable()
66 | {
67 | TypeLocation = type.Locations.FirstOrDefault(),
68 | IsDomainObject = type.IsOrImplementsInterface(type => type.IsType(Constants.DomainObjectInterfaceName, Constants.DomainModelingNamespace, arity: 0), out _),
69 | TypeName = type.Name, // Non-generic by filter
70 | ContainingNamespace = type.ContainingNamespace.ToString(),
71 | };
72 |
73 | var existingComponents = DomainEventTypeComponents.None;
74 |
75 | existingComponents |= DomainEventTypeComponents.DefaultConstructor.If(type.Constructors.Any(ctor =>
76 | !ctor.IsStatic && ctor.Parameters.Length == 0 /*&& ctor.DeclaringSyntaxReferences.Length > 0*/));
77 |
78 | result.ExistingComponents = existingComponents;
79 |
80 | return result;
81 | }
82 |
83 | private static void GenerateSource(SourceProductionContext context, Generatable generatable)
84 | {
85 | context.CancellationToken.ThrowIfCancellationRequested();
86 |
87 | // Require the expected inheritance
88 | if (!generatable.IsDomainObject)
89 | {
90 | context.ReportDiagnostic("DomainEventGeneratorUnexpectedInheritance", "Unexpected inheritance",
91 | "Type marked as domain event lacks IDomainObject interface.", DiagnosticSeverity.Warning, generatable.TypeLocation);
92 | return;
93 | }
94 | }
95 |
96 | [Flags]
97 | internal enum DomainEventTypeComponents : ulong
98 | {
99 | None = 0,
100 |
101 | DefaultConstructor = 1 << 1,
102 | }
103 |
104 | internal sealed record Generatable : IGeneratable
105 | {
106 | public bool IsDomainObject { get; set; }
107 | public string TypeName { get; set; } = null!;
108 | public string ContainingNamespace { get; set; } = null!;
109 | public DomainEventTypeComponents ExistingComponents { get; set; }
110 | public SimpleLocation? TypeLocation { get; set; }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/DomainModeling.Generator.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | netstandard2.0
5 | Architect.DomainModeling.Generator
6 | Architect.DomainModeling.Generator
7 | Enable
8 | Enable
9 | 12
10 | False
11 | True
12 | True
13 |
14 |
15 |
16 |
17 | IDE0057
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | runtime; build; native; contentfiles; analyzers; buildtransitive
27 | all
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/EntityGenerator.cs:
--------------------------------------------------------------------------------
1 | using Architect.DomainModeling.Generator.Common;
2 | using Architect.DomainModeling.Generator.Configurators;
3 | using Microsoft.CodeAnalysis;
4 | using Microsoft.CodeAnalysis.CSharp;
5 | using Microsoft.CodeAnalysis.CSharp.Syntax;
6 |
7 | namespace Architect.DomainModeling.Generator;
8 |
9 | [Generator]
10 | public class EntityGenerator : SourceGenerator
11 | {
12 | public override void Initialize(IncrementalGeneratorInitializationContext context)
13 | {
14 | var provider = context.SyntaxProvider.CreateSyntaxProvider(FilterSyntaxNode, TransformSyntaxNode)
15 | .Where(generatable => generatable is not null)
16 | .DeduplicatePartials();
17 |
18 | context.RegisterSourceOutput(provider, GenerateSource!);
19 |
20 | var aggregatedProvider = provider
21 | .Collect()
22 | .Combine(EntityFrameworkConfigurationGenerator.CreateMetadataProvider(context));
23 |
24 | context.RegisterSourceOutput(aggregatedProvider, DomainModelConfiguratorGenerator.GenerateSourceForEntities!);
25 | }
26 |
27 | private static bool FilterSyntaxNode(SyntaxNode node, CancellationToken cancellationToken = default)
28 | {
29 | // Class or record class
30 | if (node is TypeDeclarationSyntax tds && tds is ClassDeclarationSyntax or RecordDeclarationSyntax { ClassOrStructKeyword.ValueText: "class" })
31 | {
32 | // With relevant attribute
33 | if (tds.HasAttributeWithPrefix("Entity"))
34 | return true;
35 | }
36 |
37 | return false;
38 | }
39 |
40 | private static Generatable? TransformSyntaxNode(GeneratorSyntaxContext context, CancellationToken cancellationToken = default)
41 | {
42 | var model = context.SemanticModel;
43 | var tds = (TypeDeclarationSyntax)context.Node;
44 | var type = model.GetDeclaredSymbol(tds);
45 |
46 | if (type is null)
47 | return null;
48 |
49 | // Only with the attribute
50 | if (type.GetAttribute("EntityAttribute", Constants.DomainModelingNamespace, arity: 0) is null)
51 | return null;
52 |
53 | // Only concrete
54 | if (type.IsAbstract)
55 | return null;
56 |
57 | // Only non-generic
58 | if (type.IsGenericType)
59 | return null;
60 |
61 | // Only non-nested
62 | if (type.IsNested())
63 | return null;
64 |
65 | var result = new Generatable()
66 | {
67 | TypeLocation = type.Locations.FirstOrDefault(),
68 | IsEntity = type.IsOrImplementsInterface(type => type.IsType(Constants.EntityInterfaceName, Constants.DomainModelingNamespace, arity: 0), out _),
69 | TypeName = type.Name, // Non-generic by filter
70 | ContainingNamespace = type.ContainingNamespace.ToString(),
71 | };
72 |
73 | var existingComponents = EntityTypeComponents.None;
74 |
75 | existingComponents |= EntityTypeComponents.DefaultConstructor.If(type.Constructors.Any(ctor =>
76 | !ctor.IsStatic && ctor.Parameters.Length == 0 /*&& ctor.DeclaringSyntaxReferences.Length > 0*/));
77 |
78 | result.ExistingComponents = existingComponents;
79 |
80 | return result;
81 | }
82 |
83 | private static void GenerateSource(SourceProductionContext context, Generatable generatable)
84 | {
85 | context.CancellationToken.ThrowIfCancellationRequested();
86 |
87 | // Require the expected inheritance
88 | if (!generatable.IsEntity)
89 | {
90 | context.ReportDiagnostic("EntityGeneratorUnexpectedInheritance", "Unexpected inheritance",
91 | "Type marked as entity lacks IEntity interface.", DiagnosticSeverity.Warning, generatable.TypeLocation);
92 | return;
93 | }
94 | }
95 |
96 | [Flags]
97 | internal enum EntityTypeComponents : ulong
98 | {
99 | None = 0,
100 |
101 | DefaultConstructor = 1 << 1,
102 | }
103 |
104 | internal sealed record Generatable : IGeneratable
105 | {
106 | public bool IsEntity { get; set; }
107 | public string TypeName { get; set; } = null!;
108 | public string ContainingNamespace { get; set; } = null!;
109 | public EntityTypeComponents ExistingComponents { get; set; }
110 | public SimpleLocation? TypeLocation { get; set; }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/EnumExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.CompilerServices;
2 | using System.Runtime.InteropServices;
3 | using Microsoft.CodeAnalysis;
4 |
5 | namespace Architect.DomainModeling.Generator;
6 |
7 | ///
8 | /// Defines extensions on generic and specific enums.
9 | ///
10 | internal static class EnumExtensions
11 | {
12 | static EnumExtensions()
13 | {
14 | // Required to get correct behavior in GetNumericValue, where we overlap the enum type with a ulong, left-aligned
15 | if (!BitConverter.IsLittleEndian)
16 | throw new NotSupportedException("This type is only supported on little-endian architectures.");
17 | }
18 |
19 | ///
20 | /// Returns the source , or if the source was less than that.
21 | ///
22 | public static Accessibility AtLeast(this Accessibility accessibility, Accessibility minimumAccessibility)
23 | {
24 | return accessibility >= minimumAccessibility
25 | ? accessibility
26 | : minimumAccessibility;
27 | }
28 |
29 | ///
30 | /// Returns the code representation of the given , e.g. "protected internal".
31 | ///
32 | /// The result to return for unspecified accessibility.
33 | public static string ToCodeString(this Accessibility accessibility, string unspecified = "")
34 | {
35 | var result = accessibility switch
36 | {
37 | Accessibility.NotApplicable => unspecified,
38 | Accessibility.Private => "private",
39 | Accessibility.ProtectedAndInternal => "private protected",
40 | Accessibility.Protected => "protected",
41 | Accessibility.Internal => "internal",
42 | Accessibility.ProtectedOrInternal => "protected internal",
43 | Accessibility.Public => "public",
44 | _ => throw new NotSupportedException($"Unsupported accessibility: {accessibility}."),
45 | };
46 |
47 | return result;
48 | }
49 |
50 | ///
51 | ///
52 | /// Returns the , or a default value (all flags unset) if is false.
53 | ///
54 | ///
55 | /// This method is intended to help easily modify enum flags conditionally.
56 | ///
57 | ///
58 | /// // Set the SomeFlag if 1 == 2
59 | /// myEnum |= MyEnum.SomeFlag.If(1 == 2);
60 | ///
61 | ///
62 | public static T If(this T enumValue, bool condition)
63 | where T : unmanaged, Enum
64 | {
65 | return condition ? enumValue : default;
66 | }
67 |
68 | ///
69 | ///
70 | /// Returns the , or a default value (all flags unset) if is true.
71 | ///
72 | ///
73 | /// This method is intended to help easily modify enum flags conditionally.
74 | ///
75 | ///
76 | /// // Set the SomeFlag unless 1 == 2
77 | /// myEnum |= MyEnum.SomeFlag.Unless(1 == 2);
78 | ///
79 | ///
80 | public static T Unless(this T enumValue, bool condition)
81 | where T : unmanaged, Enum
82 | {
83 | return condition ? default : enumValue;
84 | }
85 |
86 | ///
87 | /// Efficiently returns whether the has the given set.
88 | ///
89 | public static bool HasFlags(this T subject, T flag)
90 | where T : unmanaged, Enum
91 | {
92 | var numericSubject = GetNumericValue(subject);
93 | var numericFlag = GetNumericValue(flag);
94 |
95 | return (numericSubject & numericFlag) == numericFlag;
96 | }
97 |
98 | ///
99 | ///
100 | /// Returns the numeric value of the given .
101 | ///
102 | ///
103 | /// The resulting can be cast to the intended integral type, even if it is a signed type.
104 | ///
105 | ///
106 | [MethodImpl(MethodImplOptions.AggressiveInlining)]
107 | private static ulong GetNumericValue(T enumValue)
108 | where T : unmanaged, Enum
109 | {
110 | Span ulongSpan = stackalloc ulong[] { 0UL };
111 | var span = MemoryMarshal.Cast(ulongSpan);
112 |
113 | span[0] = enumValue;
114 |
115 | return ulongSpan[0];
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/IGeneratable.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.CompilerServices;
2 | using Microsoft.CodeAnalysis;
3 |
4 | namespace Architect.DomainModeling.Generator;
5 |
6 | ///
7 | ///
8 | /// Interface intended for record types used to store the transformation data of source generators.
9 | ///
10 | ///
11 | /// Extension methods on this type allow additional data (such as an ) to be associated, without that data becoming part of the record's equality implementation.
12 | ///
13 | ///
14 | internal interface IGeneratable
15 | {
16 | }
17 |
18 | internal static class GeneratableExtensions
19 | {
20 | ///
21 | /// Unpacks the boolean value stored in the bit set at position .
22 | ///
23 | public static bool GetBit(this uint bits, int position)
24 | {
25 | var result = (bits >> position) & 1U;
26 | return Unsafe.As(ref result);
27 | }
28 |
29 | ///
30 | /// Stores the given in the bit set at position .
31 | ///
32 | public static void SetBit(ref this uint bits, int position, bool value)
33 | {
34 | // Create a mask to unset the target bit: 1 << position
35 | // Unset the target bit: & (1 << position)
36 |
37 | // Create a mask to write the target bit value: 1 << value
38 | // Write the target bit: | (1 << value)
39 |
40 | bits = bits
41 | & ~(1U << position)
42 | | (Unsafe.As(ref value) << position);
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/IncrementalValueProviderExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Generator;
4 |
5 | ///
6 | /// Provides extension methods on and .
7 | ///
8 | internal static class IncrementalValueProviderExtensions
9 | {
10 | #nullable disable // LINQ-assisted null filtering is not yet detected by the compiler
11 | ///
12 | ///
13 | /// Deduplicates partials, preventing duplicate source generation.
14 | ///
15 | ///
16 | /// Partials are deduplicated by calling Collect(), followed by scattering the ouput again using SelectMany(), but only over Distinct() elements.
17 | ///
18 | ///
19 | /// Since is a result of the transformation, which is based on the semantic model, should be identical for each partial of a type.
20 | ///
21 | ///
22 | /// For a correct result, must implement structural equality.
23 | ///
24 | ///
25 | public static IncrementalValuesProvider DeduplicatePartials(this IncrementalValuesProvider provider)
26 | where T : IEquatable
27 | {
28 | var result = provider.Collect().SelectMany((tuples, ct) => tuples.Distinct());
29 | return result;
30 | }
31 | #nullable enable
32 | }
33 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/JsonSerializationGenerator.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Generator;
2 |
3 | ///
4 | /// Can be used to write JSON serialization source code.
5 | ///
6 | internal static class JsonSerializationGenerator
7 | {
8 | public static string WriteJsonConverterAttribute(string modelTypeName)
9 | {
10 | return $"[System.Text.Json.Serialization.JsonConverter(typeof({modelTypeName}.JsonConverter))]";
11 | }
12 |
13 | public static string WriteNewtonsoftJsonConverterAttribute(string modelTypeName)
14 | {
15 | return $"[Newtonsoft.Json.JsonConverter(typeof({modelTypeName}.NewtonsoftJsonConverter))]";
16 | }
17 |
18 | public static string WriteJsonConverter(
19 | string modelTypeName, string underlyingTypeFullyQualifiedName,
20 | bool numericAsString)
21 | {
22 | var result = $@"
23 | #if NET7_0_OR_GREATER
24 | private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter<{modelTypeName}>
25 | {{
26 | public override {modelTypeName} Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString
27 | ? $@"
28 | // The longer numeric types are not JavaScript-safe, so treat them as strings
29 | DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(reader.TokenType == System.Text.Json.JsonTokenType.String
30 | ? reader.GetParsedString<{underlyingTypeFullyQualifiedName}>(System.Globalization.CultureInfo.InvariantCulture)
31 | : System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options));
32 | "
33 | : $@"
34 | DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options)!);
35 | ")}
36 |
37 | public override void Write(System.Text.Json.Utf8JsonWriter writer, {modelTypeName} value, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString
38 | ? $@"
39 | // The longer numeric types are not JavaScript-safe, so treat them as strings
40 | writer.WriteStringValue(DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(value).Format(stackalloc char[64], ""0.#"", System.Globalization.CultureInfo.InvariantCulture));
41 | "
42 | : $@"
43 | System.Text.Json.JsonSerializer.Serialize(writer, DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(value), options);
44 | ")}
45 |
46 | public override {modelTypeName} ReadAsPropertyName(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>
47 | DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(
48 | ((System.Text.Json.Serialization.JsonConverter<{underlyingTypeFullyQualifiedName}>)options.GetConverter(typeof({underlyingTypeFullyQualifiedName}))).ReadAsPropertyName(ref reader, typeToConvert, options));
49 |
50 | public override void WriteAsPropertyName(System.Text.Json.Utf8JsonWriter writer, {modelTypeName} value, System.Text.Json.JsonSerializerOptions options) =>
51 | ((System.Text.Json.Serialization.JsonConverter<{underlyingTypeFullyQualifiedName}>)options.GetConverter(typeof({underlyingTypeFullyQualifiedName}))).WriteAsPropertyName(
52 | writer,
53 | DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(value)!, options);
54 | }}
55 | #else
56 | private sealed class JsonConverter : System.Text.Json.Serialization.JsonConverter<{modelTypeName}>
57 | {{
58 | public override {modelTypeName} Read(ref System.Text.Json.Utf8JsonReader reader, Type typeToConvert, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString
59 | ? $@"
60 | // The longer numeric types are not JavaScript-safe, so treat them as strings
61 | reader.TokenType == System.Text.Json.JsonTokenType.String
62 | ? ({modelTypeName}){underlyingTypeFullyQualifiedName}.Parse(reader.GetString()!, System.Globalization.CultureInfo.InvariantCulture)
63 | : ({modelTypeName})System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options);
64 | "
65 | : $@"
66 | ({modelTypeName})System.Text.Json.JsonSerializer.Deserialize<{underlyingTypeFullyQualifiedName}>(ref reader, options)!;
67 | ")}
68 |
69 | public override void Write(System.Text.Json.Utf8JsonWriter writer, {modelTypeName} value, System.Text.Json.JsonSerializerOptions options) =>{(numericAsString
70 | ? $@"
71 | // The longer numeric types are not JavaScript-safe, so treat them as strings
72 | writer.WriteStringValue(value.Value.ToString(""0.#"", System.Globalization.CultureInfo.InvariantCulture));
73 | "
74 | : $@"
75 | System.Text.Json.JsonSerializer.Serialize(writer, value.Value, options);
76 | ")}
77 | }}
78 | #endif
79 | ";
80 |
81 | return result;
82 | }
83 |
84 | public static string WriteNewtonsoftJsonConverter(
85 | string modelTypeName, string underlyingTypeFullyQualifiedName,
86 | bool isStruct, bool numericAsString)
87 | {
88 | var result = $@"
89 | #if NET7_0_OR_GREATER
90 | private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter
91 | {{
92 | public override bool CanConvert(Type objectType) =>
93 | objectType == typeof({modelTypeName}){(isStruct ? $" || objectType == typeof({modelTypeName}?)" : "")};
94 |
95 | public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString
96 | ? $@"
97 | // The longer numeric types are not JavaScript-safe, so treat them as strings
98 | reader.Value is null && objectType != typeof({modelTypeName}) // Null data for a nullable value type
99 | ? ({modelTypeName}?)null
100 | : DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(reader.TokenType == Newtonsoft.Json.JsonToken.String
101 | ? {underlyingTypeFullyQualifiedName}.Parse(serializer.Deserialize(reader)!, System.Globalization.CultureInfo.InvariantCulture)
102 | : serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader));
103 | "
104 | : $@"
105 | reader.Value is null && (!typeof({modelTypeName}).IsValueType || objectType != typeof({modelTypeName})) // Null data for a reference type or nullable value type
106 | ? ({modelTypeName}?)null
107 | : DomainObjectSerializer.Deserialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader)!);
108 | ")}
109 |
110 | public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString
111 | ? $@"
112 | // The longer numeric types are not JavaScript-safe, so treat them as strings
113 | serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(instance).ToString(""0.#"", System.Globalization.CultureInfo.InvariantCulture));
114 | "
115 | : $@"
116 | serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : DomainObjectSerializer.Serialize<{modelTypeName}, {underlyingTypeFullyQualifiedName}>(instance));
117 | ")}
118 | }}
119 | #else
120 | private sealed class NewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter
121 | {{
122 | public override bool CanConvert(Type objectType) =>
123 | objectType == typeof({modelTypeName}){(isStruct ? $" || objectType == typeof({modelTypeName}?)" : "")};
124 |
125 | public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString
126 | ? $@"
127 | // The longer numeric types are not JavaScript-safe, so treat them as strings
128 | reader.Value is null && objectType != typeof({modelTypeName}) // Null data for a nullable value type
129 | ? ({modelTypeName}?)null
130 | : reader.TokenType == Newtonsoft.Json.JsonToken.String
131 | ? ({modelTypeName}){underlyingTypeFullyQualifiedName}.Parse(serializer.Deserialize(reader)!, System.Globalization.CultureInfo.InvariantCulture)
132 | : ({modelTypeName})serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader);
133 | "
134 | : $@"
135 | reader.Value is null && (!typeof({modelTypeName}).IsValueType || objectType != typeof({modelTypeName})) // Null data for a reference type or nullable value type
136 | ? ({modelTypeName}?)null
137 | : ({modelTypeName})serializer.Deserialize<{underlyingTypeFullyQualifiedName}>(reader)!;
138 | ")}
139 |
140 | public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer) =>{(numericAsString
141 | ? $@"
142 | // The longer numeric types are not JavaScript-safe, so treat them as strings
143 | serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : instance.Value.ToString(""0.#"", System.Globalization.CultureInfo.InvariantCulture));
144 | "
145 | : $@"
146 | serializer.Serialize(writer, value is not {modelTypeName} instance ? (object?)null : instance.Value);
147 | ")}
148 | }}
149 | #endif
150 | ";
151 |
152 | return result;
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/NamespaceSymbolExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Generator;
4 |
5 | ///
6 | /// Provides extensions on .
7 | ///
8 | internal static class NamespaceSymbolExtensions
9 | {
10 | ///
11 | /// Returns whether the given is or resides in the System namespace.
12 | ///
13 | public static bool IsInSystemNamespace(this INamespaceSymbol namespaceSymbol)
14 | {
15 | while (namespaceSymbol?.ContainingNamespace is not null)
16 | namespaceSymbol = namespaceSymbol.ContainingNamespace;
17 |
18 | return namespaceSymbol?.Name == "System";
19 | }
20 |
21 | ///
22 | /// Returns whether the given has the given .
23 | ///
24 | public static bool HasFullName(this INamespaceSymbol? namespaceSymbol, string fullName)
25 | {
26 | return namespaceSymbol.HasFullName(fullName.AsSpan());
27 | }
28 |
29 | ///
30 | /// Returns whether the given has the given .
31 | ///
32 | public static bool HasFullName(this INamespaceSymbol? namespaceSymbol, ReadOnlySpan fullName)
33 | {
34 | if (namespaceSymbol is null)
35 | return false;
36 |
37 | do
38 | {
39 | var length = namespaceSymbol.Name.Length;
40 |
41 | // If the last component's name mismatches
42 | // Or there is no 'start of string' or dot right before it
43 | // Then the names mismatch
44 | if (!fullName.EndsWith(namespaceSymbol.Name.AsSpan(), StringComparison.Ordinal) ||
45 | !(fullName.Length == length || fullName[fullName.Length - length - 1] == '.'))
46 | {
47 | return false;
48 | }
49 |
50 | fullName = fullName.Slice(0, fullName.Length - namespaceSymbol.Name.Length);
51 | if (fullName.Length > 0) fullName = fullName.Slice(0, fullName.Length - 1); // Slice the '.'
52 |
53 | namespaceSymbol = namespaceSymbol.ContainingNamespace;
54 | } while (namespaceSymbol.ContainingNamespace?.IsGlobalNamespace == false);
55 |
56 | return true;
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/SourceGenerator.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 | using System.Runtime.CompilerServices;
3 | using System.Text;
4 | using Microsoft.CodeAnalysis;
5 | using Microsoft.CodeAnalysis.Text;
6 |
7 | namespace Architect.DomainModeling.Generator;
8 |
9 | ///
10 | /// The base class for s implemented in this package.
11 | ///
12 | public abstract class SourceGenerator : IIncrementalGenerator
13 | {
14 | ///
15 | ///
16 | /// Helps avoid errors caused by duplicate type names.
17 | ///
18 | ///
19 | /// Stores stable string hash codes concatenated with separators: one for each namespace encountered for a { generator, type } pair.
20 | ///
21 | ///
22 | private static ConcurrentDictionary NamespacesByGeneratorAndTypeName { get; } = new ConcurrentDictionary();
23 |
24 | static SourceGenerator()
25 | {
26 | #if DEBUG
27 | // Uncomment the following to debug the source generators
28 | //if (!System.Diagnostics.Debugger.IsAttached) System.Diagnostics.Debugger.Launch();
29 | #endif
30 | }
31 |
32 | // Note: If we ever want to know the .NET version being compiled for, one way could be Execute()'s GeneratorExecutionContext.Compilation.ReferencedAssemblyNames.FirstOrDefault(name => name.Name == "System.Runtime")?.Version.Major
33 |
34 | protected static void AddSource(SourceProductionContext context, string sourceText, string typeName, string containingNamespace,
35 | [CallerFilePath] string? callerFilePath = null)
36 | {
37 | var sourceName = $"{typeName}.g.cs";
38 |
39 | // When type names collide, add a stable hash code based on the namespace
40 | // Note that directly including namespaces in the file name would create hard-to-read file names and risk oversized paths
41 | var uniqueKey = callerFilePath is null
42 | ? typeName
43 | : $"{Path.GetFileNameWithoutExtension(callerFilePath)}:{typeName}";
44 | var stableNamespaceHashCode = containingNamespace.GetStableStringHashCode32();
45 | var hashCodesForTypeName = NamespacesByGeneratorAndTypeName.AddOrUpdate(
46 | key: uniqueKey,
47 | addValue: stableNamespaceHashCode,
48 | updateValueFactory: (key, namespaceHashCodeConcatenation) => namespaceHashCodeConcatenation.Contains(stableNamespaceHashCode)
49 | ? namespaceHashCodeConcatenation
50 | : $"{namespaceHashCodeConcatenation}-{stableNamespaceHashCode}");
51 | if (!hashCodesForTypeName.StartsWith(stableNamespaceHashCode)) // Not the first to want this name
52 | sourceName = $"{typeName}-{stableNamespaceHashCode}.g.cs";
53 |
54 | sourceText = sourceText.NormalizeWhitespace();
55 |
56 | context.AddSource(sourceName, SourceText.From(sourceText, Encoding.UTF8));
57 | }
58 |
59 | public abstract void Initialize(IncrementalGeneratorInitializationContext context);
60 | }
61 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/SourceProductionContextExtensions.cs:
--------------------------------------------------------------------------------
1 | using Architect.DomainModeling.Generator.Common;
2 | using Microsoft.CodeAnalysis;
3 |
4 | namespace Architect.DomainModeling.Generator;
5 |
6 | ///
7 | /// Defines extension methods on .
8 | ///
9 | internal static class SourceProductionContextExtensions
10 | {
11 | ///
12 | /// Shorthand extension method to report a diagnostic, with less boilerplate code.
13 | ///
14 | public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, ISymbol? symbol = null)
15 | {
16 | context.ReportDiagnostic(id, title, description, severity, symbol?.Locations.FirstOrDefault());
17 | }
18 |
19 | ///
20 | /// Shorthand extension method to report a diagnostic, with less boilerplate code.
21 | ///
22 | public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, Location? location)
23 | {
24 | context.ReportDiagnostic(Diagnostic.Create(
25 | new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true),
26 | location));
27 | }
28 |
29 | ///
30 | /// Shorthand extension method to report a diagnostic, with less boilerplate code.
31 | ///
32 | public static void ReportDiagnostic(this SourceProductionContext context, string id, string title, string description, DiagnosticSeverity severity, SimpleLocation? location)
33 | {
34 | context.ReportDiagnostic(Diagnostic.Create(
35 | new DiagnosticDescriptor(id, title, description, "Architect.DomainModeling", severity, isEnabledByDefault: true),
36 | location));
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/StringExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Text.RegularExpressions;
2 |
3 | namespace Architect.DomainModeling.Generator;
4 |
5 | ///
6 | /// Provides extensions on .
7 | ///
8 | public static class StringExtensions
9 | {
10 | ///
11 | /// , but escaped for matching that from a .
12 | ///
13 | private static readonly string RegexNewLine = Regex.Escape(Environment.NewLine);
14 |
15 | private static readonly Regex NewlineRegex = new Regex(@"\r?\n", RegexOptions.Compiled); // Finds the next \r\n pair or \n instance
16 | private static readonly Regex LineFeedWithNeedlessIndentRegex = new Regex(@"\n[ \t]+(?=[\r\n])", RegexOptions.Compiled); // Finds the next line feed with indentations that is otherwise empty
17 | private static readonly Regex ThreeOrMoreNewlinesRegex = new Regex($"(?:{RegexNewLine}){{3,}}", RegexOptions.Compiled); // Finds the next set of 3 or more contiguous newlines
18 | private static readonly Regex OpeningBraceWithTwoNewlinesRegex = new Regex($"{{{RegexNewLine}{RegexNewLine}", RegexOptions.Compiled); // Finds the next opening brace followed by 2 newlines
19 | private static readonly Regex ClosingBraceWithTwoNewlinesRegex = new Regex($"{RegexNewLine}({RegexNewLine}\t* *)}}", RegexOptions.Compiled | RegexOptions.RightToLeft); // Finds the next closing brace preceded by 2 newlines, capturing the last newline and its identation
20 | private static readonly Regex EndSummaryWithTwoNewlinesRegex = new Regex($"{RegexNewLine}{RegexNewLine}", RegexOptions.Compiled); // Finds the next tag followed by 2 newlines
21 | private static readonly Regex CloseAttributeWithTwoNewlinesRegex = new Regex($"]{RegexNewLine}{RegexNewLine}", RegexOptions.Compiled); // Finds the next ] symbol followed by 2 newlines
22 |
23 | private static readonly string Base32Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
24 |
25 | ///
26 | /// Returns the input with the first character made uppercase.
27 | ///
28 | public static string ToTitleCase(this string source)
29 | {
30 | if (source is null) throw new ArgumentNullException(nameof(source));
31 |
32 | if (source.Length == 0 || Char.IsUpper(source[0]))
33 | return source;
34 |
35 | var chars = new char[source.Length];
36 | chars[0] = Char.ToUpperInvariant(source[0]);
37 | source.CopyTo(1, chars, 1, source.Length - 1);
38 |
39 | return new string(chars);
40 | }
41 |
42 | ///
43 | /// Normalizes the whitespace for the given C# source code as much as possible.
44 | ///
45 | public static string NormalizeWhitespace(this string source)
46 | {
47 | source = source.TrimStart(); // Remove starting whitespace
48 | source = NewlineRegex.Replace(source, Environment.NewLine); // Normalize line endings for the executing OS
49 | source = LineFeedWithNeedlessIndentRegex.Replace(source, "\n"); // Remove needless indentation from otherwise empty lines
50 | source = ThreeOrMoreNewlinesRegex.Replace(source, $"{Environment.NewLine}{Environment.NewLine}"); // Remove needless whitespace between paragraphs
51 | source = OpeningBraceWithTwoNewlinesRegex.Replace(source, $"{{{Environment.NewLine}"); // Remove needless whitespace after opening braces
52 | source = ClosingBraceWithTwoNewlinesRegex.Replace(source, $"$1}}"); // Remove needless whitespace before closing braces
53 | source = EndSummaryWithTwoNewlinesRegex.Replace(source, $"{Environment.NewLine}"); // Remove needless whitespace after summaries
54 | source = CloseAttributeWithTwoNewlinesRegex.Replace(source, $"]{Environment.NewLine}"); // Remove needless whitespace between attributes
55 |
56 | return source;
57 | }
58 |
59 | public static int GetStableHashCode32(this string source)
60 | {
61 | var span = source.AsSpan();
62 |
63 | // FNV-1a
64 | // For its performance, collision resistance, and outstanding distribution:
65 | // https://softwareengineering.stackexchange.com/a/145633
66 | unchecked
67 | {
68 | // Inspiration: https://gist.github.com/RobThree/25d764ea6d4849fdd0c79d15cda27d61
69 | // Confirmation: https://gist.github.com/StephenCleary/4f6568e5ab5bee7845943fdaef8426d2
70 |
71 | const uint fnv32Offset = 2166136261;
72 | const uint fnv32Prime = 16777619;
73 |
74 | var result = fnv32Offset;
75 |
76 | for (var i = 0; i < span.Length; i++)
77 | result = (result ^ span[i]) * fnv32Prime;
78 |
79 | return (int)result;
80 | }
81 | }
82 |
83 | public static ulong GetStableHashCode64(this string source)
84 | {
85 | var span = source.AsSpan();
86 |
87 | // FNV-1a
88 | // For its performance, collision resistance, and outstanding distribution:
89 | // https://softwareengineering.stackexchange.com/a/145633
90 | unchecked
91 | {
92 | // Inspiration: https://gist.github.com/RobThree/25d764ea6d4849fdd0c79d15cda27d61
93 |
94 | const ulong fnv64Offset = 14695981039346656037UL;
95 | const ulong fnv64Prime = 1099511628211UL;
96 |
97 | var result = fnv64Offset;
98 |
99 | for (var i = 0; i < span.Length; i++)
100 | result = (result ^ span[i]) * fnv64Prime;
101 |
102 | return result;
103 | }
104 | }
105 |
106 | public static ulong GetStableHashCode64(this string source, ulong offset = 14695981039346656037UL)
107 | {
108 | var span = source.AsSpan();
109 |
110 | // FNV-1a
111 | // For its performance, collision resistance, and outstanding distribution:
112 | // https://softwareengineering.stackexchange.com/a/145633
113 | unchecked
114 | {
115 | // Inspiration: https://gist.github.com/RobThree/25d764ea6d4849fdd0c79d15cda27d61
116 |
117 | const ulong fnv64Prime = 1099511628211UL;
118 |
119 | var result = offset;
120 |
121 | for (var i = 0; i < span.Length; i++)
122 | result = (result ^ span[i]) * fnv64Prime;
123 |
124 | return result;
125 | }
126 | }
127 |
128 | public static string GetStableStringHashCode32(this string source)
129 | {
130 | var hashCode = source.GetStableHashCode32();
131 |
132 | Span bytes = stackalloc byte[8];
133 |
134 | for (var i = 0; i < 4; i++)
135 | bytes[i] = (byte)(hashCode >> 8 * i);
136 |
137 | var chars = new char[13];
138 | ToBase32Chars8(bytes, chars.AsSpan());
139 | var result = new string(chars, 0, 7);
140 |
141 | return result;
142 | }
143 |
144 | public static string GetStableStringHashCode64(this string source)
145 | {
146 | var hashCode = source.GetStableHashCode64();
147 |
148 | Span bytes = stackalloc byte[8];
149 |
150 | for (var i = 0; i < 8; i++)
151 | bytes[i] = (byte)(hashCode >> 8 * i);
152 |
153 | var chars = new char[13];
154 | ToBase32Chars8(bytes, chars.AsSpan());
155 | var result = new string(chars);
156 |
157 | return result;
158 | }
159 |
160 | ///
161 | ///
162 | /// Converts the given 8 bytes to 13 base32 chars.
163 | ///
164 | ///
165 | private static void ToBase32Chars8(ReadOnlySpan bytes, Span chars)
166 | {
167 | System.Diagnostics.Debug.Assert(Base32Alphabet.Length == 32);
168 | System.Diagnostics.Debug.Assert(bytes.Length >= 8);
169 | System.Diagnostics.Debug.Assert(chars.Length >= 13);
170 |
171 | var ulongValue = 0UL;
172 | for (var i = 0; i < 8; i++) ulongValue = (ulongValue << 8) | bytes[i];
173 |
174 | // Can encode 8 bytes as 13 chars
175 | for (var i = 13 - 1; i >= 0; i--)
176 | {
177 | var quotient = ulongValue / 32UL;
178 | var remainder = ulongValue - 32UL * quotient;
179 | ulongValue = quotient;
180 | chars[i] = Base32Alphabet[(int)remainder];
181 | }
182 | }
183 | }
184 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/TypeDeclarationSyntaxExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis.CSharp.Syntax;
2 |
3 | namespace Architect.DomainModeling.Generator;
4 |
5 | ///
6 | /// Provides extensions on .
7 | ///
8 | internal static class TypeDeclarationSyntaxExtensions
9 | {
10 | ///
11 | /// Returns whether the is a nested type.
12 | ///
13 | public static bool IsNested(this TypeDeclarationSyntax typeDeclarationSyntax)
14 | {
15 | var result = typeDeclarationSyntax.Parent is not BaseNamespaceDeclarationSyntax;
16 | return result;
17 | }
18 |
19 | ///
20 | /// Returns whether the has any attributes.
21 | ///
22 | public static bool HasAttributes(this TypeDeclarationSyntax typeDeclarationSyntax)
23 | {
24 | var result = typeDeclarationSyntax.AttributeLists.Count > 0;
25 | return result;
26 | }
27 |
28 | ///
29 | ///
30 | /// Returns whether the is directly annotated with an attribute whose name starts with the given prefix.
31 | ///
32 | ///
33 | /// Prefixes are useful because a developer may type either "[Obsolete]" or "[ObsoleteAttribute]".
34 | ///
35 | ///
36 | public static bool HasAttributeWithPrefix(this TypeDeclarationSyntax typeDeclarationSyntax, string namePrefix)
37 | {
38 | foreach (var attributeList in typeDeclarationSyntax.AttributeLists)
39 | foreach (var attribute in attributeList.Attributes)
40 | if ((attribute.Name is IdentifierNameSyntax identifierName && identifierName.Identifier.ValueText.StartsWith(namePrefix)) ||
41 | (attribute.Name is GenericNameSyntax genericName && genericName.Identifier.ValueText.StartsWith(namePrefix)))
42 | return true;
43 |
44 | return false;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/DomainModeling.Generator/TypeSyntaxExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.CodeAnalysis.CSharp.Syntax;
2 |
3 | namespace Architect.DomainModeling.Generator;
4 |
5 | ///
6 | /// Provides extensions on .
7 | ///
8 | internal static class TypeSyntaxExtensions
9 | {
10 | ///
11 | /// Returns whether the given has the given arity (type parameter count) and (unqualified) name.
12 | ///
13 | /// Pass null to accept any arity.
14 | public static bool HasArityAndName(this TypeSyntax typeSyntax, int? arity, string unqualifiedName)
15 | {
16 | return TryGetArityAndUnqualifiedName(typeSyntax, out var actualArity, out var actualUnqualifiedName) &&
17 | (arity is null || actualArity == arity) &&
18 | actualUnqualifiedName == unqualifiedName;
19 | }
20 |
21 | ///
22 | /// Returns whether the given has the given arity (type parameter count) and (unqualified) name suffix.
23 | ///
24 | /// Pass null to accept any arity.
25 | public static bool HasArityAndNameSuffix(this TypeSyntax typeSyntax, int? arity, string unqualifiedName)
26 | {
27 | return TryGetArityAndUnqualifiedName(typeSyntax, out var actualArity, out var actualUnqualifiedName) &&
28 | (arity is null || actualArity == arity) &&
29 | actualUnqualifiedName.EndsWith(unqualifiedName);
30 | }
31 |
32 | private static bool TryGetArityAndUnqualifiedName(TypeSyntax typeSyntax, out int arity, out string unqualifiedName)
33 | {
34 | if (typeSyntax is SimpleNameSyntax simpleName)
35 | {
36 | arity = simpleName.Arity;
37 | unqualifiedName = simpleName.Identifier.ValueText;
38 | }
39 | else if (typeSyntax is QualifiedNameSyntax qualifiedName)
40 | {
41 | arity = qualifiedName.Arity;
42 | unqualifiedName = qualifiedName.Right.Identifier.ValueText;
43 | }
44 | else if (typeSyntax is AliasQualifiedNameSyntax aliasQualifiedName)
45 | {
46 | arity = aliasQualifiedName.Arity;
47 | unqualifiedName = aliasQualifiedName.Name.Identifier.ValueText;
48 | }
49 | else
50 | {
51 | arity = -1;
52 | unqualifiedName = null!;
53 | return false;
54 | }
55 |
56 | return true;
57 | }
58 |
59 | ///
60 | /// Returns the given 's name, or null if no name can be obtained.
61 | ///
62 | public static string? GetNameOrDefault(this TypeSyntax typeSyntax)
63 | {
64 | return typeSyntax switch
65 | {
66 | SimpleNameSyntax simpleName => simpleName.Identifier.ValueText,
67 | QualifiedNameSyntax qualifiedName => qualifiedName.Right.Identifier.ValueText,
68 | AliasQualifiedNameSyntax aliasQualifiedName => aliasQualifiedName.Name.Identifier.ValueText,
69 | _ => null,
70 | };
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/Common/StructuralListTests.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Immutable;
2 | using Architect.DomainModeling.Generator.Common;
3 | using Xunit;
4 |
5 | namespace Architect.DomainModeling.Tests.Common;
6 |
7 | public sealed class StructuralListTests
8 | {
9 | [Theory]
10 | [InlineData("", "")]
11 | [InlineData("A", "A")]
12 | [InlineData("ABC", "ABC")]
13 | [InlineData("abc", "abc")]
14 | public void Equals_WithEqualElements_ShouldReturnTrue(string leftChars, string rightChars)
15 | {
16 | var left = new StructuralList, char>([.. leftChars]);
17 | var right = new StructuralList, char>([.. rightChars]);
18 |
19 | Assert.Equal(left, right);
20 | }
21 |
22 | [Theory]
23 | [InlineData("", " ")]
24 | [InlineData(" ", "")]
25 | [InlineData("A", "B")]
26 | [InlineData("A", " A")]
27 | [InlineData(" A", "A")]
28 | [InlineData(" A", "A ")]
29 | [InlineData("A ", " A")]
30 | [InlineData("ABC", "abc")]
31 | [InlineData("abc", "ABC")]
32 | public void Equals_WithUnequalElements_ShouldReturnFalse(string leftChars, string rightChars)
33 | {
34 | var left = new StructuralList, char>([.. leftChars]);
35 | var right = new StructuralList, char>([.. rightChars]);
36 |
37 | Assert.NotEqual(left, right);
38 | }
39 |
40 | ///
41 | /// Although technically two unequal objects could have the same hash code, we can test better by constraining our test set and pretending that the hash codes should then be unequal too.
42 | ///
43 | [Theory]
44 | [InlineData("", "")]
45 | [InlineData("A", "A")]
46 | [InlineData("ABC", "ABC")]
47 | [InlineData("abc", "abc")]
48 | [InlineData("", " ")]
49 | [InlineData(" ", "")]
50 | [InlineData("A", "B")]
51 | [InlineData("A", " A")]
52 | [InlineData(" A", "A")]
53 | [InlineData(" A", "A ")]
54 | [InlineData("A ", " A")]
55 | [InlineData("ABC", "abc")]
56 | [InlineData("abc", "ABC")]
57 | public void GetHashCode_BetweenTwoCollections_ShouldMatchTheyEquality(string leftChars, string rightChars)
58 | {
59 | var left = new StructuralList, char>([.. leftChars]);
60 | var right = new StructuralList, char>([.. rightChars]);
61 |
62 | var expectedResult = left.Equals(right);
63 |
64 | var result = left.GetHashCode().Equals(right.GetHashCode());
65 |
66 | Assert.Equal(expectedResult, result);
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/Comparisons/DictionaryComparerTests.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using Architect.DomainModeling.Comparisons;
3 | using Xunit;
4 |
5 | namespace Architect.DomainModeling.Tests.Comparisons;
6 |
7 | public class DictionaryComparerTests
8 | {
9 | [return: NotNullIfNotNull(nameof(keys))]
10 | private static Dictionary? CreateDictionaryWithEqualityComparer(IEnumerable? keys, IEqualityComparer comparer)
11 | where TKey : notnull
12 | {
13 | if (keys is null)
14 | return null;
15 |
16 | var result = new Dictionary(comparer);
17 | foreach (var key in keys)
18 | result[key] = "";
19 | return result;
20 | }
21 |
22 | private static void AssertGetHashCodesEqual(bool expectedResult, IReadOnlyDictionary? left, IReadOnlyDictionary? right)
23 | {
24 | var leftHashCode = DictionaryComparer.GetDictionaryHashCode(left);
25 | var rightHashCode = DictionaryComparer.GetDictionaryHashCode(right);
26 |
27 | var result = leftHashCode == rightHashCode;
28 |
29 | // Dictionaries are order-agnostic and our implementation avoids reading the entire thing
30 | // This may lead to results different from the equality check
31 |
32 | // If the objects are equal, then the hash codes must be too (i.e. no false negatives)
33 | if (expectedResult) Assert.Equal(expectedResult, result);
34 | }
35 |
36 | private static void InterfaceAlternativesReturn(bool expectedResult, Dictionary? left, Dictionary? right)
37 | where TKey : notnull
38 | where TValue : IEquatable
39 | {
40 | Assert.Equal(expectedResult, DictionaryComparer.DictionaryEquals((IDictionary?)left, (IDictionary?)right));
41 | Assert.Equal(expectedResult, DictionaryComparer.DictionaryEquals((IReadOnlyDictionary?)left, (IReadOnlyDictionary?)right));
42 | }
43 |
44 | [Theory]
45 | [InlineData(null, null, true)]
46 | [InlineData(null, "", false)]
47 | [InlineData("", null, false)]
48 | [InlineData("", "", true)]
49 | [InlineData("A", "A", true)]
50 | [InlineData("A", "a", true)]
51 | [InlineData("A", "AA", false)]
52 | public void DictionaryEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult)
53 | {
54 | var left = CreateDictionaryWithEqualityComparer(one is null ? null : new[] { one }, StringComparer.OrdinalIgnoreCase);
55 | var right = CreateDictionaryWithEqualityComparer(two is null ? null : new[] { two }, StringComparer.OrdinalIgnoreCase);
56 |
57 | var result = DictionaryComparer.DictionaryEquals(left, right);
58 |
59 | Assert.Equal(expectedResult, result);
60 | InterfaceAlternativesReturn(result, left, right);
61 | AssertGetHashCodesEqual(result, left, right);
62 | }
63 |
64 | [Fact]
65 | public void DictionaryEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult()
66 | {
67 | var left = CreateDictionaryWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal);
68 | var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.Ordinal);
69 |
70 | if (left is null || right is null)
71 | return; // Implementation does not support custom comparer
72 |
73 | var result = DictionaryComparer.DictionaryEquals(left, right);
74 |
75 | Assert.False(result);
76 | InterfaceAlternativesReturn(result, left, right);
77 | AssertGetHashCodesEqual(result, left, right);
78 | }
79 |
80 | [Fact]
81 | public void DictionaryEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpectedResult()
82 | {
83 | var left = CreateDictionaryWithEqualityComparer(new[] { "A", "a", }, StringComparer.OrdinalIgnoreCase);
84 | var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase);
85 |
86 | if (left is null || right is null)
87 | return; // Implementation does not support custom comparer
88 |
89 | var result = DictionaryComparer.DictionaryEquals(left, right);
90 |
91 | Assert.True(result);
92 | InterfaceAlternativesReturn(result, left, right);
93 | }
94 |
95 | [Fact]
96 | public void DictionaryEquals_WithDifferentCaseComparersWithoutTwoWayEquality_ShouldReturnExpectedResult()
97 | {
98 | var left = CreateDictionaryWithEqualityComparer(new[] { "a", }, StringComparer.Ordinal);
99 | var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase);
100 |
101 | if (left is null || right is null)
102 | return; // Implementation does not support custom comparer
103 |
104 | var result = DictionaryComparer.DictionaryEquals(left, right);
105 |
106 | Assert.False(result);
107 | InterfaceAlternativesReturn(result, left, right);
108 | AssertGetHashCodesEqual(result, left, right);
109 | }
110 |
111 | [Fact]
112 | public void DictionaryEquals_WithDifferentCaseComparersWithTwoWayEquality_ShouldReturnExpectedResult()
113 | {
114 | var left = CreateDictionaryWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal);
115 | var right = CreateDictionaryWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase);
116 |
117 | if (left is null || right is null)
118 | return; // Implementation does not support custom comparer
119 |
120 | var result = DictionaryComparer.DictionaryEquals(left, right);
121 |
122 | Assert.True(result);
123 | InterfaceAlternativesReturn(result, left, right);
124 | AssertGetHashCodesEqual(result, left, right);
125 | }
126 |
127 | [Theory]
128 | [InlineData("", "", true)]
129 | [InlineData("A", "", false)]
130 | [InlineData("", "A", false)]
131 | [InlineData("A", "A", true)]
132 | [InlineData("A", "a", false)]
133 | [InlineData("a", "A", false)]
134 | [InlineData("A", "B", false)]
135 | public void DictionaryEquals_WithSameKeys_ShouldReturnExpectedResultBasedOnValue(string leftValue, string rightValue, bool expectedResult)
136 | {
137 | var left = new Dictionary() { [1] = leftValue };
138 | var right = new Dictionary() { [1] = rightValue };
139 |
140 | var result = DictionaryComparer.DictionaryEquals(left, right);
141 |
142 | Assert.Equal(expectedResult, result);
143 | InterfaceAlternativesReturn(result, left, right);
144 | AssertGetHashCodesEqual(result, left, right);
145 | }
146 |
147 | [Fact]
148 | public void DictionaryEquals_WithSameDataInDifferentOrdering_ShouldReturnExpectedResult()
149 | {
150 | var left = new Dictionary() { [1] = "A", [2] = "B", };
151 | var right = new Dictionary() { [2] = "B", [1] = "A", };
152 |
153 | var result = DictionaryComparer.DictionaryEquals(left, right);
154 |
155 | Assert.True(result);
156 | InterfaceAlternativesReturn(result, left, right);
157 | AssertGetHashCodesEqual(result, left, right);
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/Comparisons/LookupComparerTests.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using Architect.DomainModeling.Comparisons;
3 | using Xunit;
4 |
5 | namespace Architect.DomainModeling.Tests.Comparisons;
6 |
7 | public class LookupComparerTests
8 | {
9 | [return: NotNullIfNotNull(nameof(keys))]
10 | private static ILookup? CreateLookupWithEqualityComparer(IEnumerable? keys, IEqualityComparer? comparer)
11 | {
12 | if (keys is null) return null;
13 |
14 | // This approach avoids duplicate values, which we do not want for the tests that use this method
15 | // For example, if "A" and "a" are added to an ignore-case lookup, only "A" is added, but we would give it two dummy values, which we wish to avoid
16 | var result = keys
17 | .GroupBy(key => key, comparer)
18 | .ToLookup(group => group.Key, _ => "", comparer);
19 |
20 | return result;
21 | }
22 |
23 | private static void AssertGetHashCodesEqual(bool expectedResult, ILookup? left, ILookup? right)
24 | {
25 | var leftHashCode = LookupComparer.GetLookupHashCode(left);
26 | var rightHashCode = LookupComparer.GetLookupHashCode(right);
27 |
28 | var result = leftHashCode == rightHashCode;
29 |
30 | // Lookups are order-agnostic and our implementation avoids reading the entire thing
31 | // This may lead to results different from the equality check
32 |
33 | // If the objects are equal, then the hash codes must be too (i.e. no false negatives)
34 | if (expectedResult) Assert.Equal(expectedResult, result);
35 | }
36 |
37 | ///
38 | /// If this is no longer true, for example because is used instead, then we should adjust the "fast path" in the various equality methods accordingly.
39 | ///
40 | [Fact]
41 | public void LookupGrouping_Regularly_ShouldImplementIList()
42 | {
43 | var lookup = new int[] { 1 }.ToLookup(i => i);
44 |
45 | var grouping = lookup.Single();
46 |
47 | Assert.True(grouping is IList);
48 | }
49 |
50 | [Theory]
51 | [InlineData(null, null, true)]
52 | [InlineData(null, "", false)]
53 | [InlineData("", null, false)]
54 | [InlineData("", "", true)]
55 | [InlineData("A", "A", true)]
56 | [InlineData("A", "a", true)]
57 | [InlineData("A", "AA", false)]
58 | public void LookupEquals_WithStringsAndIgnoreCaseComparer_ShouldReturnExpectedResult(string? one, string? two, bool expectedResult)
59 | {
60 | var left = CreateLookupWithEqualityComparer(one is null ? null : new[] { one }, StringComparer.OrdinalIgnoreCase);
61 | var right = CreateLookupWithEqualityComparer(two is null ? null : new[] { two }, StringComparer.OrdinalIgnoreCase);
62 |
63 | var result = LookupComparer.LookupEquals(left, right);
64 |
65 | Assert.Equal(expectedResult, result);
66 | AssertGetHashCodesEqual(result, left, right);
67 | }
68 |
69 | [Fact]
70 | public void LookupEquals_WithoutTwoWayEquality_ShouldReturnExpectedResult()
71 | {
72 | var left = CreateLookupWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal);
73 | var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.Ordinal);
74 |
75 | if (left is null || right is null)
76 | return; // Implementation does not support custom comparer
77 |
78 | var result = LookupComparer.LookupEquals(left, right);
79 |
80 | Assert.False(result);
81 | AssertGetHashCodesEqual(result, left, right);
82 | }
83 |
84 | [Fact]
85 | public void LookupEquals_WithIgnoreCaseWithTwoWayEquality_ShouldReturnExpectedResult()
86 | {
87 | var left = CreateLookupWithEqualityComparer(new[] { "A", "a", }, StringComparer.OrdinalIgnoreCase);
88 | var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase);
89 |
90 | if (left is null || right is null)
91 | return; // Implementation does not support custom comparer
92 |
93 | var result = LookupComparer.LookupEquals(left, right);
94 |
95 | Assert.True(result);
96 | }
97 |
98 | [Fact]
99 | public void LookupEquals_WithDifferentCaseComparersWithoutTwoWayEquality_ShouldReturnExpectedResult()
100 | {
101 | var left = CreateLookupWithEqualityComparer(new[] { "a", }, StringComparer.Ordinal);
102 | var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase);
103 |
104 | if (left is null || right is null)
105 | return; // Implementation does not support custom comparer
106 |
107 | var result = LookupComparer.LookupEquals(left, right);
108 |
109 | Assert.False(result);
110 | AssertGetHashCodesEqual(result, left, right);
111 | }
112 |
113 | [Fact]
114 | public void LookupEquals_WithDifferentCaseComparersWithTwoWayEquality_ShouldReturnExpectedResult()
115 | {
116 | var left = CreateLookupWithEqualityComparer(new[] { "A", "a", }, StringComparer.Ordinal);
117 | var right = CreateLookupWithEqualityComparer(new[] { "A", }, StringComparer.OrdinalIgnoreCase);
118 |
119 | if (left is null || right is null)
120 | return; // Implementation does not support custom comparer
121 |
122 | var result = LookupComparer.LookupEquals(left, right);
123 |
124 | Assert.True(result);
125 | AssertGetHashCodesEqual(result, left, right);
126 | }
127 |
128 | [Fact]
129 | public void LookupEquals_WithElementsRequiringTwoWayComparison_ShouldReturnExpectedResult()
130 | {
131 | // Left will consider right equal: it contains all of its keys, each with the same set of values
132 | // Right will not consider left equal: it contains all of its keys, but not always with the same set of values
133 | // The values MUST be checked in each direction to ensure that the inequality is detected
134 | var left = new[] { "A" }.ToLookup(key => key, key => key == "A" ? 1 : 2, StringComparer.OrdinalIgnoreCase);
135 | var right = new[] { "A", "a", }.ToLookup(key => key, key => key == "A" ? 1 : 2, StringComparer.OrdinalIgnoreCase);
136 |
137 | var result1 = LookupComparer.LookupEquals(left, right);
138 | var result2 = LookupComparer.LookupEquals(right, left);
139 |
140 | Assert.False(result1);
141 | Assert.False(result2);
142 | AssertGetHashCodesEqual(result1, left, right);
143 | AssertGetHashCodesEqual(result2, right, left);
144 | }
145 |
146 | [Theory]
147 | [InlineData("", "", true)]
148 | [InlineData("A", "", false)]
149 | [InlineData("", "A", false)]
150 | [InlineData("A", "A", true)]
151 | [InlineData("A", "a", false)]
152 | [InlineData("a", "A", false)]
153 | [InlineData("A", "B", false)]
154 | [InlineData("A", "A,A", false)]
155 | [InlineData("A,A", "A", false)]
156 | [InlineData("A,B", "B,A", false)] // Element order matters
157 | public void LookupEquals_WithSameKeys_ShouldReturnExpectedResultBasedOnValues(string leftValueString, string rightValueString, bool expectedResult)
158 | {
159 | var leftValues = leftValueString.Split(",");
160 | var rightValues = rightValueString.Split(",");
161 |
162 | var left = leftValues.ToLookup(value => 1);
163 | var right = rightValues.ToLookup(value => 1);
164 |
165 | var result = LookupComparer.LookupEquals(left, right);
166 |
167 | Assert.Equal(expectedResult, result);
168 | AssertGetHashCodesEqual(result, left, right);
169 | }
170 |
171 | [Fact]
172 | public void LookupEquals_WithSameDataInDifferentKeyOrdering_ShouldReturnExpectedResult()
173 | {
174 | var left = new[] { (1, "A"), (1, "B"), (2, "C"), }.ToLookup(pair => pair.Item1, pair => pair.Item2);
175 | var right = new[] { (2, "C"), (1, "A"), (1, "B"), }.ToLookup(pair => pair.Item1, pair => pair.Item2);
176 |
177 | var result = LookupComparer.LookupEquals(left, right);
178 |
179 | Assert.True(result); // Key order does not matter
180 | AssertGetHashCodesEqual(result, left, right);
181 | }
182 |
183 | [Fact]
184 | public void LookupEquals_WithSameDataInDifferentElementOrdering_ShouldReturnExpectedResult()
185 | {
186 | var left = new[] { (1, "A"), (1, "B"), (2, "C"), }.ToLookup(pair => pair.Item1, pair => pair.Item2);
187 | var right = new[] { (1, "B"), (1, "A"), (2, "C"), }.ToLookup(pair => pair.Item1, pair => pair.Item2);
188 |
189 | var result = LookupComparer.LookupEquals(left, right);
190 |
191 | Assert.False(result); // Element order matters
192 | AssertGetHashCodesEqual(result, left, right);
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/DomainModeling.Tests.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | Architect.DomainModeling.Tests
6 | Architect.DomainModeling.Tests
7 | Enable
8 | Enable
9 | False
10 |
11 |
12 |
13 |
14 |
15 |
16 | CA1861, IDE0290, IDE0305
17 |
18 |
19 |
20 | true
21 | $(BaseIntermediateOutputPath)/GeneratedFiles
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | all
31 | runtime; build; native; contentfiles; analyzers; buildtransitive
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/DummyBuilderTests.cs:
--------------------------------------------------------------------------------
1 | using Architect.DomainModeling.Tests.DummyBuilderTestTypes;
2 | using Xunit;
3 |
4 | namespace Architect.DomainModeling.Tests
5 | {
6 | public class DummyBuilderTests
7 | {
8 | [Fact]
9 | public void Build_Regularly_ShouldReturnExpectedResult()
10 | {
11 | var expectedCreationDateTime = new DateTime(2000, 01, 01, 00, 00, 00, DateTimeKind.Utc).ToLocalTime();
12 |
13 | var result = new TestEntityDummyBuilder().Build();
14 |
15 | Assert.Equal(expectedCreationDateTime, result.CreationDateTime);
16 | Assert.Equal(DateTimeKind.Local, result.CreationDateTime.Kind);
17 | Assert.Equal(1, result.Count);
18 | Assert.Equal("Currency", result.Amount.Currency);
19 | Assert.Equal(1m, result.Amount.Amount.Value);
20 | }
21 |
22 | [Fact]
23 | public void Build_WithCustomizations_ShouldReturnExpectedResult()
24 | {
25 | var expectedCreationDateTime = new DateTime(3000, 01, 01, 00, 00, 00, DateTimeKind.Utc).ToLocalTime();
26 |
27 | var result = new TestEntityDummyBuilder()
28 | .WithCreationDateTime(DateTime.UnixEpoch)
29 | .WithCreationDateTime("3000-01-01") // DateTimes get a numeric overload
30 | .WithCreationDate(DateOnly.MaxValue)
31 | .WithCreationDate("1970-01-01")
32 | .WithCreationTime(TimeOnly.MaxValue)
33 | .WithCreationTime(null) // Overloads must be resolvable to a preferred overload for null (achieved with a dummy optional parameter for the non-preferred overload(s))
34 | .WithCreationTime("02:03:04")
35 | .WithCount(7)
36 | .WithAmount(new Money("OtherCurrency", (Amount)1.23m))
37 | .WithNotAProperty("Whatever")
38 | .Build();
39 |
40 | Assert.Equal(expectedCreationDateTime, result.CreationDateTime);
41 | Assert.Equal(DateTimeKind.Local, result.CreationDateTime.Kind);
42 | Assert.Equal(new DateOnly(1970, 01, 01), result.CreationDate);
43 | Assert.Equal(new TimeOnly(02, 03, 04), result.CreationTime);
44 | Assert.Equal(new DateTime(2000, 01, 01, 00, 00, 00, DateTimeKind.Utc), result.ModificationDateTime); // Generated default
45 | Assert.Equal(7, result.Count);
46 | Assert.Equal("OtherCurrency", result.Amount.Currency);
47 | Assert.Equal(1.23m, result.Amount.Amount.Value);
48 | }
49 |
50 | [Fact]
51 | public void Build_WithStringWrapperValueObject_ShouldUseEntityConstructorParameterName()
52 | {
53 | var result = new StringWrapperTestingDummyBuilder().Build();
54 |
55 | Assert.Equal("FirstName", result.FirstName.Value); // Generated wrapper
56 | Assert.Equal("LastName", result.LastName.Value); // Manual wrapper
57 | }
58 | }
59 |
60 | // Use a namespace, since our source generators dislike nested types
61 | // We will test a somewhat realistic setup: an Entity with some scalars and a ValueObject that itself contains a WrapperValueObject
62 | namespace DummyBuilderTestTypes
63 | {
64 | [DummyBuilder]
65 | public sealed partial class TestEntityDummyBuilder
66 | {
67 | // Demonstrate that we can take priority over the generated members
68 | public TestEntityDummyBuilder WithCreationDateTime(DateTime value) => this.With(b => b.CreationDateTime = value);
69 | }
70 |
71 | [Entity]
72 | public sealed class TestEntity : Entity
73 | {
74 | public DateTime CreationDateTime { get; }
75 | public DateOnly CreationDate { get; }
76 | public TimeOnly CreationTime { get; }
77 | public DateTimeOffset ModificationDateTime { get; }
78 | public ushort Count { get; }
79 | public Money Amount { get; }
80 |
81 | ///
82 | /// The type's simplest non-default constructor should be used by the builder.
83 | ///
84 | /// Used and discarded. Used to display that ctor params are leading, not properties.
85 | public TestEntity(DateTimeOffset creationDateTime, DateOnly creationDate, TimeOnly? creationTime, DateTimeOffset modificationDateTime, ushort count, Money amount, string notAProperty)
86 | : base(new TestEntityId(Guid.NewGuid().ToString("N")))
87 | {
88 | this.CreationDateTime = creationDateTime.LocalDateTime;
89 | this.CreationDate = creationDate;
90 | this.CreationTime = creationTime ?? default;
91 | this.ModificationDateTime = modificationDateTime;
92 | this.Count = count;
93 | this.Amount = amount;
94 |
95 | Console.WriteLine(notAProperty);
96 | }
97 |
98 | [Obsolete("Just here to confirm that the generated source code is not invoking it.", error: true)]
99 | public TestEntity(DateTimeOffset creationDateTime, DateOnly creationDate, TimeOnly creationTime, DateTimeOffset modificationDateTime, ushort count, Money amount, string notAProperty, string moreComplexConstructor)
100 | : this(creationDateTime, creationDate, creationTime, modificationDateTime, count, amount, notAProperty)
101 | {
102 | throw new Exception($"The {nameof(moreComplexConstructor)} should not have been used: {moreComplexConstructor}.");
103 | }
104 |
105 | [Obsolete("Just here to confirm that the generated source code is not invoking it.", error: true)]
106 | public TestEntity()
107 | : base(new TestEntityId(Guid.NewGuid().ToString("N")))
108 | {
109 | throw new Exception($"The default constructor should not have been used.");
110 | }
111 | }
112 |
113 | [WrapperValueObject]
114 | public sealed partial class Amount
115 | {
116 | // The type's simplest non-default constructor should be used by the builder. It is source-generated.
117 |
118 | [Obsolete("Just here to confirm that the generated source code is not invoking it.", error: true)]
119 | public Amount(decimal value, string moreComplexConstructor)
120 | {
121 | throw new Exception($"The {nameof(moreComplexConstructor)} should not have been used: {moreComplexConstructor}.");
122 | }
123 | }
124 |
125 | [ValueObject]
126 | public sealed partial class Money
127 | {
128 | public string Currency { get; private init; }
129 | public Amount Amount { get; private init; }
130 |
131 | ///
132 | /// The type's simplest non-default constructor should be used by the builder.
133 | ///
134 | public Money(string currency, Amount amount)
135 | {
136 | this.Currency = currency ?? throw new ArgumentNullException(nameof(currency));
137 | this.Amount = amount;
138 | }
139 |
140 | [Obsolete("Just here to confirm that the generated source code is not invoking it.", error: true)]
141 | public Money()
142 | {
143 | throw new Exception("The default constructor should not have been used.");
144 | }
145 |
146 | [Obsolete("Just here to confirm that the generated source code is not invoking it.", error: true)]
147 | public Money(string currency, Amount amount, string moreComplexConstructor)
148 | : this(currency, amount)
149 | {
150 | throw new Exception($"The {nameof(moreComplexConstructor)} should not have been used: {moreComplexConstructor}.");
151 | }
152 | }
153 |
154 | public sealed class EmptyType
155 | {
156 | }
157 |
158 | [Obsolete("Should merely compile.", error: true)]
159 | [DummyBuilder]
160 | public sealed partial class EmptyTypeDummyBuilder
161 | {
162 | }
163 |
164 | [WrapperValueObject]
165 | public sealed partial class StringWrapper : WrapperValueObject
166 | {
167 | protected override StringComparison StringComparison => StringComparison.Ordinal;
168 | }
169 |
170 | public sealed class ManualStringWrapper : WrapperValueObject
171 | {
172 | protected override StringComparison StringComparison => StringComparison.Ordinal;
173 | public override string ToString() => this.Value;
174 |
175 | public string Value { get; }
176 |
177 | public ManualStringWrapper(string value)
178 | {
179 | this.Value = value ?? throw new ArgumentNullException(nameof(value));
180 | }
181 | }
182 |
183 | public sealed partial class StringWrapperTestingEntity : Entity
184 | {
185 | public StringWrapper FirstName { get; }
186 | public ManualStringWrapper LastName { get; }
187 |
188 | public StringWrapperTestingEntity(StringWrapper firstName, ManualStringWrapper lastName)
189 | : base(default)
190 | {
191 | this.FirstName = firstName;
192 | this.LastName = lastName;
193 | }
194 | }
195 |
196 | [DummyBuilder]
197 | public sealed partial class StringWrapperTestingDummyBuilder
198 | {
199 | }
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/Entities/EntityTests.cs:
--------------------------------------------------------------------------------
1 | using Xunit;
2 |
3 | namespace Architect.DomainModeling.Tests.Entities;
4 |
5 | public class EntityTests
6 | {
7 | [Theory]
8 | [InlineData(null, false)]
9 | [InlineData(0, true)]
10 | [InlineData(1, false)]
11 | [InlineData(-1, false)]
12 | public void DefaultId_WithClassId_ShouldEquateAsExpected(int? value, bool expectedResult)
13 | {
14 | var instance = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
15 |
16 | Assert.Equal(expectedResult, instance.HasDefaultId());
17 | }
18 |
19 | [Theory]
20 | [InlineData(null, false)]
21 | [InlineData(0, false)]
22 | [InlineData(1, true)]
23 | [InlineData(-1, true)]
24 | public void GetHashCode_WithClassId_ShouldEquateAsExpected(int? value, bool expectedResult)
25 | {
26 | var one = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
27 | var two = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
28 |
29 | Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode()));
30 | }
31 |
32 | [Theory]
33 | [InlineData(null, false)]
34 | [InlineData(0, false)]
35 | [InlineData(1, true)]
36 | [InlineData(-1, true)]
37 | public void Equals_WithClassId_ShouldEquateAsExpected(int? value, bool expectedResult)
38 | {
39 | var one = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
40 | var two = new ClassIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
41 |
42 | Assert.Equal(one, one);
43 | Assert.Equal(two, two);
44 | Assert.Equal(expectedResult, one.Equals(two));
45 | }
46 |
47 | [Theory]
48 | [InlineData(null, true)]
49 | [InlineData(0UL, true)]
50 | [InlineData(1UL, false)]
51 | public void DefaultId_WithStructId_ShouldEquateAsExpected(ulong? value, bool expectedResult)
52 | {
53 | var instance = new StructIdEntity(value is null ? default : new UlongId(value.Value));
54 |
55 | Assert.Equal(expectedResult, instance.HasDefaultId());
56 | }
57 |
58 | [Theory]
59 | [InlineData(null, false)]
60 | [InlineData(0UL, false)]
61 | [InlineData(1UL, true)]
62 | public void GetHashCode_WithStructId_ShouldEquateAsExpected(ulong? value, bool expectedResult)
63 | {
64 | var one = new StructIdEntity(value is null ? default : new UlongId(value.Value));
65 | var two = new StructIdEntity(value is null ? default : new UlongId(value.Value));
66 |
67 | Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode()));
68 | }
69 |
70 | [Theory]
71 | [InlineData(null, false)]
72 | [InlineData(0UL, false)]
73 | [InlineData(1UL, true)]
74 | public void Equals_WithStructId_ShouldEquateAsExpected(ulong? value, bool expectedResult)
75 | {
76 | var one = new StructIdEntity(value is null ? default : new UlongId(value.Value));
77 | var two = new StructIdEntity(value is null ? default : new UlongId(value.Value));
78 |
79 | Assert.Equal(one, one);
80 | Assert.Equal(two, two);
81 | Assert.Equal(expectedResult, one.Equals(two));
82 | }
83 |
84 | [Theory]
85 | [InlineData(null, true)]
86 | [InlineData("", false)]
87 | [InlineData("1", false)]
88 | public void DefaultId_WithStringId_ShouldEquateAsExpected(string? value, bool expectedResult)
89 | {
90 | var instance = new StringIdEntity(value!);
91 |
92 | Assert.Equal(expectedResult, instance.HasDefaultId());
93 | }
94 |
95 | [Theory]
96 | [InlineData(null, false)]
97 | [InlineData("", true)]
98 | [InlineData("1", true)]
99 | public void GetHashCode_WithStringId_ShouldEquateAsExpected(string? value, bool expectedResult)
100 | {
101 | var one = new StringIdEntity(value!);
102 | var two = new StringIdEntity(value!);
103 |
104 | Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode()));
105 | }
106 |
107 | [Theory]
108 | [InlineData(null, false)]
109 | [InlineData("", true)]
110 | [InlineData("1", true)]
111 | public void Equals_WithStringId_ShouldEquateAsExpected(string? value, bool expectedResult)
112 | {
113 | var one = new StringIdEntity(value!);
114 | var two = new StringIdEntity(value!);
115 |
116 | Assert.Equal(one, one);
117 | Assert.Equal(two, two);
118 | Assert.Equal(expectedResult, one.Equals(two));
119 | }
120 |
121 | [Fact]
122 | public void Equals_WithSameIdTypeAndValueButDifferentEntityType_ShouldEquateAsExpected()
123 | {
124 | var one = new StringIdEntity("1");
125 | var two = new OtherStringIdEntity("1");
126 |
127 | Assert.NotEqual((Entity)one, two);
128 | }
129 |
130 | [Theory]
131 | [InlineData(null, true)]
132 | [InlineData("", true)]
133 | [InlineData("1", false)]
134 | public void DefaultId_WithStringWrappingId_ShouldEquateAsExpected(string? value, bool expectedResult)
135 | {
136 | var instance = new StringWrappingIdEntity(value!);
137 |
138 | Assert.Equal(expectedResult, instance.HasDefaultId());
139 | }
140 |
141 | [Theory]
142 | [InlineData(null, false)] // Null and empty string are both treated as the default ID value (and represented as "")
143 | [InlineData("", false)] // Null and empty string are both treated as the default ID value (and represented as "")
144 | [InlineData("1", true)]
145 | public void GetHashCode_WithStringWrappingId_ShouldEquateAsExpected(string? value, bool expectedResult)
146 | {
147 | var one = new StringWrappingIdEntity(value!);
148 | var two = new StringWrappingIdEntity(value!);
149 |
150 | Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode()));
151 | }
152 |
153 | [Theory]
154 | [InlineData(null, false)] // Null and empty string are both treated as the default ID value (and represented as "")
155 | [InlineData("", false)] // Null and empty string are both treated as the default ID value (and represented as "")
156 | [InlineData("1", true)]
157 | public void Equals_WithStringWrappingId_ShouldEquateAsExpected(string? value, bool expectedResult)
158 | {
159 | var one = new StringWrappingIdEntity(value!);
160 | var two = new StringWrappingIdEntity(value!);
161 |
162 | Assert.Equal(one, one);
163 | Assert.Equal(two, two);
164 | Assert.Equal(expectedResult, one.Equals(two));
165 | }
166 |
167 | [Theory]
168 | [InlineData(null, true)]
169 | [InlineData(0, false)] // Interface cannot be constructed, so default is null
170 | [InlineData(1, false)]
171 | [InlineData(-1, false)]
172 | public void DefaultId_WithInterfaceId_ShouldEquateAsExpected(int? value, bool expectedResult)
173 | {
174 | var instance = new InterfaceIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
175 |
176 | Assert.Equal(expectedResult, instance.HasDefaultId());
177 | }
178 |
179 | [Theory]
180 | [InlineData(null, false)]
181 | [InlineData(0, true)] // Interface cannot be constructed, so default is null
182 | [InlineData(1, true)]
183 | [InlineData(-1, true)]
184 | public void GetHashCode_WithInterfaceId_ShouldEquateAsExpected(int? value, bool expectedResult)
185 | {
186 | var one = new InterfaceIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
187 | var two = new InterfaceIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
188 |
189 | Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode()));
190 | }
191 |
192 | [Theory]
193 | [InlineData(null, false)]
194 | [InlineData(0, true)] // Interface cannot be constructed, so default is null
195 | [InlineData(1, true)]
196 | [InlineData(-1, true)]
197 | public void Equals_WithInterfaceId_ShouldEquateAsExpected(int? value, bool expectedResult)
198 | {
199 | var one = new InterfaceIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
200 | var two = new InterfaceIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
201 |
202 | Assert.Equal(one, one);
203 | Assert.Equal(two, two);
204 | Assert.Equal(expectedResult, one.Equals(two));
205 | }
206 |
207 | [Theory]
208 | [InlineData(null, true)]
209 | [InlineData(0, false)] // Abstract cannot be constructed, so default is null
210 | [InlineData(1, false)]
211 | [InlineData(-1, false)]
212 | public void DefaultId_WithAbstractId_ShouldEquateAsExpected(int? value, bool expectedResult)
213 | {
214 | var instance = new AbstractIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
215 |
216 | Assert.Equal(expectedResult, instance.HasDefaultId());
217 | }
218 |
219 | [Theory]
220 | [InlineData(null, false)]
221 | [InlineData(0, true)] // Abstract cannot be constructed, so default is null
222 | [InlineData(1, true)]
223 | [InlineData(-1, true)]
224 | public void GetHashCode_WithAbstractId_ShouldEquateAsExpected(int? value, bool expectedResult)
225 | {
226 | var one = new AbstractIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
227 | var two = new AbstractIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
228 |
229 | Assert.Equal(expectedResult, one.GetHashCode().Equals(two.GetHashCode()));
230 | }
231 |
232 | [Theory]
233 | [InlineData(null, false)]
234 | [InlineData(0, true)] // Abstract cannot be constructed, so default is null
235 | [InlineData(1, true)]
236 | [InlineData(-1, true)]
237 | public void Equals_WithAbstractId_ShouldEquateAsExpected(int? value, bool expectedResult)
238 | {
239 | var one = new AbstractIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
240 | var two = new AbstractIdEntity(value is null ? null! : new ConcreteId() { Value = value.Value, });
241 |
242 | Assert.Equal(one, one);
243 | Assert.Equal(two, two);
244 | Assert.Equal(expectedResult, one.Equals(two));
245 | }
246 |
247 | private sealed class StructIdEntity : Entity
248 | {
249 | public StructIdEntity(ulong id)
250 | : base(new UlongId(id))
251 | {
252 | }
253 |
254 | public bool HasDefaultId() => Equals(this.Id, DefaultId);
255 | }
256 |
257 | private sealed class ClassIdEntity : Entity
258 | {
259 | public ClassIdEntity(ConcreteId id)
260 | : base(id)
261 | {
262 | }
263 |
264 | public bool HasDefaultId() => Equals(this.Id, DefaultId);
265 | }
266 |
267 | private sealed class StringIdEntity : Entity
268 | {
269 | public StringIdEntity(string id)
270 | : base(id)
271 | {
272 | }
273 |
274 | public bool HasDefaultId() => Equals(this.Id, DefaultId);
275 | }
276 |
277 | private sealed class OtherStringIdEntity : Entity
278 | {
279 | public OtherStringIdEntity(string id)
280 | : base(id)
281 | {
282 | }
283 | }
284 |
285 | private sealed class StringWrappingIdEntity : Entity
286 | {
287 | public StringWrappingIdEntity(StringBasedId id)
288 | : base(id)
289 | {
290 | }
291 |
292 | public bool HasDefaultId() => Equals(this.Id, DefaultId);
293 | }
294 |
295 | private sealed class InterfaceIdEntity : Entity
296 | {
297 | public InterfaceIdEntity(IId id)
298 | : base(id)
299 | {
300 | }
301 |
302 | public bool HasDefaultId() => Equals(this.Id, DefaultId);
303 | }
304 |
305 | private sealed class AbstractIdEntity : Entity
306 | {
307 | public AbstractIdEntity(AbstractId id)
308 | : base(id)
309 | {
310 | }
311 |
312 | public bool HasDefaultId() => Equals(this.Id, DefaultId);
313 | }
314 |
315 | private interface IId : IEquatable
316 | {
317 | }
318 |
319 | private abstract class AbstractId : IEquatable, IId
320 | {
321 | public abstract override int GetHashCode();
322 | public abstract override bool Equals(object? obj);
323 | public bool Equals(AbstractId? other) => this.Equals((object?)other);
324 | public bool Equals(IId? other) => this.Equals((object?)other);
325 | }
326 |
327 | private sealed class ConcreteId : AbstractId, IEquatable
328 | {
329 | public override int GetHashCode() => this.Value.GetHashCode();
330 | public override bool Equals(object? obj) => obj is ConcreteId other && Equals(this.Value, other.Value);
331 | public bool Equals(ConcreteId? other) => this.Equals((object?)other);
332 |
333 | public int Value { get; set; }
334 | }
335 | }
336 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/EntityFramework/EntityFrameworkConfigurationGeneratorTests.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.EntityFrameworkCore;
2 | using Microsoft.EntityFrameworkCore.Metadata.Conventions;
3 | using Xunit;
4 |
5 | namespace Architect.DomainModeling.Tests.EntityFramework;
6 |
7 | public sealed class EntityFrameworkConfigurationGeneratorTests : IDisposable
8 | {
9 | internal static bool AllowParameterizedConstructors = true;
10 |
11 | private string UniqueName { get; } = Guid.NewGuid().ToString("N");
12 | private TestDbContext DbContext { get; }
13 |
14 | public EntityFrameworkConfigurationGeneratorTests()
15 | {
16 | this.DbContext = new TestDbContext($"DataSource={this.UniqueName};Mode=Memory;Cache=Shared;");
17 | this.DbContext.Database.OpenConnection();
18 | }
19 |
20 | public void Dispose()
21 | {
22 | this.DbContext.Dispose();
23 | }
24 |
25 | [Fact]
26 | public void ConfigureConventions_WithAllExtensionsCalled_ShouldBeAbleToWorkWithAllDomainObjects()
27 | {
28 | var values = new ValueObjectForEF((Wrapper1ForEF)"One", (Wrapper2ForEF)2);
29 | var entity = new EntityForEF(values);
30 | var domainEvent = new DomainEventForEF(id: 2, ignored: null!);
31 |
32 | this.DbContext.Database.EnsureCreated();
33 | this.DbContext.AddRange(entity, domainEvent);
34 | this.DbContext.SaveChanges();
35 | this.DbContext.ChangeTracker.Clear();
36 |
37 | // Throw if deserialization attempts to use the parameterized constructors
38 | AllowParameterizedConstructors = false;
39 |
40 | var reloadedEntity = this.DbContext.Set().Single();
41 | var reloadedDomainEvent = this.DbContext.Set().Single();
42 |
43 | // Confirm that construction happened as expected
44 | Assert.Throws(Activator.CreateInstance); // Should have no default ctor
45 | Assert.Throws(Activator.CreateInstance); // Should have no default ctor
46 | Assert.Throws(Activator.CreateInstance); // Should have no default ctor
47 | Assert.False(reloadedDomainEvent.HasFieldInitializerRun); // Has no default ctor, so should have used GetUninitializedObject
48 | Assert.True(reloadedEntity.HasFieldInitializerRun); // Has default ctor that should have been used
49 | Assert.True(reloadedEntity.Values.HasFieldInitializerRun); // Should have generated default ctor that should have been used
50 | Assert.True(reloadedEntity.Values.One.HasFieldInitializerRun); // Should have generated default ctor that should have been used
51 | Assert.True(reloadedEntity.Values.Two.HasFieldInitializerRun); // Should have generated default ctor that should have been used
52 |
53 | Assert.Equal(2, reloadedDomainEvent.Id);
54 |
55 | Assert.Equal(2, reloadedEntity.Id.Value);
56 | Assert.Equal("One", reloadedEntity.Values.One);
57 | Assert.Equal(2m, reloadedEntity.Values.Two);
58 | }
59 | }
60 |
61 | internal sealed class TestDbContext(
62 | string connectionString)
63 | : DbContext(new DbContextOptionsBuilder().UseSqlite(connectionString).Options)
64 | {
65 | protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
66 | {
67 | configurationBuilder.Conventions.Remove();
68 | configurationBuilder.Conventions.Remove();
69 | configurationBuilder.Conventions.Remove();
70 |
71 | configurationBuilder.ConfigureDomainModelConventions(domainModel =>
72 | {
73 | domainModel.ConfigureIdentityConventions();
74 | domainModel.ConfigureWrapperValueObjectConventions();
75 | domainModel.ConfigureEntityConventions();
76 | domainModel.ConfigureDomainEventConventions();
77 | });
78 | }
79 |
80 | protected override void OnModelCreating(ModelBuilder modelBuilder)
81 | {
82 | // Configure only which entities, properties, and keys exist
83 | // Do not configure any conversions or constructor bindings, to see that our conventions handle those
84 |
85 | modelBuilder.Entity(builder =>
86 | {
87 | builder.Property(x => x.Id);
88 |
89 | builder.OwnsOne(x => x.Values, values =>
90 | {
91 | values.Property(x => x.One);
92 | values.Property(x => x.Two);
93 | });
94 |
95 | builder.HasKey(x => x.Id);
96 | });
97 |
98 | modelBuilder.Entity(builder =>
99 | {
100 | builder.Property(x => x.Id);
101 |
102 | builder.HasKey(x => x.Id);
103 | });
104 | }
105 | }
106 |
107 | [DomainEvent]
108 | internal sealed class DomainEventForEF : IDomainObject
109 | {
110 | ///
111 | /// This lets us test if a constructor as used or not.
112 | ///
113 | public bool HasFieldInitializerRun { get; } = true;
114 |
115 | public DomainEventForEFId Id { get; set; } = 1;
116 |
117 | public DomainEventForEF(DomainEventForEFId id, object ignored)
118 | {
119 | if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors)
120 | throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors.");
121 |
122 | _ = ignored;
123 |
124 | this.Id = id;
125 | }
126 | }
127 | [IdentityValueObject]
128 | public readonly partial record struct DomainEventForEFId;
129 |
130 | [Entity]
131 | internal sealed class EntityForEF : Entity
132 | {
133 | ///
134 | /// This lets us test if a constructor as used or not.
135 | ///
136 | public bool HasFieldInitializerRun { get; } = true;
137 |
138 | public ValueObjectForEF Values { get; }
139 |
140 | public EntityForEF(ValueObjectForEF values)
141 | : base(id: 2)
142 | {
143 | if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors)
144 | throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors.");
145 |
146 | this.Values = values;
147 | }
148 |
149 | #pragma warning disable CS8618 // Reconstitution constructor
150 | private EntityForEF()
151 | : base(default)
152 | {
153 | }
154 | #pragma warning restore CS8618
155 | }
156 |
157 | [WrapperValueObject]
158 | internal sealed partial class Wrapper1ForEF
159 | {
160 | protected override StringComparison StringComparison => StringComparison.Ordinal;
161 |
162 | ///
163 | /// This lets us test if a constructor as used or not.
164 | ///
165 | public bool HasFieldInitializerRun { get; } = true;
166 |
167 | public Wrapper1ForEF(string value)
168 | {
169 | if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors)
170 | throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors.");
171 |
172 | this.Value = value ?? throw new ArgumentNullException(nameof(value));
173 | }
174 | }
175 |
176 | [WrapperValueObject]
177 | internal sealed partial class Wrapper2ForEF
178 | {
179 | ///
180 | /// This lets us test if a constructor as used or not.
181 | ///
182 | public bool HasFieldInitializerRun { get; } = true;
183 |
184 | public Wrapper2ForEF(decimal value)
185 | {
186 | if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors)
187 | throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors.");
188 |
189 | this.Value = value;
190 | }
191 | }
192 |
193 | [ValueObject]
194 | internal sealed partial class ValueObjectForEF
195 | {
196 | ///
197 | /// This lets us test if a constructor as used or not.
198 | ///
199 | public bool HasFieldInitializerRun = true;
200 |
201 | public Wrapper1ForEF One { get; private init; }
202 | public Wrapper2ForEF Two { get; private init; }
203 |
204 | public ValueObjectForEF(Wrapper1ForEF one, Wrapper2ForEF two)
205 | {
206 | if (!EntityFrameworkConfigurationGeneratorTests.AllowParameterizedConstructors)
207 | throw new InvalidOperationException("Deserialization was not allowed to use the parameterized constructors.");
208 |
209 | this.One = one;
210 | this.Two = two;
211 | }
212 | }
213 |
--------------------------------------------------------------------------------
/DomainModeling.Tests/FileScopedNamespaceTests.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Tests;
2 |
3 | // This file tests source generation in combination with C# 10's FileScopedNamespaces, which initially was wrongfully flagged as nested types
4 |
5 | [ValueObject]
6 | public partial class FileScopedNamespaceValueObject
7 | {
8 | public override string ToString() => throw new NotSupportedException();
9 | }
10 |
11 | [WrapperValueObject]
12 | public partial class FileScopedNamespaceWrapperValueObject
13 | {
14 | }
15 |
16 | [DummyBuilder]
17 | public partial class FileScopedDummyBuilder
18 | {
19 | }
20 |
21 | [IdentityValueObject]
22 | public partial struct FileScopedId
23 | {
24 | }
25 |
26 | public partial class FileScopedNamespaceEntity : Entity
27 | {
28 | public FileScopedNamespaceEntity()
29 | : base(default)
30 | {
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/DomainModeling.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.1.32328.378
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DomainModeling.Example", "DomainModeling.Example\DomainModeling.Example.csproj", "{66195298-4899-4F4E-BE32-0FC7B697C343}"
7 | EndProject
8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DomainModeling", "DomainModeling\DomainModeling.csproj", "{CEFD6067-C690-4B97-9F52-98EB9220233C}"
9 | EndProject
10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DomainModeling.Tests", "DomainModeling.Tests\DomainModeling.Tests.csproj", "{483B8791-0029-416E-BC3A-C62E3FF22EE3}"
11 | EndProject
12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DomainModeling.Generator", "DomainModeling.Generator\DomainModeling.Generator.csproj", "{F4035B17-3F4B-4298-A68E-AD3B730A4DB6}"
13 | EndProject
14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{08DABA83-2014-4A2F-A584-B5FFA6FEA45D}"
15 | ProjectSection(SolutionItems) = preProject
16 | .editorconfig = .editorconfig
17 | pipeline-publish-preview.yml = pipeline-publish-preview.yml
18 | pipeline-publish-stable.yml = pipeline-publish-stable.yml
19 | pipeline-verify.yml = pipeline-verify.yml
20 | README.md = README.md
21 | LICENSE = LICENSE
22 | EndProjectSection
23 | EndProject
24 | Global
25 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
26 | Debug|Any CPU = Debug|Any CPU
27 | Release|Any CPU = Release|Any CPU
28 | EndGlobalSection
29 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
30 | {66195298-4899-4F4E-BE32-0FC7B697C343}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
31 | {66195298-4899-4F4E-BE32-0FC7B697C343}.Debug|Any CPU.Build.0 = Debug|Any CPU
32 | {66195298-4899-4F4E-BE32-0FC7B697C343}.Release|Any CPU.ActiveCfg = Release|Any CPU
33 | {66195298-4899-4F4E-BE32-0FC7B697C343}.Release|Any CPU.Build.0 = Release|Any CPU
34 | {CEFD6067-C690-4B97-9F52-98EB9220233C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
35 | {CEFD6067-C690-4B97-9F52-98EB9220233C}.Debug|Any CPU.Build.0 = Debug|Any CPU
36 | {CEFD6067-C690-4B97-9F52-98EB9220233C}.Release|Any CPU.ActiveCfg = Release|Any CPU
37 | {CEFD6067-C690-4B97-9F52-98EB9220233C}.Release|Any CPU.Build.0 = Release|Any CPU
38 | {483B8791-0029-416E-BC3A-C62E3FF22EE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
39 | {483B8791-0029-416E-BC3A-C62E3FF22EE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
40 | {483B8791-0029-416E-BC3A-C62E3FF22EE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
41 | {483B8791-0029-416E-BC3A-C62E3FF22EE3}.Release|Any CPU.Build.0 = Release|Any CPU
42 | {F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
43 | {F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
44 | {F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
45 | {F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Release|Any CPU.Build.0 = Release|Any CPU
46 | EndGlobalSection
47 | GlobalSection(SolutionProperties) = preSolution
48 | HideSolutionNode = FALSE
49 | EndGlobalSection
50 | GlobalSection(ExtensibilityGlobals) = postSolution
51 | SolutionGuid = {313559F1-D96F-4814-A8DD-5DA7A6E3726F}
52 | EndGlobalSection
53 | EndGlobal
54 |
--------------------------------------------------------------------------------
/DomainModeling/Attributes/DomainEventAttribute.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling;
2 |
3 | ///
4 | ///
5 | /// Marks a type as a DDD domain event in the domain model.
6 | ///
7 | ///
8 | /// Although the package currently offers no direction for how to work with domain events, this attribute allows them to be marked, and possibly included in source generators that are based on domain object types.
9 | ///
10 | ///
11 | /// This attribute should only be applied to concrete types.
12 | /// For example, if TransactionSettledEvent is a concrete type inheriting from abstract type FinancialEvent, then only TransactionSettledEvent should have the attribute.
13 | ///
14 | ///
15 | [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
16 | public class DomainEventAttribute : Attribute
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/DomainModeling/Attributes/DummyBuilderAttribute.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling;
2 |
3 | ///
4 | ///
5 | /// Marks a type as a dummy builder that can produce instances of , such as for testing.
6 | ///
7 | ///
8 | /// Dummy builders make it easy to produce non-empty instances.
9 | ///
10 | ///
11 | /// Specific default values can be customized by manually declaring the corresponding properties or fields.
12 | /// These can simply be copied from the source-generated implementation and then changed.
13 | ///
14 | ///
15 | /// Additional With*() methods can be added by imitating the source-generated implementation, either delegating to other With*() methods or assigning the properties or fields.
16 | ///
17 | ///
18 | /// This attribute should only be applied to concrete types.
19 | /// For example, if PaymentDummyBuilder is a concrete dummy builder type inheriting from abstract type FinancialDummyBuilder, then only PaymentDummyBuilder should have the attribute.
20 | ///
21 | ///
22 | /// The model type produced by the annotated dummy builder.
23 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
24 | public class DummyBuilderAttribute : Attribute
25 | where TModel : notnull
26 | {
27 | }
28 |
--------------------------------------------------------------------------------
/DomainModeling/Attributes/EntityAttribute.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling;
2 |
3 | ///
4 | ///
5 | /// Marks a type as a DDD entity in the domain model.
6 | ///
7 | ///
8 | /// If the annotated type is also partial, the source generator kicks in to complete it.
9 | ///
10 | ///
11 | /// This attribute should only be applied to concrete types.
12 | /// For example, if Banana and Strawberry are two concrete entity types inheriting from type Fruit, then only Banana and Strawberry should have the attribute.
13 | ///
14 | ///
15 | [AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
16 | public class EntityAttribute : Attribute
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/DomainModeling/Attributes/IdentityValueObjectAttribute.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling;
2 |
3 | ///
4 | ///
5 | /// Marks a type as a DDD identity value object in the domain model, i.e. a value object containing an ID, with underlying type .
6 | ///
7 | ///
8 | /// If the annotated type is also a partial struct, the source generator kicks in to complete it.
9 | ///
10 | ///
11 | /// Note that identity types tend to have no validation.
12 | /// For example, even though no entity might exist for IDs 0 and 999999999999, they are still valid ID values for which such a question could be asked.
13 | /// If validation is desirable for an ID type, such as for a third-party ID that is expected to fit within given length, then a wrapper value object is worth considering.
14 | ///
15 | ///
16 | /// The underlying type wrapped by the annotated identity type.
17 | [AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
18 | public class IdentityValueObjectAttribute : ValueObjectAttribute
19 | where T : notnull, IEquatable, IComparable
20 | {
21 | }
22 |
--------------------------------------------------------------------------------
/DomainModeling/Attributes/SourceGeneratedAttribute.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling;
2 |
3 | ///
4 | ///
5 | /// Indicates that additional source code is generated for the type at compile time.
6 | ///
7 | ///
8 | /// This attribute only takes effect is the type is marked as partial and has an interface or base class that supports source generation.
9 | ///
10 | ///
11 | [Obsolete("This attribute is deprecated. Replace it by the [Entity], [ValueObject], [WrapperValueObject], [IdentityValueObject], [DomainEvent], or [DummyBuilder] attribute instead.", error: true)]
12 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
13 | public class SourceGeneratedAttribute : Attribute
14 | {
15 | }
16 |
--------------------------------------------------------------------------------
/DomainModeling/Attributes/ValueObjectAttribute.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling;
2 |
3 | ///
4 | ///
5 | /// Marks a type as a DDD value object in the domain model.
6 | ///
7 | ///
8 | /// If the annotated type is also a partial class, the source generator kicks in to complete it.
9 | ///
10 | ///
11 | /// This attribute should only be applied to concrete types.
12 | /// For example, if Address is a concrete value object type inheriting from abstract type PersonalDetail, then only Address should have the attribute.
13 | ///
14 | ///
15 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
16 | public class ValueObjectAttribute : Attribute
17 | {
18 | }
19 |
--------------------------------------------------------------------------------
/DomainModeling/Attributes/WrapperValueObjectAttribute.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling;
2 |
3 | ///
4 | ///
5 | /// Marks a type as a DDD wrapper value object in the domain model, i.e. a value object wrapping a single value of type .
6 | /// For example, consider a ProperName type wrapping a .
7 | ///
8 | ///
9 | /// If the annotated type is also a partial class, the source generator kicks in to complete it.
10 | ///
11 | ///
12 | /// This attribute should only be applied to concrete types.
13 | /// For example, if ProperName is a concrete wrapper value object type inheriting from abstract type Text, then only ProperName should have the attribute.
14 | ///
15 | ///
16 | /// The underlying type wrapped by the annotated wrapper value object type.
17 | [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)]
18 | public class WrapperValueObjectAttribute : ValueObjectAttribute
19 | where TValue : notnull
20 | {
21 | }
22 |
--------------------------------------------------------------------------------
/DomainModeling/Comparisons/DictionaryComparer.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Comparisons;
4 |
5 | ///
6 | ///
7 | /// Structurally compares or objects.
8 | ///
9 | ///
10 | public static class DictionaryComparer
11 | {
12 | ///
13 | ///
14 | /// Returns a hash code over some of the content of the given .
15 | ///
16 | ///
17 | public static int GetDictionaryHashCode(IReadOnlyDictionary? instance)
18 | {
19 | // Unfortunately, we can do no better than distinguish between null, empty, and non-empty
20 | // Example:
21 | // Left instance contains keys { "A", "a" } and has StringComparer.Ordinal
22 | // Right instance contains keys { "A" } and has StringComparer.OrdinalIgnoreCase
23 | // Both have the same keys
24 | // Each considers the other equal, because every query on each results in the same result
25 | // (Enumeration results in different results, but enumeration does not count, as such types have no ordering guarantees)
26 | // Equal objects MUST produce equal hash codes
27 | // But without knowledge of each other, there is no reliable way for the two to produce the same hash code
28 |
29 | if (instance is null) return 0;
30 | if (instance.Count == 0) return 1;
31 | return 2;
32 | }
33 |
34 | ///
35 | ///
36 | /// Returns a hash code over some of the content of the given .
37 | ///
38 | ///
39 | public static int GetDictionaryHashCode(IDictionary? instance)
40 | {
41 | // Unfortunately, we can do no better than distinguish between null, empty, and non-empty
42 | // Example:
43 | // Left instance contains keys { "A", "a" } and has StringComparer.Ordinal
44 | // Right instance contains keys { "A" } and has StringComparer.OrdinalIgnoreCase
45 | // Both have the same keys
46 | // Each considers the other equal, because every query on each results in the same result
47 | // (Enumeration results in different results, but enumeration does not count, as such types have no ordering guarantees)
48 | // Equal objects MUST produce equal hash codes
49 | // But without knowledge of each other, there is no reliable way for the two to produce the same hash code
50 |
51 | if (instance is null) return 0;
52 | if (instance.Count == 0) return 1;
53 | return 2;
54 | }
55 |
56 | ///
57 | ///
58 | /// Returns a hash code over some of the content of the given .
59 | ///
60 | ///
61 | public static int GetDictionaryHashCode(Dictionary? instance)
62 | where TKey : notnull
63 | {
64 | return GetDictionaryHashCode((IReadOnlyDictionary?)instance);
65 | }
66 |
67 | ///
68 | ///
69 | /// Compares the given objects for equality by comparing their keys and values.
70 | ///
71 | ///
72 | /// This method performs equality checks on the keys and values.
73 | /// It is not recursive. To support nested collections, use custom collections that override their equality checks accordingly.
74 | ///
75 | ///
76 | public static bool DictionaryEquals(IReadOnlyDictionary? left, IReadOnlyDictionary? right)
77 | {
78 | // Devirtualized path for practically all dictionaries
79 | #pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. -- Was type-checked
80 | if (left is Dictionary leftDict && right is Dictionary rightDict)
81 | return DictionaryEquals(leftDict, rightDict);
82 | #pragma warning restore CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint.
83 |
84 | return GetResult(left, right);
85 |
86 | // Local function that performs the work
87 | static bool GetResult(IReadOnlyDictionary? left, IReadOnlyDictionary? right)
88 | {
89 | if (ReferenceEquals(left, right)) return true;
90 | if (left is null || right is null) return false; // Double nulls are already handled above
91 |
92 | // EqualityComparer.Default helps avoid an IEquatable constraint yet still gets optimized: https://github.com/dotnet/coreclr/pull/14125
93 |
94 | foreach (var leftPair in left)
95 | if (!right.TryGetValue(leftPair.Key, out var rightValue) || !EqualityComparer.Default.Equals(leftPair.Value, rightValue))
96 | return false;
97 |
98 | foreach (var rightPair in right)
99 | if (!left.TryGetValue(rightPair.Key, out var leftValue) || !EqualityComparer.Default.Equals(rightPair.Value, leftValue))
100 | return false;
101 |
102 | return true;
103 | }
104 | }
105 |
106 | ///
107 | ///
108 | /// Compares the given objects for equality by comparing their keys and values.
109 | ///
110 | ///
111 | /// This method performs equality checks on the keys and values.
112 | /// It is not recursive. To support nested collections, use custom collections that override their equality checks accordingly.
113 | ///
114 | ///
115 | public static bool DictionaryEquals(IDictionary? left, IDictionary? right)
116 | {
117 | // Devirtualized path for practically all dictionaries
118 | #pragma warning disable CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint. -- Was type-checked
119 | if (left is Dictionary leftDict && right is Dictionary rightDict)
120 | return DictionaryEquals(leftDict, rightDict);
121 | #pragma warning restore CS8714 // The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn't match 'notnull' constraint.
122 |
123 | return GetResult(left, right);
124 |
125 | // Local function that performs the work
126 | static bool GetResult(IDictionary? left, IDictionary? right)
127 | {
128 | if (ReferenceEquals(left, right)) return true;
129 | if (left is null || right is null) return false; // Double nulls are already handled above
130 |
131 | // EqualityComparer.Default helps avoid an IEquatable constraint yet still gets optimized: https://github.com/dotnet/coreclr/pull/14125
132 |
133 | foreach (var leftPair in left)
134 | if (!right.TryGetValue(leftPair.Key, out var rightValue) || !EqualityComparer.Default.Equals(leftPair.Value, rightValue))
135 | return false;
136 |
137 | foreach (var rightPair in right)
138 | if (!left.TryGetValue(rightPair.Key, out var leftValue) || !EqualityComparer.Default.Equals(rightPair.Value, leftValue))
139 | return false;
140 |
141 | return true;
142 | }
143 | }
144 |
145 | ///
146 | ///
147 | /// Compares the given objects for equality by comparing their keys and values.
148 | ///
149 | ///
150 | /// This method performs equality checks on the keys and values.
151 | /// It is not recursive. To support nested collections, use custom collections that override their equality checks accordingly.
152 | ///
153 | ///
154 | public static bool DictionaryEquals(Dictionary? left, Dictionary? right)
155 | where TKey : notnull
156 | {
157 | if (ReferenceEquals(left, right)) return true;
158 | if (left is null || right is null) return false; // Double nulls are already handled above
159 |
160 | // EqualityComparer.Default helps avoid an IEquatable constraint yet still gets optimized: https://github.com/dotnet/coreclr/pull/14125
161 |
162 | foreach (var leftPair in left)
163 | if (!right.TryGetValue(leftPair.Key, out var rightValue) || !EqualityComparer.Default.Equals(leftPair.Value, rightValue))
164 | return false;
165 |
166 | foreach (var rightPair in right)
167 | if (!left.TryGetValue(rightPair.Key, out var leftValue) || !EqualityComparer.Default.Equals(rightPair.Value, leftValue))
168 | return false;
169 |
170 | return true;
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/DomainModeling/Comparisons/LookupComparer.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Comparisons;
4 |
5 | ///
6 | ///
7 | /// Structurally compares objects.
8 | ///
9 | ///
10 | public static class LookupComparer
11 | {
12 | ///
13 | ///
14 | /// Returns a hash code over some of the content of the given .
15 | ///
16 | ///
17 | public static int GetLookupHashCode([AllowNull] ILookup instance)
18 | {
19 | // Unfortunately, we can do no better than distinguish between null, empty, and non-empty
20 | // Example:
21 | // Left instance contains keys { "A", "a" } and has StringComparer.Ordinal
22 | // Right instance contains keys { "A" } and has StringComparer.OrdinalIgnoreCase
23 | // Both have the same keys
24 | // Each considers the other equal, because every query on each results in the same result
25 | // (Enumeration results in different results, but enumeration does not count, as such types have no ordering guarantees)
26 | // Equal objects MUST produce equal hash codes
27 | // But without knowledge of each other, there is no reliable way for the two to produce the same hash code
28 |
29 | if (instance is null) return 0;
30 | if (instance.Count == 0) return 1;
31 | return 2;
32 | }
33 |
34 | ///
35 | ///
36 | /// Compares the given objects for equality by comparing their elements.
37 | ///
38 | ///
39 | public static bool LookupEquals([AllowNull] ILookup left, [AllowNull] ILookup right)
40 | {
41 | if (ReferenceEquals(left, right)) return true;
42 | if (left is null || right is null) return false; // Double nulls are already handled above
43 |
44 | // The lookups must be equal from the perspective of each
45 | return LeftLeadingEquals(left, right) && LeftLeadingEquals(right, left);
46 |
47 | // Local function that compares two lookups from the perspective of the left one
48 | static bool LeftLeadingEquals(ILookup left, ILookup right)
49 | {
50 | foreach (var leftGroup in left)
51 | {
52 | var rightGroup = right[leftGroup.Key];
53 |
54 | // Fast path
55 | if (leftGroup is IList leftList && rightGroup is IList rightList)
56 | {
57 | var leftListCount = leftList.Count;
58 |
59 | if (leftListCount != rightList.Count) return false;
60 |
61 | // EqualityComparer.Default helps avoid an IEquatable constraint yet still gets optimized: https://github.com/dotnet/coreclr/pull/14125
62 | for (var i = 0; i < leftListCount; i++)
63 | if (!EqualityComparer.Default.Equals(leftList[i], rightList[i]))
64 | return false;
65 | }
66 | // Slow path
67 | else
68 | {
69 | if (!ElementEnumerableEquals(leftGroup, rightGroup))
70 | return false;
71 | }
72 | }
73 |
74 | return true;
75 | }
76 |
77 | // Local function that compares two groups of elements by enumerating them
78 | static bool ElementEnumerableEquals(IEnumerable leftGroup, IEnumerable rightGroup)
79 | {
80 | using var rightGroupEnumerator = rightGroup.GetEnumerator();
81 |
82 | foreach (var leftElement in leftGroup)
83 | if (!rightGroupEnumerator.MoveNext() || !EqualityComparer.Default.Equals(leftElement, rightGroupEnumerator.Current))
84 | return false;
85 |
86 | if (rightGroupEnumerator.MoveNext()) return false;
87 |
88 | return true;
89 | }
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/DomainModeling/Configuration/IDomainEventConfigurator.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Configuration;
4 |
5 | ///
6 | /// An instance of this abstraction configures a miscellaneous component when it comes to domain event types.
7 | /// One example is a convention configurator for Entity Framework.
8 | ///
9 | public interface IDomainEventConfigurator
10 | {
11 | ///
12 | /// A callback to configure a domain event of type .
13 | ///
14 | void ConfigureDomainEvent<
15 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TDomainEvent>(
16 | in Args args)
17 | where TDomainEvent : IDomainObject;
18 |
19 | public readonly struct Args
20 | {
21 | public readonly bool HasDefaultConstructor { get; init; }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/DomainModeling/Configuration/IEntityConfigurator.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Configuration;
4 |
5 | ///
6 | /// An instance of this abstraction configures a miscellaneous component when it comes to types.
7 | /// One example is a convention configurator for Entity Framework.
8 | ///
9 | public interface IEntityConfigurator
10 | {
11 | ///
12 | /// A callback to configure an entity of type .
13 | ///
14 | void ConfigureEntity<
15 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TEntity>(
16 | in Args args)
17 | where TEntity : IEntity;
18 |
19 | public readonly struct Args
20 | {
21 | public bool HasDefaultConstructor { get; init; }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/DomainModeling/Configuration/IIdentityConfigurator.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Configuration;
4 |
5 | ///
6 | /// An instance of this abstraction configures a miscellaneous component when it comes to types.
7 | /// One example is a convention configurator for Entity Framework.
8 | ///
9 | public interface IIdentityConfigurator
10 | {
11 | ///
12 | /// A callback to configure an identity of type .
13 | ///
14 | void ConfigureIdentity<
15 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TIdentity,
16 | TUnderlying>(
17 | in Args args)
18 | where TIdentity : IIdentity, ISerializableDomainObject
19 | where TUnderlying : notnull, IEquatable, IComparable;
20 |
21 | public readonly struct Args
22 | {
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/DomainModeling/Configuration/IWrapperValueObjectConfigurator.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 |
3 | namespace Architect.DomainModeling.Configuration;
4 |
5 | ///
6 | /// An instance of this abstraction configures a miscellaneous component when it comes to types.
7 | /// One example is a convention configurator for Entity Framework.
8 | ///
9 | public interface IWrapperValueObjectConfigurator
10 | {
11 | ///
12 | /// A callback to configure a wrapper value object of type .
13 | ///
14 | void ConfigureWrapperValueObject<
15 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TWrapper,
16 | TValue>(
17 | in Args args)
18 | where TWrapper : IWrapperValueObject, ISerializableDomainObject
19 | where TValue : notnull;
20 |
21 | public readonly struct Args
22 | {
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/DomainModeling/Conversions/DomainObjectSerializer.cs:
--------------------------------------------------------------------------------
1 | #if NET7_0_OR_GREATER
2 |
3 | using System.Diagnostics.CodeAnalysis;
4 | using System.Linq.Expressions;
5 | using System.Reflection;
6 |
7 | namespace Architect.DomainModeling.Conversions;
8 |
9 | public static class DomainObjectSerializer
10 | {
11 | private static readonly MethodInfo GenericDeserializeMethod = typeof(DomainObjectSerializer).GetMethods().Single(method =>
12 | method.Name == nameof(Deserialize) && method.GetParameters() is []);
13 | private static readonly MethodInfo GenericDeserializeFromValueMethod = typeof(DomainObjectSerializer).GetMethods().Single(method =>
14 | method.Name == nameof(Deserialize) && method.GetParameters().Length == 1);
15 | private static readonly MethodInfo GenericSerializeMethod = typeof(DomainObjectSerializer).GetMethods().Single(method =>
16 | method.Name == nameof(Serialize) && method.GetParameters().Length == 1);
17 |
18 | #region Deserialize empty
19 |
20 | ///
21 | /// Deserializes an empty, uninitialized instance of type .
22 | ///
23 | public static TModel Deserialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel>()
24 | where TModel : IDomainObject
25 | {
26 | if (typeof(TModel).IsValueType)
27 | return default!;
28 |
29 | return ObjectInstantiator.Instantiate();
30 | }
31 |
32 | ///
33 | ///
34 | /// Creates an expression of a call to .
35 | ///
36 | ///
37 | /// When evaluated, the expression deserializes an empty, uninitialized instance of the .
38 | ///
39 | ///
40 | public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType)
41 | {
42 | var method = GenericDeserializeMethod.MakeGenericMethod(modelType);
43 | var result = Expression.Call(method);
44 | return result;
45 | }
46 |
47 | ///
48 | ///
49 | /// Creates a lambda expression that calls .
50 | ///
51 | ///
52 | /// The result deserializes an empty, uninitialized instance of type .
53 | ///
54 | ///
55 | /// To obtain a delegate, call on the result.
56 | ///
57 | ///
58 | public static Expression> CreateDeserializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel>()
59 | where TModel : IDomainObject
60 | {
61 | var call = CreateDeserializeExpression(typeof(TModel));
62 | var lambda = Expression.Lambda>(call);
63 | return lambda;
64 | }
65 |
66 | #endregion
67 |
68 | #region Deserialize from value
69 |
70 | ///
71 | /// Deserializes a from a .
72 | ///
73 | [return: NotNullIfNotNull(nameof(value))]
74 | public static TModel? Deserialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>(
75 | TUnderlying? value)
76 | where TModel : ISerializableDomainObject
77 | {
78 | return value is null
79 | ? default
80 | : TModel.Deserialize(value);
81 | }
82 |
83 | ///
84 | ///
85 | /// Creates an expression of a call to .
86 | ///
87 | ///
88 | /// When evaluated, the result deserializes an instance of the from a given instance of the .
89 | ///
90 | ///
91 | public static Expression CreateDeserializeExpression([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType)
92 | {
93 | var result = CreateDeserializeExpressionCore(modelType, underlyingType, out _);
94 | return result;
95 | }
96 |
97 | ///
98 | ///
99 | /// Creates a lambda expression that calls .
100 | ///
101 | ///
102 | /// The result deserializes a from a given .
103 | ///
104 | ///
105 | /// To obtain a delegate, call on the result.
106 | ///
107 | ///
108 | public static Expression> CreateDeserializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>()
109 | where TModel : ISerializableDomainObject
110 | {
111 | var call = CreateDeserializeExpressionCore(typeof(TModel), typeof(TUnderlying), out var parameter);
112 | var lambda = Expression.Lambda>(call, parameter);
113 | return lambda;
114 | }
115 |
116 | private static MethodCallExpression CreateDeserializeExpressionCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType,
117 | out ParameterExpression parameter)
118 | {
119 | var method = GenericDeserializeFromValueMethod.MakeGenericMethod(modelType, underlyingType);
120 | parameter = Expression.Parameter(underlyingType, "value");
121 | var result = Expression.Call(method, parameter);
122 | return result;
123 | }
124 |
125 | #endregion
126 |
127 | #region Serialize
128 |
129 | ///
130 | /// Serializes a as a .
131 | ///
132 | public static TUnderlying? Serialize<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>(
133 | TModel? instance)
134 | where TModel : ISerializableDomainObject
135 | {
136 | return instance is null
137 | ? default
138 | : instance.Serialize();
139 | }
140 |
141 | ///
142 | ///
143 | /// Creates an expression of a call to .
144 | ///
145 | ///
146 | /// When evaluated, the result serializes a given instance of the as an instance of the .
147 | ///
148 | ///
149 | public static Expression CreateSerializeExpression([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType)
150 | {
151 | var result = CreateSerializeExpressionCore(modelType, underlyingType, out _);
152 | return result;
153 | }
154 |
155 | ///
156 | ///
157 | /// Creates a lambda expression that calls .
158 | ///
159 | ///
160 | /// The result serializes a given as a .
161 | ///
162 | ///
163 | /// To obtain a delegate, call on the result.
164 | ///
165 | ///
166 | public static Expression> CreateSerializeExpression<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, TUnderlying>()
167 | where TModel : ISerializableDomainObject
168 | {
169 | var call = CreateSerializeExpressionCore(typeof(TModel), typeof(TUnderlying), out var parameter);
170 | var lambda = Expression.Lambda>(call, parameter);
171 | return lambda;
172 | }
173 |
174 | private static MethodCallExpression CreateSerializeExpressionCore([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type modelType, Type underlyingType,
175 | out ParameterExpression parameter)
176 | {
177 | var method = GenericSerializeMethod.MakeGenericMethod(modelType, underlyingType);
178 | parameter = Expression.Parameter(modelType, "instance");
179 | var result = Expression.Call(method, parameter);
180 | return result;
181 | }
182 |
183 | #endregion
184 | }
185 |
186 | #endif
187 |
--------------------------------------------------------------------------------
/DomainModeling/Conversions/FormattingExtensions.cs:
--------------------------------------------------------------------------------
1 | namespace Architect.DomainModeling.Conversions;
2 |
3 | ///
4 | /// Provides formatting-related extension methods on formattable types.
5 | ///
6 | public static class FormattingExtensions
7 | {
8 | #if NET7_0_OR_GREATER
9 | ///
10 | ///
11 | /// Formats the into the provided , returning the segment that was written to.
12 | ///
13 | ///
14 | /// If there is not enough space in the , instead a new string is allocated and returned as a span.
15 | ///
16 | ///
17 | /// The value to format.
18 | /// The buffer to attempt to format into. For best performance, it is advisable to use a stack-allocated buffer that is expected to be large enough, e.g. 64 chars.
19 | /// A span containing the characters that represent a standard or custom format string that defines the acceptable format.
20 | /// An optional object that supplies culture-specific formatting information.
21 | public static ReadOnlySpan Format(this T value, Span buffer, ReadOnlySpan format, IFormatProvider? provider)
22 | where T : notnull, ISpanFormattable
23 | {
24 | if (!value.TryFormat(buffer, out var charCount, format, provider))
25 | return value.ToString().AsSpan();
26 |
27 | return buffer[..charCount];
28 | }
29 | #endif
30 | }
31 |
--------------------------------------------------------------------------------
/DomainModeling/Conversions/FormattingHelper.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using System.Runtime.CompilerServices;
3 | using System.Text.Unicode;
4 |
5 | namespace Architect.DomainModeling.Conversions;
6 |
7 | ///
8 | ///
9 | /// Delegates to *Formattable interfaces depending on their presence on a given type parameter.
10 | /// Uses overload resolution to avoid a compiler error where the interface is missing.
11 | ///
12 | ///
13 | /// This type is intended for use by source-generated code, to avoid compiler errors in situations where the presence of the required interfaces is extremely likely but cannot be guaranteed.
14 | ///
15 | ///
16 | public static class FormattingHelper
17 | {
18 | #if NET7_0_OR_GREATER
19 |
20 | ///
21 | /// This overload throws because is unavailable.
22 | /// Implement the interface to have overload resolution pick the functional overload.
23 | ///
24 | /// Used only for overload resolution.
25 | public static string ToString(T? instance,
26 | string? format, IFormatProvider? formatProvider,
27 | [CallerLineNumber] int callerLineNumber = -1)
28 | {
29 | throw new NotSupportedException($"Type {typeof(T).Name} does not support formatting.");
30 | }
31 |
32 | ///
33 | /// Delegates to .
34 | ///
35 | public static string ToString(T? instance,
36 | string? format, IFormatProvider? formatProvider)
37 | where T : IFormattable
38 | {
39 | if (instance is null)
40 | return "";
41 |
42 | return instance.ToString(format, formatProvider);
43 | }
44 |
45 | #pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution
46 | ///
47 | ///
48 | /// Returns the input string.
49 | ///
50 | ///
51 | /// This overload exists to avoid a special case for strings, which do not implement .
52 | ///
53 | ///
54 | /// Ignored.
55 | /// Ignored.
56 | [return: NotNullIfNotNull(nameof(instance))]
57 | public static string ToString(string? instance,
58 | string? format, IFormatProvider? formatProvider)
59 | {
60 | return instance ?? "";
61 | }
62 | #pragma warning restore IDE0060 // Remove unused parameter
63 |
64 | ///
65 | /// This overload throws because is unavailable.
66 | /// Implement the interface to have overload resolution pick the functional overload.
67 | ///
68 | /// Used only for overload resolution.
69 | public static bool TryFormat(T? instance,
70 | Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider,
71 | [CallerLineNumber] int callerLineNumber = -1)
72 | {
73 | throw new NotSupportedException($"Type {typeof(T).Name} does not support span formatting.");
74 | }
75 |
76 | ///
77 | /// Delegates to .
78 | ///
79 | public static bool TryFormat(T? instance,
80 | Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider)
81 | where T : ISpanFormattable
82 | {
83 | if (instance is null)
84 | {
85 | charsWritten = 0;
86 | return true;
87 | }
88 |
89 | return instance.TryFormat(destination, out charsWritten, format, provider);
90 | }
91 |
92 | #pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution
93 | ///
94 | ///
95 | /// Tries to write the string into the provided span of characters.
96 | ///
97 | ///
98 | /// This overload exists to avoid a special case for strings, which do not implement .
99 | ///
100 | ///
101 | /// Ignored.
102 | /// Ignored.
103 | public static bool TryFormat(string? instance,
104 | Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider)
105 | {
106 | charsWritten = 0;
107 |
108 | if (instance is null)
109 | return true;
110 |
111 | if (instance.Length > destination.Length)
112 | return false;
113 |
114 | instance.AsSpan().CopyTo(destination);
115 | charsWritten = instance.Length;
116 | return true;
117 | }
118 | #pragma warning restore IDE0060 // Remove unused parameter
119 |
120 | #endif
121 |
122 | #if NET8_0_OR_GREATER
123 |
124 | ///
125 | /// This overload throws because is unavailable.
126 | /// Implement the interface to have overload resolution pick the functional overload.
127 | ///
128 | /// Used only for overload resolution.
129 | public static bool TryFormat(T? instance,
130 | Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider,
131 | [CallerLineNumber] int callerLineNumber = -1)
132 | {
133 | throw new NotSupportedException($"Type {typeof(T).Name} does not support UTF-8 span formatting.");
134 | }
135 |
136 | ///
137 | /// Delegates to .
138 | ///
139 | public static bool TryFormat(T? instance,
140 | Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider)
141 | where T : IUtf8SpanFormattable
142 | {
143 | if (instance is null)
144 | {
145 | bytesWritten = 0;
146 | return true;
147 | }
148 |
149 | return instance.TryFormat(utf8Destination, out bytesWritten, format, provider);
150 | }
151 |
152 | #pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution
153 | ///
154 | ///
155 | /// Tries to write the string into the provided span of bytes.
156 | ///
157 | ///
158 | /// This overload exists to avoid a special case for strings, which do not implement .
159 | ///
160 | ///
161 | /// Ignored.
162 | /// Ignored.
163 | public static bool TryFormat(string instance,
164 | Span utf8Destination, out int bytesWritten, ReadOnlySpan format, IFormatProvider? provider)
165 | {
166 | if (instance is null)
167 | {
168 | bytesWritten = 0;
169 | return true;
170 | }
171 |
172 | return Utf8.FromUtf16(instance, utf8Destination, charsRead: out _, bytesWritten: out bytesWritten) == System.Buffers.OperationStatus.Done;
173 | }
174 | #pragma warning restore IDE0060 // Remove unused parameter
175 |
176 | #endif
177 | }
178 |
--------------------------------------------------------------------------------
/DomainModeling/Conversions/ObjectInstantiator.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using System.Reflection;
3 | using System.Runtime.CompilerServices;
4 |
5 | namespace Architect.DomainModeling.Conversions;
6 |
7 | ///
8 | /// Instantiates objects of arbitrary types.
9 | ///
10 | internal static class ObjectInstantiator<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] T>
11 | {
12 | private static readonly Func ConstructionFunction;
13 |
14 | static ObjectInstantiator()
15 | {
16 | if (typeof(T).IsValueType)
17 | {
18 | ConstructionFunction = () => default!;
19 | }
20 | else if (typeof(T).IsAbstract || typeof(T).IsInterface || typeof(T).IsGenericTypeDefinition)
21 | {
22 | ConstructionFunction = () => throw new NotSupportedException("Uninitialized instantiation of abstract, interface, or unbound generic types is not supported.");
23 | }
24 | else if (typeof(T) == typeof(string) || typeof(T).IsArray)
25 | {
26 | ConstructionFunction = () => throw new NotSupportedException("Uninitialized instantiation of arrays and strings is not supported.");
27 | }
28 | else if (typeof(T).GetConstructor(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, binder: null, Array.Empty(), modifiers: null) is ConstructorInfo ctor)
29 | {
30 | #if NET8_0_OR_GREATER
31 | var invoker = ConstructorInvoker.Create(ctor);
32 | ConstructionFunction = () => (T)invoker.Invoke();
33 | #else
34 | ConstructionFunction = () => (T)Activator.CreateInstance(typeof(T), nonPublic: true)!;
35 | #endif
36 | }
37 | else
38 | {
39 | ConstructionFunction = () => (T)RuntimeHelpers.GetUninitializedObject(typeof(T));
40 | }
41 | }
42 |
43 | ///
44 | ///
45 | /// Instantiates an instance of type , using its default constructor if one is available, or by producing an uninitialized object otherwise.
46 | ///
47 | ///
48 | /// Throws a for arrays, strings, and unbound generic types.
49 | ///
50 | ///
51 | public static T Instantiate()
52 | {
53 | return ConstructionFunction();
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/DomainModeling/Conversions/ParsingHelper.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics.CodeAnalysis;
2 | using System.Runtime.CompilerServices;
3 | using System.Text;
4 | using System.Text.Unicode;
5 |
6 | namespace Architect.DomainModeling.Conversions;
7 |
8 | ///
9 | ///
10 | /// Delegates to *Parsable interfaces depending on their presence on a given type parameter.
11 | /// Uses overload resolution to avoid a compiler error where the interface is missing.
12 | ///
13 | ///
14 | /// This type is intended for use by source-generated code, to avoid compiler errors in situations where the presence of the required interfaces is extremely likely but cannot be guaranteed.
15 | ///
16 | ///
17 | public static class ParsingHelper
18 | {
19 | #if NET7_0_OR_GREATER
20 |
21 | ///
22 | /// This overload throws because is unavailable.
23 | /// Implement the interface to have overload resolution pick the functional overload.
24 | ///
25 | /// Used only for overload resolution.
26 | public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [NotNullWhen(true)] out T? result,
27 | [CallerLineNumber] int callerLineNumber = -1)
28 | {
29 | throw new NotSupportedException($"Type {typeof(T).Name} does not support parsing.");
30 | }
31 |
32 | ///
33 | /// Delegates to .
34 | ///
35 | public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [NotNullWhen(true)] out T? result)
36 | where T : IParsable
37 | {
38 | return T.TryParse(s, provider, out result);
39 | }
40 |
41 | ///
42 | /// This overload throws because is unavailable.
43 | /// Implement the interface to have overload resolution pick the functional overload.
44 | ///
45 | /// Used only for overload resolution.
46 | public static T Parse(string s, IFormatProvider? provider,
47 | [CallerLineNumber] int callerLineNumber = -1)
48 | {
49 | throw new NotSupportedException($"Type {typeof(T).Name} does not support parsing.");
50 | }
51 |
52 | ///
53 | /// Delegates to .
54 | ///
55 | public static T Parse(string s, IFormatProvider? provider)
56 | where T : IParsable
57 | {
58 | return T.Parse(s, provider);
59 | }
60 |
61 | ///
62 | /// This overload throws because is unavailable.
63 | /// Implement the interface to have overload resolution pick the functional overload.
64 | ///
65 | /// Used only for overload resolution.
66 | public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [NotNullWhen(true)] out T? result,
67 | [CallerLineNumber] int callerLineNumber = -1)
68 | {
69 | throw new NotSupportedException($"Type {typeof(T).Name} does not support span parsing.");
70 | }
71 |
72 | ///
73 | /// Delegates to .
74 | ///
75 | public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [NotNullWhen(true)] out T? result)
76 | where T : ISpanParsable
77 | {
78 | return T.TryParse(s, provider, out result);
79 | }
80 |
81 | ///
82 | /// This overload throws because is unavailable.
83 | /// Implement the interface to have overload resolution pick the functional overload.
84 | ///
85 | /// Used only for overload resolution.
86 | public static T Parse(ReadOnlySpan s, IFormatProvider? provider,
87 | [CallerLineNumber] int callerLineNumber = -1)
88 | {
89 | throw new NotSupportedException($"Type {typeof(T).Name} does not support span parsing.");
90 | }
91 |
92 | ///
93 | /// Delegates to .
94 | ///
95 | public static T Parse(ReadOnlySpan s, IFormatProvider? provider)
96 | where T : ISpanParsable
97 | {
98 | return T.Parse(s, provider);
99 | }
100 |
101 | #endif
102 |
103 | #if NET8_0_OR_GREATER
104 |
105 | #pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution
106 | ///
107 | ///
108 | /// For strings, this overload tries to parse a span of UTF-8 characters into a string.
109 | ///
110 | ///
111 | /// For other types, this overload throws because is unavailable.
112 | /// Implement the interface to have overload resolution pick the functional overload.
113 | ///
114 | ///
115 | /// Used only for overload resolution.
116 | public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [NotNullWhen(true)] out T? result,
117 | [CallerLineNumber] int callerLineNumber = -1)
118 | {
119 | if (typeof(T) == typeof(string))
120 | {
121 | if (!Utf8.IsValid(utf8Text))
122 | {
123 | result = default;
124 | return false;
125 | }
126 |
127 | result = (T)(object)Encoding.UTF8.GetString(utf8Text);
128 | return true;
129 | }
130 |
131 | throw new NotSupportedException($"Type {typeof(T).Name} does not support UTF-8 span parsing.");
132 | }
133 | #pragma warning restore IDE0060 // Remove unused parameter
134 |
135 | ///
136 | /// Delegates to .
137 | ///
138 | public static bool TryParse(ReadOnlySpan utf8Text, IFormatProvider? provider, [NotNullWhen(true)] out T? result)
139 | where T : IUtf8SpanParsable
140 | {
141 | return T.TryParse(utf8Text, provider, out result);
142 | }
143 |
144 | #pragma warning disable IDE0060 // Remove unused parameter -- Required to let generated code make use of overload resolution
145 | ///
146 | ///
147 | /// For strings, this overload parses a span of UTF-8 characters into a string.
148 | ///
149 | ///
150 | /// For other types, this overload throws because is unavailable.
151 | /// Implement the interface to have overload resolution pick the functional overload.
152 | ///
153 | ///
154 | /// Used only for overload resolution.
155 | public static T Parse(ReadOnlySpan utf8Text, IFormatProvider? provider,
156 | [CallerLineNumber] int callerLineNumber = -1)
157 | {
158 | if (typeof(T) == typeof(string))
159 | return (T)(object)Encoding.UTF8.GetString(utf8Text);
160 |
161 | throw new NotSupportedException($"Type {typeof(T).Name} does not support UTF-8 span parsing.");
162 | }
163 | #pragma warning restore IDE0060 // Remove unused parameter
164 |
165 | ///
166 | /// Delegates to .
167 | ///
168 | public static T Parse(ReadOnlySpan utf8Text, IFormatProvider? provider)
169 | where T : IUtf8SpanParsable
170 | {
171 | return T.Parse(utf8Text, provider);
172 | }
173 |
174 | #endif
175 | }
176 |
--------------------------------------------------------------------------------
/DomainModeling/Conversions/Utf8JsonReaderExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Buffers;
2 | using System.Runtime.CompilerServices;
3 | using System.Text.Json;
4 |
5 | namespace Architect.DomainModeling.Conversions;
6 |
7 | ///
8 | /// Provides conversion-related extension methods on .
9 | ///
10 | public static class Utf8JsonReaderExtensions
11 | {
12 | #if NET7_0_OR_GREATER
13 | ///
14 | /// Reads the next string JSON token from the source and parses it as , which must implement .
15 | ///
16 | /// A that is ready to read a property name.
17 | /// An object that provides culture-specific formatting information about the input string.
18 | /// Used only for overload resolution.
19 | public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? provider,
20 | [CallerLineNumber] int callerLineNumber = -1)
21 | where T : ISpanParsable
22 | {
23 | ReadOnlySpan chars = stackalloc char[0];
24 |
25 | var maxCharLength = reader.HasValueSequence ? reader.ValueSequence.Length : reader.ValueSpan.Length;
26 | if (maxCharLength > 2048) // Avoid oversized stack allocations
27 | {
28 | chars = reader.GetString().AsSpan();
29 | }
30 | else
31 | {
32 | Span buffer = stackalloc char[(int)maxCharLength];
33 | var charCount = reader.CopyString(buffer);
34 | chars = buffer[..charCount];
35 | }
36 |
37 | var result = T.Parse(chars, provider);
38 | return result;
39 | }
40 | #endif
41 |
42 | #if NET8_0_OR_GREATER
43 | ///
44 | /// Reads the next string JSON token from the source and parses it as