├── .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 , which must implement . 45 | /// 46 | /// A that is ready to read a property name. 47 | /// An object that provides culture-specific formatting information about the input string. 48 | public static T GetParsedString(this Utf8JsonReader reader, IFormatProvider? provider) 49 | where T : IUtf8SpanParsable 50 | { 51 | ReadOnlySpan chars = reader.HasValueSequence 52 | ? stackalloc byte[0] 53 | : reader.ValueSpan; 54 | 55 | if (reader.HasValueSequence) 56 | { 57 | if (reader.ValueSequence.Length > 2048) // Avoid oversized stack allocations 58 | { 59 | chars = reader.ValueSequence.ToArray(); 60 | } 61 | else 62 | { 63 | Span buffer = stackalloc byte[(int)reader.ValueSequence.Length]; 64 | reader.ValueSequence.CopyTo(buffer); 65 | chars = buffer; 66 | } 67 | } 68 | 69 | var result = T.Parse(chars, provider); 70 | return result; 71 | } 72 | #endif 73 | } 74 | -------------------------------------------------------------------------------- /DomainModeling/DomainModeling.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0;net7.0;net6.0 5 | False 6 | Architect.DomainModeling 7 | Architect.DomainModeling 8 | Enable 9 | Enable 10 | 11 11 | True 12 | True 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 3.0.3 21 | 22 | A complete Domain-Driven Design (DDD) toolset for implementing domain models, including base types and source generators. 23 | 24 | https://github.com/TheArchitectDev/Architect.DomainModeling 25 | 26 | Release notes: 27 | 28 | 3.0.3: 29 | 30 | - Enhancement: Upgraded package versions. 31 | 32 | 3.0.2: 33 | 34 | - Bug fix. 35 | 36 | 3.0.1: 37 | 38 | - Bug fix. 39 | 40 | 3.0.0: 41 | 42 | - BREAKING: Platform support: Dropped support for .NET 5.0 (EOL). 43 | - BREAKING: Marker attributes: [SourceGenerated] attribute is refactored into [Entity], [ValueObject], [WrapperValueObject<TValue>], etc. Obsolete marking helps with migrating. 44 | - BREAKING: DummyBuilder base class: The DummyBuilder<TModel, TModelBuilder> base class is deprecated in favor of the new [DummyBuilder<TModel>] attribute. Obsolete marking helps with migrating. 45 | - BREAKING: Private ctors: Source-generated ValueObject types now generate a private default ctor with [JsonConstructor], for logic-free deserialization. This may break deserialization if properties lack an init/set. Analyzer included. 46 | - BREAKING: Init properties: A new analyzer warns if a WrapperValueObject's Value property lacks an init/set, because logic-free deserialization then requires a workaround. 47 | - BREAKING: ISerializableDomainObject interface: Wrapper value objects and identities now require the new ISerializableDomainObject<TModel, TValue> interface (generated automatically). 48 | - Feature: Custom inheritance: Source generation with custom base classes is now easy, with marker attributes identifying the concrete types. 49 | - Feature: Optional inheritance: For source-generated value objects, wrappers, and identities, the base type or interface is generated and can be omitted. 50 | - Feature: DomainObjectSerializer (.NET 7+): The new DomainObjectSerializer type can be used to (de)serialize identities and wrappers without running any domain logic (such as parameterized ctors), and customizable per type. 51 | - Feature: Entity Framework mappings (.NET 7+): If Entity Framework is used, mappings by convention (that also bypass ctors) can be generated. Override DbContext.ConfigureConventions() and call ConfigureDomainModelConventions(). Its action param allows all identities, wrapper value objects, entities, and/or domain events to be mapped, even in a trimmer-safe way. 52 | - Feature: Miscellaneous mappings: Other third party components can similarly map domain objects. See the readme. 53 | - Feature: Marker attributes: Non-partial types with the new marker attributes skip source generation, but can still participate in mappings. 54 | - Feature: Record struct identities: Explicitly declared identity types now support "record struct", allowing their curly braces to be omitted: `public partial record struct GeneratedId;` 55 | - Feature: ValueObject validation helpers: Added ValueObject.ContainsNonPrintableCharactersOrDoubleQuotes(), a common validation requirement for proper names. 56 | - Feature: Formattable and parsable interfaces (.NET 7+): Generated identities and wrappers now implement IFormattable, IParsable<TSelf>, ISpanFormattable, and ISpanParsable<TSelf>, recursing into the wrapped type's implementation. 57 | - Feature: UTF-8 formattable and parsable interfaces (.NET 8+): Generated identities and wrappers now implement IUtf8SpanFormattable and IUtf8SpanParsable<TSelf>, recursing into the wrapped type's implementation. 58 | - Enhancement: JSON converters (.NET 7+): All generated JSON converters now pass through the new Serialize() and Deserialize() methods, for customizable and logic-free (de)serialization. 59 | - Enhancement: JSON converters (.NET 7+): ReadAsPropertyName() and WriteAsPropertyName() in generated JSON converters now recurse into the wrapped type's converter and also pass through the new Serialize() and Deserialize() methods. 60 | - Bug fix: IDE stability: Fixed a compile-time bug that could cause some of the IDE's features to crash, such as certain analyzers. 61 | - Minor feature: Additional interfaces: IEntity and IWrapperValueObject<TValue> interfaces are now available. 62 | 63 | The Architect 64 | The Architect 65 | TheArchitectDev, Timovzl 66 | https://github.com/TheArchitectDev/Architect.DomainModeling 67 | Git 68 | LICENSE 69 | DDD, Domain-Driven Design, Entity, ValueObject, value, object, DomainModeling, domain, modeling, SourceGenerator, source, generator 70 | 71 | 72 | 73 | 74 | True 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | false 86 | Content 87 | PreserveNewest 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /DomainModeling/DomainObject.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// An object in the domain model. 5 | /// 6 | [Serializable] 7 | public class DomainObject : IDomainObject 8 | { 9 | } 10 | -------------------------------------------------------------------------------- /DomainModeling/DummyBuilder.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// A base class used to implement the Builder pattern, specifically for use in test methods. 5 | /// 6 | /// The type constructed by the builder. 7 | /// The type of the concrete builder itself. 8 | [Obsolete("This base class is deprecated. Apply the [DummyBuilder] attribute instead.", error: true)] 9 | public abstract class DummyBuilder 10 | where TModel : class 11 | where TModelBuilder : DummyBuilder 12 | { 13 | protected DummyBuilder() 14 | { 15 | if (this.GetType() != typeof(TModelBuilder)) 16 | throw new Exception($"Builder class {this.GetType().Name} must specify its own type as the {nameof(TModelBuilder)} type parameter."); 17 | } 18 | 19 | protected virtual TModelBuilder With(Action assignment) 20 | { 21 | assignment((TModelBuilder)this); 22 | return (TModelBuilder)this; 23 | } 24 | 25 | public abstract TModel Build(); 26 | } 27 | -------------------------------------------------------------------------------- /DomainModeling/Entity.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Runtime.CompilerServices; 3 | using Architect.DomainModeling.Conversions; 4 | 5 | namespace Architect.DomainModeling; 6 | 7 | /// 8 | /// 9 | /// An entity is a data model that is defined by its identity and a thread of continuity. It may be mutated during its life cycle. 10 | /// 11 | /// 12 | /// automatically declares an ID property of type , as well as overriding certain behavior to make use of it. 13 | /// 14 | /// 15 | /// automatically generates source code for type if it does not exist. 16 | /// The source-generated type wraps a value of type . 17 | /// 18 | /// 19 | /// The custom ID type for this entity. The type is source-generated if a nonexistent type is specified. 20 | /// The underlying primitive type used by the custom ID type. 21 | [Serializable] 22 | public abstract class Entity< 23 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TId, 24 | TIdPrimitive> 25 | : Entity 26 | where TId : IEquatable?, IComparable? 27 | where TIdPrimitive : IEquatable?, IComparable? 28 | { 29 | protected Entity(TId id) 30 | : base(id) 31 | { 32 | } 33 | 34 | public override bool Equals(Entity? other) 35 | { 36 | // Since the ID type is specifically generated for our entity type, any subtype will belong to the same sequence of IDs 37 | // This lets us avoid an exact type match, which lets us consider a Fruit equal a Banana if their IDs match 38 | 39 | if (other is not Entity) 40 | return false; 41 | 42 | // Either we must be the same reference 43 | // Or we must have non-null, non-default, equal IDs (i.e. two entities with a default ID are not automatically considered the same entity) 44 | return ReferenceEquals(this, other) || 45 | (this.Id is not null && !this.Id.Equals(DefaultId) && this.Id.Equals(other.Id)); 46 | } 47 | } 48 | 49 | /// 50 | /// 51 | /// An entity is a data model that is defined by its identity and a thread of continuity. It may be mutated during its life cycle. 52 | /// 53 | /// 54 | /// automatically declares an ID property of type , as well as overriding certain behavior to make use of it. 55 | /// 56 | /// 57 | [Serializable] 58 | public abstract class Entity< 59 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TId> 60 | : Entity, IEquatable?> 61 | where TId : IEquatable? 62 | { 63 | public override string ToString() => $"{{{this.GetType().Name} Id={this.Id}}}"; 64 | 65 | /// 66 | /// 67 | /// An instance of has a meaningful value if it is both non-null (for reference types) and not equal to this value. 68 | /// 69 | /// 70 | /// For example, when is a custom class wrapping an (auto-increment) , comparing against default/null is insufficient to determine if an instance has a meaningful value. 71 | /// An instance containing a value of 0 should also be considered an uninitialized ID. 72 | /// 73 | /// 74 | /// This property contains an empty instance of , with all its fields set to their default values. 75 | /// 76 | /// 77 | protected static readonly TId? DefaultId = typeof(TId).IsValueType || typeof(TId).IsAbstract || typeof(TId).IsInterface || typeof(TId).IsGenericTypeDefinition || typeof(TId) == typeof(string) || typeof(TId).IsArray 78 | ? default 79 | : ObjectInstantiator.Instantiate(); 80 | 81 | /// 82 | /// The entity's unique identity. 83 | /// 84 | public TId Id { get; } 85 | 86 | /// The unique identity for the entity. 87 | protected Entity(TId id) 88 | { 89 | this.Id = id; 90 | } 91 | 92 | public override int GetHashCode() 93 | { 94 | // With a null or default-valued ID, use a reference-based hash code, to match Equals() 95 | return this.Id is null || this.Id.Equals(DefaultId) 96 | ? RuntimeHelpers.GetHashCode(this) 97 | : this.Id.GetHashCode(); 98 | } 99 | 100 | public override bool Equals(object? other) 101 | { 102 | return other is Entity otherId && this.Equals(otherId); 103 | } 104 | 105 | public virtual bool Equals(Entity? other) 106 | { 107 | if (other is null) 108 | return false; 109 | 110 | // Either we must be the same reference 111 | // Or we must have non-null, non-default, equal IDs (i.e. two entities with a default ID are not automatically considered the same entity) 112 | // We must also be of the same type, to avoid different subtypes using the same TId (an antipattern) from providing false positives 113 | // TODO Enhancement: For table-per-type (TPT), consider having a Fruit equal a Banana if their IDs match (hard to do efficiently) 114 | return ReferenceEquals(this, other) || 115 | (this.Id is not null && !this.Id.Equals(DefaultId) && this.Id.Equals(other.Id) && this.GetType() == other.GetType()); 116 | } 117 | } 118 | 119 | /// 120 | /// 121 | /// An entity is a data model that is defined by its identity and a thread of continuity. It may be mutated during its life cycle. 122 | /// 123 | /// 124 | [Serializable] 125 | public abstract class Entity : DomainObject, IEntity 126 | { 127 | } 128 | -------------------------------------------------------------------------------- /DomainModeling/IApplicationService.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// An application service exists on top of the domain model. It orchestrates use cases by calling methods on domain objects. 6 | /// 7 | /// 8 | /// Application services must be stateless. 9 | /// 10 | /// 11 | /// An application service does not make business decisions, which are part of the domain model itself. 12 | /// 13 | /// 14 | /// Often there are non-domain concerns, such as security, caching, or logging, that are added by the application service. 15 | /// 16 | /// 17 | public interface IApplicationService 18 | { 19 | } 20 | -------------------------------------------------------------------------------- /DomainModeling/IDomainObject.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// An object in the domain model. 5 | /// 6 | public interface IDomainObject 7 | { 8 | } 9 | -------------------------------------------------------------------------------- /DomainModeling/IDomainService.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// A domain service encapsulates behavior that does not belong to any specific or . 6 | /// 7 | /// 8 | /// Domain services must be stateless. 9 | /// 10 | /// 11 | public interface IDomainService : IDomainObject 12 | { 13 | } 14 | -------------------------------------------------------------------------------- /DomainModeling/IEntity.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// An entity is a data model that is defined by its identity and a thread of continuity. It may be mutated during its life cycle. 6 | /// 7 | /// 8 | public interface IEntity : IDomainObject 9 | { 10 | } 11 | -------------------------------------------------------------------------------- /DomainModeling/IIdentity.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// A specific used as an object's identity. 6 | /// 7 | /// 8 | /// This interface marks an identity type that wraps underlying type . 9 | /// 10 | /// 11 | public interface IIdentity : IValueObject 12 | where T : notnull, IEquatable, IComparable 13 | { 14 | } 15 | -------------------------------------------------------------------------------- /DomainModeling/ISerializableDomainObject.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | 3 | namespace Architect.DomainModeling; 4 | 5 | /// 6 | /// An of type that can be serialized and deserialized to underlying type . 7 | /// 8 | public interface ISerializableDomainObject< 9 | [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] TModel, 10 | TUnderlying> 11 | { 12 | /// 13 | /// Serializes a as a . 14 | /// 15 | TUnderlying? Serialize(); 16 | 17 | #if NET7_0_OR_GREATER 18 | /// 19 | /// Deserializes a from a . 20 | /// 21 | abstract static TModel Deserialize(TUnderlying value); 22 | #endif 23 | } 24 | -------------------------------------------------------------------------------- /DomainModeling/IValueObject.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// An immutable data model representing one or more values. 6 | /// 7 | /// 8 | /// Value objects are identified and compared by their values. 9 | /// 10 | /// 11 | /// Struct value objects should implement this interface, as they cannot inherit from . 12 | /// 13 | /// 14 | public interface IValueObject : IDomainObject 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /DomainModeling/IWrapperValueObject.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// An wrapping a single value, i.e. an immutable data model representing a single value. 6 | /// 7 | /// 8 | /// Value objects are identified and compared by their values. 9 | /// 10 | /// 11 | /// Struct value objects should implement this interface, as they cannot inherit from . 12 | /// 13 | /// 14 | public interface IWrapperValueObject : IValueObject 15 | where TValue : notnull 16 | { 17 | } 18 | -------------------------------------------------------------------------------- /DomainModeling/ValueObject.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// An immutable data model representing one or more values. 6 | /// 7 | /// 8 | /// Value objects are identified and compared by their values. 9 | /// 10 | /// 11 | /// This type offers protected methods to help perform common validations on its underlying data. 12 | /// 13 | /// 14 | [Serializable] 15 | public abstract partial class ValueObject : DomainObject, IValueObject 16 | { 17 | public override abstract string? ToString(); 18 | public override int GetHashCode() => throw new NotSupportedException(); 19 | public override bool Equals(object? obj) => throw new NotSupportedException(); 20 | 21 | /// 22 | /// 23 | /// The may use this to decide how to compare its immediate string properties. 24 | /// 25 | /// 26 | /// This setting does not affect non-string properties, properties of other types, or strings in collections. 27 | /// 28 | /// 29 | /// Generally, each string should be wrapped in its own , which handles its comparisons appropriately. 30 | /// That way, a multi-valued need not concern itself with this setting. 31 | /// 32 | /// 33 | protected virtual StringComparison StringComparison => StringComparison.Ordinal; 34 | 35 | public static bool operator ==(ValueObject? left, ValueObject? right) => left is null ? right is null : left.Equals(right); 36 | public static bool operator !=(ValueObject? left, ValueObject? right) => !(left == right); 37 | } 38 | -------------------------------------------------------------------------------- /DomainModeling/WrapperValueObject.cs: -------------------------------------------------------------------------------- 1 | namespace Architect.DomainModeling; 2 | 3 | /// 4 | /// 5 | /// A wrapping a single value, i.e. an immutable data model representing a single value. 6 | /// 7 | /// 8 | /// Value objects are identified and compared by their values. 9 | /// 10 | /// 11 | /// This type offers protected methods to help perform common validations on its underlying data. 12 | /// 13 | /// 14 | [Serializable] 15 | public abstract class WrapperValueObject : ValueObject, IWrapperValueObject 16 | where TValue : notnull 17 | { 18 | /// 19 | /// 20 | /// The may use this to decide how to compare its string value. 21 | /// 22 | /// 23 | /// This property is only relevant where is string, and may throw a otherwise. 24 | /// 25 | /// 26 | /// String-based subclasses should usually override this with one of the following: 27 | /// 28 | /// 29 | /// => StringComparison.Ordinal; 30 | /// => StringComparison.OrdinalIgnoreCase; 31 | /// 32 | /// 33 | protected abstract override StringComparison StringComparison { get; } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /pipeline-publish-preview.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | - master 3 | pr: none 4 | 5 | pool: 6 | vmImage: 'windows-latest' 7 | 8 | steps: 9 | 10 | # Explicit restore helps avoid the issue described here: 11 | # https://developercommunity.visualstudio.com/content/problem/983843/dotnet-build-task-does-not-use-nugetorg-for-one-pr.html 12 | - task: DotNetCoreCLI@2 13 | displayName: 'DotNet Restore' 14 | inputs: 15 | command: 'restore' 16 | includeNugetOrg: true 17 | projects: | 18 | **/*.csproj 19 | !**/*Tests*.csproj 20 | 21 | #- task: DotNetCoreCLI@2 22 | # displayName: 'DotNet Build' 23 | # inputs: 24 | # command: 'build' 25 | # arguments: '/WarnAsError --no-restore --configuration Release' 26 | # projects: | 27 | # **/*.csproj 28 | # 29 | #- task: DotNetCoreCLI@2 30 | # displayName: 'DotNet Test' 31 | # inputs: 32 | # command: 'test' 33 | # arguments: '--no-restore --no-build --configuration Release' 34 | # projects: | 35 | # **/*Tests*.csproj 36 | 37 | # DotNet Pack needs to be run from a script in order to use --version-suffix 38 | - script: dotnet pack $(Build.SourcesDirectory)/DomainModeling/DomainModeling.csproj --no-restore --configuration Release --version-suffix "preview-$(Build.BuildNumber)" -o $(Build.ArtifactStagingDirectory) 39 | displayName: 'DotNet Pack' 40 | 41 | - task: NuGetCommand@2 42 | displayName: 'NuGet Push' 43 | inputs: 44 | command: 'push' 45 | nuGetFeedType: 'external' 46 | publishFeedCredentials: 'NuGet' 47 | -------------------------------------------------------------------------------- /pipeline-publish-stable.yml: -------------------------------------------------------------------------------- 1 | trigger: none 2 | pr: none 3 | 4 | pool: 5 | vmImage: 'windows-latest' 6 | 7 | steps: 8 | 9 | # Explicit restore helps avoid the issue described here: 10 | # https://developercommunity.visualstudio.com/content/problem/983843/dotnet-build-task-does-not-use-nugetorg-for-one-pr.html 11 | - task: DotNetCoreCLI@2 12 | displayName: 'DotNet Restore' 13 | inputs: 14 | command: 'restore' 15 | includeNugetOrg: true 16 | projects: | 17 | **/*.csproj 18 | 19 | - task: DotNetCoreCLI@2 20 | displayName: 'DotNet Build' 21 | inputs: 22 | command: 'build' 23 | arguments: '/WarnAsError --no-restore --configuration Release' 24 | projects: | 25 | **/*.csproj 26 | 27 | - task: DotNetCoreCLI@2 28 | displayName: 'DotNet Test' 29 | inputs: 30 | command: 'test' 31 | arguments: '--no-restore --no-build --configuration Release' 32 | projects: | 33 | **/*Tests*.csproj 34 | 35 | - script: dotnet pack $(Build.SourcesDirectory)/DomainModeling/DomainModeling.csproj /WarnAsError --no-restore --configuration Release -o $(Build.ArtifactStagingDirectory) 36 | displayName: 'DotNet Pack' 37 | 38 | - task: NuGetCommand@2 39 | displayName: 'NuGet Push' 40 | inputs: 41 | command: 'push' 42 | nuGetFeedType: 'external' 43 | publishFeedCredentials: 'NuGet' 44 | -------------------------------------------------------------------------------- /pipeline-verify.yml: -------------------------------------------------------------------------------- 1 | trigger: none 2 | pr: 3 | - master 4 | 5 | pool: 6 | vmImage: 'windows-latest' 7 | 8 | steps: 9 | 10 | # Explicit restore helps avoid the issue described here: 11 | # https://developercommunity.visualstudio.com/content/problem/983843/dotnet-build-task-does-not-use-nugetorg-for-one-pr.html 12 | - task: DotNetCoreCLI@2 13 | displayName: 'DotNet Restore' 14 | inputs: 15 | command: 'restore' 16 | includeNugetOrg: true 17 | projects: | 18 | **/*.csproj 19 | 20 | - task: DotNetCoreCLI@2 21 | displayName: 'DotNet Test' 22 | inputs: 23 | command: 'test' 24 | arguments: '/WarnAsError --no-restore --configuration Release' 25 | projects: | 26 | **/*Tests*.csproj 27 | --------------------------------------------------------------------------------