diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3329eb8..b2a0d94 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -22,6 +22,9 @@ jobs: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 + # The NativeAOT test application copies test databases out of this + # submodule, so autobuild cannot compile the solution without it. + submodules: true persist-credentials: false # If this run was triggered by a pull request event, then checkout diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a57825d..19b69aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,8 +39,43 @@ jobs: MAXMIND_BENCHMARK_DB: ${{ github.workspace }}/MaxMind.Db.Test/TestData/MaxMind-DB/test-data/GeoIP2-City-Test.mmdb + # Without this the benchmark falls back to 1,000 pseudo-random addresses, + # which all miss in this 26-record database: no record is found, no model + # is ever constructed, and it reports zero allocations. These addresses + # hit, so the decode path is actually measured. + MAXMIND_BENCHMARK_IP_ADDRESSES: 2.125.160.216,67.43.156.1,81.2.69.142,81.2.69.144,81.2.69.160,89.160.20.112,89.160.20.128,149.101.100.1,175.16.199.1,202.196.224.1,216.160.83.56,216.160.83.61 - name: Run tests run: dotnet test env: MAXMIND_TEST_BASE_DIR: ${{ github.workspace }}/MaxMind.Db.Test + + native-aot: + strategy: + matrix: + include: + - platform: ubuntu-latest + rid: linux-x64 + - platform: windows-latest + rid: win-x64 + - platform: macos-latest + rid: osx-arm64 + runs-on: ${{ matrix.platform }} + name: NativeAOT on ${{ matrix.platform }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Publish and run NativeAOT integration tests + shell: bash + run: bash ./dev-bin/test-native-aot.sh ${{ matrix.rid }} diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..ee38941 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,3 @@ +{ + "ignores": ["MaxMind.Db.SourceGenerator/AnalyzerReleases.*.md"] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..66dcf1e --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +MaxMind.Db.SourceGenerator/AnalyzerReleases.*.md diff --git a/MaxMind.Db.Benchmark/MaxMind.Db.Benchmark.csproj b/MaxMind.Db.Benchmark/MaxMind.Db.Benchmark.csproj index 906492f..b3c60c4 100644 --- a/MaxMind.Db.Benchmark/MaxMind.Db.Benchmark.csproj +++ b/MaxMind.Db.Benchmark/MaxMind.Db.Benchmark.csproj @@ -32,6 +32,9 @@ + diff --git a/MaxMind.Db.NativeAot/App/MaxMind.Db.NativeAot.App.csproj b/MaxMind.Db.NativeAot/App/MaxMind.Db.NativeAot.App.csproj new file mode 100644 index 0000000..be1a6cf --- /dev/null +++ b/MaxMind.Db.NativeAot/App/MaxMind.Db.NativeAot.App.csproj @@ -0,0 +1,28 @@ + + + + Exe + true + true + full + true + false + + + + + + + + + + + + + diff --git a/MaxMind.Db.NativeAot/App/Program.cs b/MaxMind.Db.NativeAot/App/Program.cs new file mode 100644 index 0000000..937bcf8 --- /dev/null +++ b/MaxMind.Db.NativeAot/App/Program.cs @@ -0,0 +1,175 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Runtime.CompilerServices; +using MaxMind.Db; +using MaxMind.Db.NativeAot.Models; +using MaxMind.Db.NetStandard.TestModels; + +namespace MaxMind.Db.NativeAot.App +{ + internal static class Program + { + private static readonly IPAddress _decoderAddress = IPAddress.Parse("1.1.1.1"); + private static readonly IPAddress _cityAddress = IPAddress.Parse("81.2.69.160"); + + private static void Main() + { + Check(!RuntimeFeature.IsDynamicCodeSupported, "The integration test must run as NativeAOT."); + Check( + typeof(CityResponse).Assembly != typeof(Reader).Assembly, + "Models must be generated in a separate assembly."); + + foreach (var mode in new[] { FileAccessMode.MemoryMapped, FileAccessMode.Memory }) + { + TestDecoderDatabase(mode); + TestCityDatabase(mode); + TestReflectionFallbackFailsWithGuidance(mode); + } + + Console.WriteLine("NativeAOT integration tests passed."); + } + + private static void TestDecoderDatabase(FileAccessMode mode) + { + var database = Path.Combine(AppContext.BaseDirectory, "MaxMind-DB-test-decoder.mmdb"); + using var reader = new Reader(database, mode); + + Check(reader.Metadata.DatabaseType == "MaxMind DB Decoder Test", "Unexpected metadata."); + + var dictionary = reader.Find>(_decoderAddress) ?? + throw new InvalidOperationException("Dictionary lookup returned null."); + Check( + (string)dictionary["utf8_string"] == "unicode! ☯ - ♫", + "Dictionary string did not decode."); + + var concurrentDictionary = + reader.Find>(_decoderAddress) ?? + throw new InvalidOperationException( + "ConcurrentDictionary lookup returned null."); + Check( + (string)concurrentDictionary["utf8_string"] == "unicode! ☯ - ♫", + "Source-generated top-level dictionary did not decode."); + + var constructorModel = reader.Find(_decoderAddress) ?? + throw new InvalidOperationException("Constructor model lookup returned null."); + Check( + constructorModel.Utf8String == "unicode! ☯ - ♫", + "Constructor model string did not decode."); + Check( + SequenceEquals(constructorModel.Array, 1, 2, 3), + "IReadOnlyList did not decode."); + Check( + NestedStringEquals(constructorModel.Map, "hello"), + "IReadOnlyDictionary did not decode."); + + var netStandardModel = reader.Find(_decoderAddress) ?? + throw new InvalidOperationException(".NET Standard model lookup returned null."); + Check( + netStandardModel.Utf8String == "unicode! ☯ - ♫", + ".NET Standard model did not decode."); + + var propertyModel = reader.Find(_decoderAddress) ?? + throw new InvalidOperationException("Property model lookup returned null."); + Check(SequenceEquals(propertyModel.Array, 1, 2, 3), "ICollection did not decode."); + Check( + NestedStringEquals(propertyModel.Map, "hello"), + "Dictionary did not decode."); + + var concreteModel = reader.Find(_decoderAddress) ?? + throw new InvalidOperationException("Concrete collection model lookup returned null."); + Check(SequenceEquals(concreteModel.Array, 1, 2, 3), "LinkedList did not decode."); + Check( + NestedStringEquals(concreteModel.Map, "hello"), + "Concrete dictionary did not decode."); + + var count = 0; + foreach (var _ in reader.FindAll()) + { + count++; + } + Check(count == 26, $"FindAll returned {count} records instead of 26."); + } + + private static void TestCityDatabase(FileAccessMode mode) + { + var database = Path.Combine(AppContext.BaseDirectory, "GeoIP2-City-Test.mmdb"); + var injectables = new InjectableValues(); + injectables.AddValue("locales", (IReadOnlyList)["en"]); + using var reader = new Reader(database, mode); + + var response = reader.Find(_cityAddress, injectables) ?? + throw new InvalidOperationException("City lookup returned null."); + Check(response.City.Name == "London", "City name did not decode."); + Check(response.Subdivisions.Count == 1, "Subdivisions did not decode."); + Check( + response.Subdivisions[0].Name == "England", + "Subdivision name did not decode."); + Check( + response.Traits.Network?.ToString() == "81.2.69.160/27", + "Network did not decode."); + } + + // ReflectionFallbackModel has no generated registration, so this is the only + // coverage of what the reflection fallback does once trimmed and published as + // NativeAOT; no analyzer reports that path. Full trimming removes the members + // reflection needs, so the lookup fails before Expression.Compile is ever + // reached. What matters is that the failure stays actionable. + private static void TestReflectionFallbackFailsWithGuidance(FileAccessMode mode) + { + var database = Path.Combine(AppContext.BaseDirectory, "MaxMind-DB-test-decoder.mmdb"); + using var reader = new Reader(database, mode); + + try + { + reader.Find(_decoderAddress); + } + catch (DeserializationException ex) + { + Check( + ex.Message.Contains(nameof(ReflectionFallbackModel)), + "The fallback failure must name the model."); + Check( + ex.Message.Contains("rebuild the assembly that declares the model"), + "The fallback failure must point at the source generator."); + return; + } + + throw new InvalidOperationException( + "Decoding a model without a generated registration must fail once trimmed."); + } + + private static bool SequenceEquals(IEnumerable values, params long[] expected) + { + var index = 0; + foreach (var value in values) + { + if (index >= expected.Length || value != expected[index]) + { + return false; + } + index++; + } + return index == expected.Length; + } + + private static bool NestedStringEquals( + IReadOnlyDictionary map, + string expected + ) + => map.TryGetValue("mapX", out var nested) && + nested is IReadOnlyDictionary inner && + inner.TryGetValue("utf8_stringX", out var value) && + (value as string) == expected; + + private static void Check(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + } +} diff --git a/MaxMind.Db.NativeAot/Directory.Build.props b/MaxMind.Db.NativeAot/Directory.Build.props new file mode 100644 index 0000000..a6064c0 --- /dev/null +++ b/MaxMind.Db.NativeAot/Directory.Build.props @@ -0,0 +1,32 @@ + + + + net8.0 + 14.0 + enable + true + true + true + latest + true + + + + + + + + + + + + + + + + diff --git a/MaxMind.Db.NativeAot/Models/MaxMind.Db.NativeAot.Models.csproj b/MaxMind.Db.NativeAot/Models/MaxMind.Db.NativeAot.Models.csproj new file mode 100644 index 0000000..f35321b --- /dev/null +++ b/MaxMind.Db.NativeAot/Models/MaxMind.Db.NativeAot.Models.csproj @@ -0,0 +1,19 @@ + + + + + true + + + + + + + + + + diff --git a/MaxMind.Db.NativeAot/Models/Models.cs b/MaxMind.Db.NativeAot/Models/Models.cs new file mode 100644 index 0000000..ffe0662 --- /dev/null +++ b/MaxMind.Db.NativeAot/Models/Models.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using MaxMind.Db; + +namespace MaxMind.Db.NativeAot.Models +{ + public abstract record NamedEntity + { + [MapKey("names")] + public IReadOnlyDictionary Names { get; init; } = + new Dictionary(); + + [Inject("locales")] + public IReadOnlyList Locales { get; init; } = ["en"]; + + public string? Name + { + get + { + foreach (var locale in Locales) + { + if (Names.TryGetValue(locale, out var name)) + { + return name; + } + } + return null; + } + } + } + + public sealed record City : NamedEntity; + + public sealed record Subdivision : NamedEntity; + + public sealed record Traits + { + [Network] + public Network? Network { get; init; } + } + + public abstract record CityResponseBase + { + [MapKey("city", true)] + public City City { get; init; } = new(); + + [MapKey("subdivisions")] + public IReadOnlyList Subdivisions { get; init; } = []; + + [MapKey("traits", true)] + public Traits Traits { get; init; } = new(); + } + + public sealed record CityResponse : CityResponseBase; + + public sealed class DecoderConstructorModel + { + [Constructor] + public DecoderConstructorModel( + [MapKey("utf8_string")] string utf8String, + [MapKey("array")] IReadOnlyList array, + [MapKey("map")] IReadOnlyDictionary map + ) + { + Utf8String = utf8String; + Array = array; + Map = map; + } + + public IReadOnlyList Array { get; } + public IReadOnlyDictionary Map { get; } + public string Utf8String { get; } + } + + public abstract record DecoderPropertyModelBase + { + [MapKey("array")] + public ICollection Array { get; init; } = new List(); + + [MapKey("map")] + public Dictionary Map { get; init; } = new(); + } + + public sealed record DecoderPropertyModel : DecoderPropertyModelBase; + + public sealed record DecoderConcreteCollectionModel + { + [MapKey("array")] + public LinkedList Array { get; init; } = new(); + + [MapKey("map")] + public ConcreteDictionary Map { get; init; } = new(); + } + + public sealed class ConcreteDictionary : Dictionary + where TKey : notnull + { + } + + // Deriving from MapKeyAttribute is a shape the generator cannot evaluate, so + // ReflectionFallbackModel is deliberately left without a generated registration. + // It is what pins the behavior of the reflection fallback under a real NativeAOT + // publish, which no analyzer covers: IL3050 is not reported for the + // Expression.Compile calls that path relies on. + public sealed class Utf8KeyAttribute : MapKeyAttribute + { + public Utf8KeyAttribute() : base("utf8_string") + { + } + } + + // MMDBSG011 is suppressed here rather than project-wide, and only because this + // model exists to exercise the skip. A project-wide NoWarn would hide real + // diagnostics in the rest of the sample. +#pragma warning disable MMDBSG011 + public sealed record ReflectionFallbackModel + { + [Utf8Key] + public string? Utf8String { get; init; } + } +#pragma warning restore MMDBSG011 +} diff --git a/MaxMind.Db.NativeAot/NuGet.Config b/MaxMind.Db.NativeAot/NuGet.Config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/MaxMind.Db.NativeAot/NuGet.Config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/MaxMind.Db.NetStandard.TestModels/MaxMind.Db.NetStandard.TestModels.csproj b/MaxMind.Db.NetStandard.TestModels/MaxMind.Db.NetStandard.TestModels.csproj new file mode 100644 index 0000000..f3b42cd --- /dev/null +++ b/MaxMind.Db.NetStandard.TestModels/MaxMind.Db.NetStandard.TestModels.csproj @@ -0,0 +1,26 @@ + + + + netstandard2.1;netstandard2.0 + 14.0 + enable + true + true + latest + true + false + + + + + + + + + + + + diff --git a/MaxMind.Db.NetStandard.TestModels/Models.cs b/MaxMind.Db.NetStandard.TestModels/Models.cs new file mode 100644 index 0000000..2598828 --- /dev/null +++ b/MaxMind.Db.NetStandard.TestModels/Models.cs @@ -0,0 +1,23 @@ +namespace MaxMind.Db.NetStandard.TestModels +{ + /// + /// A generated model used to verify .NET Standard package consumers. + /// + public sealed class NetStandardModel + { + /// + /// Initializes a new instance of the class. + /// + /// The decoded UTF-8 string. + [Constructor] + public NetStandardModel([MapKey("utf8_string")] string utf8String) + { + Utf8String = utf8String; + } + + /// + /// Gets the decoded UTF-8 string. + /// + public string Utf8String { get; } + } +} diff --git a/MaxMind.Db.ReflectionFallback.TestModels/MaxMind.Db.ReflectionFallback.TestModels.csproj b/MaxMind.Db.ReflectionFallback.TestModels/MaxMind.Db.ReflectionFallback.TestModels.csproj new file mode 100644 index 0000000..bbda816 --- /dev/null +++ b/MaxMind.Db.ReflectionFallback.TestModels/MaxMind.Db.ReflectionFallback.TestModels.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + ../MaxMind.snk + true + true + 14.0 + enable + true + true + latest + true + + + + + + + diff --git a/MaxMind.Db.ReflectionFallback.TestModels/Models.cs b/MaxMind.Db.ReflectionFallback.TestModels/Models.cs new file mode 100644 index 0000000..8fcc5e7 --- /dev/null +++ b/MaxMind.Db.ReflectionFallback.TestModels/Models.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; + +namespace MaxMind.Db.ReflectionFallback.TestModels +{ + public sealed class FallbackList : List + { + } + + public sealed class ReflectionConstructorModel + { + [Constructor] + public ReflectionConstructorModel( + [MapKey("utf8_string")] string utf8String, + [MapKey("array")] FallbackList values + ) + { + Utf8String = utf8String; + Values = values; + } + + public string Utf8String { get; } + public FallbackList Values { get; } + } + + public sealed class ReflectionPropertyModel + { + [MapKey("missing")] + public string Missing { get; set; } = "preserved default"; + + [MapKey("utf8_string")] + public string? Utf8String { get; set; } + + [MapKey("array")] + public FallbackList? Values { get; set; } + } + + public sealed class ReflectionInnerModel + { + [MapKey("utf8_stringX")] + public string? Value { get; set; } + } + + public sealed class ReflectionAlwaysCreateConstructorModel + { + [Constructor] + public ReflectionAlwaysCreateConstructorModel( + [MapKey("no_such_key", true)] long absentValueType, + [MapKey("no_such_map", true)] ReflectionInnerModel absentModel + ) + { + AbsentValueType = absentValueType; + AbsentModel = absentModel; + } + + public ReflectionInnerModel AbsentModel { get; } + public long AbsentValueType { get; } + } + + public sealed class ReflectionAlwaysCreatePropertyModel + { + [MapKey("no_such_map", true)] + public ReflectionInnerModel? AbsentModel { get; set; } + + [MapKey("no_such_key", true)] + public long AbsentValueType { get; set; } + } +} diff --git a/MaxMind.Db.ReflectionFallback.TestModels/TypeHolders.cs b/MaxMind.Db.ReflectionFallback.TestModels/TypeHolders.cs new file mode 100644 index 0000000..01dd609 --- /dev/null +++ b/MaxMind.Db.ReflectionFallback.TestModels/TypeHolders.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Numerics; + +namespace MaxMind.Db.ReflectionFallback.TestModels +{ + // Mirrors MaxMind.Db.Test.Helper.TypeHolder and PropTypeHolder. Those models live + // in an assembly that references the source generator, so they exercise generated + // activation; these are the same shapes on the reflection fallback, which is what + // every consumer gets until they rebuild against a generator-bearing MaxMind.Db. + public class ReflectionInnerMapX + { + [Constructor] + public ReflectionInnerMapX( + string utf8_stringX, + [Network] Network network, + LinkedList arrayX + ) + { + ArrayX = arrayX; + Network = network; + Utf8StringX = utf8_stringX; + } + + public LinkedList ArrayX { get; } + public Network Network { get; } + public string Utf8StringX { get; } + } + + public class ReflectionInnerMap + { + [Constructor] + public ReflectionInnerMap(ReflectionInnerMapX mapX) + { + MapX = mapX; + } + + public ReflectionInnerMapX MapX { get; } + } + + public class ReflectionInnerNonexistant + { + [Constructor] + public ReflectionInnerNonexistant( + [Inject("injected")] string injected, + [Network] Network network + ) + { + Injected = injected; + Network = network; + } + + public string Injected { get; } + public Network Network { get; } + } + + public class ReflectionNonexistant + { + [Constructor] + public ReflectionNonexistant( + [MapKey("innerNonexistant", true)] ReflectionInnerNonexistant innerNonexistant, + [Inject("injected")] string injected, + [Network] Network network + ) + { + Injected = injected; + InnerNonexistant = innerNonexistant; + Network = network; + } + + public string Injected { get; } + public ReflectionInnerNonexistant InnerNonexistant { get; } + public Network Network { get; } + } + + public class ReflectionTypeHolder + { + [Constructor] + public ReflectionTypeHolder( + string utf8_string, + byte[] bytes, + int uint16, + long uint32, + ulong uint64, + BigInteger uint128, + int int32, + bool boolean, + ICollection array, + [MapKey("double")] double mmDouble, + [MapKey("float")] float mmFloat, + [MapKey("map")] ReflectionInnerMap map, + [MapKey("nonexistant", true)] ReflectionNonexistant nonexistant + ) + { + Array = array; + Boolean = boolean; + Bytes = bytes; + Double = mmDouble; + Float = mmFloat; + Int32 = int32; + Map = map; + Nonexistant = nonexistant; + Uint16 = uint16; + Uint32 = uint32; + Uint64 = uint64; + Uint128 = uint128; + Utf8String = utf8_string; + } + + public ICollection Array { get; } + public bool Boolean { get; } + public byte[] Bytes { get; } + public double Double { get; } + public float Float { get; } + public long Int32 { get; } + public ReflectionInnerMap Map { get; } + public ReflectionNonexistant Nonexistant { get; } + public int Uint16 { get; } + public long Uint32 { get; } + public ulong Uint64 { get; } + public BigInteger Uint128 { get; } + public string Utf8String { get; } + } + + public class ReflectionPropInnerMapX + { + [MapKey("arrayX")] + public LinkedList? ArrayX { get; set; } + + [Network] + public Network? Network { get; set; } + + [MapKey("utf8_stringX")] + public string? Utf8StringX { get; set; } + } + + public class ReflectionPropInnerMap + { + [MapKey("mapX")] + public ReflectionPropInnerMapX? MapX { get; set; } + } + + public class ReflectionPropTypeHolder + { + [MapKey("array")] + public ICollection? Array { get; set; } + + [MapKey("boolean")] + public bool Boolean { get; set; } + + [MapKey("bytes")] + public byte[]? Bytes { get; set; } + + [MapKey("double")] + public double Double { get; set; } + + [MapKey("float")] + public float Float { get; set; } + + [MapKey("int32")] + public int Int32 { get; set; } + + [MapKey("map")] + public ReflectionPropInnerMap? Map { get; set; } + + [Inject("injected")] + public string? Injected { get; set; } + + [Network] + public Network? Network { get; set; } + + [MapKey("uint128")] + public BigInteger Uint128 { get; set; } + + [MapKey("uint16")] + public int Uint16 { get; set; } + + [MapKey("uint32")] + public long Uint32 { get; set; } + + [MapKey("uint64")] + public ulong Uint64 { get; set; } + + [MapKey("utf8_string")] + public string? Utf8String { get; set; } + } +} diff --git a/MaxMind.Db.SourceGenerator.Test/MaxMind.Db.SourceGenerator.Test.csproj b/MaxMind.Db.SourceGenerator.Test/MaxMind.Db.SourceGenerator.Test.csproj new file mode 100644 index 0000000..715e0ad --- /dev/null +++ b/MaxMind.Db.SourceGenerator.Test/MaxMind.Db.SourceGenerator.Test.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + Exe + 14.0 + enable + true + true + latest + true + true + + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers + all + + + + diff --git a/MaxMind.Db.SourceGenerator.Test/MaxMindDbSourceGeneratorTest.cs b/MaxMind.Db.SourceGenerator.Test/MaxMindDbSourceGeneratorTest.cs new file mode 100644 index 0000000..e7e7849 --- /dev/null +++ b/MaxMind.Db.SourceGenerator.Test/MaxMindDbSourceGeneratorTest.cs @@ -0,0 +1,1420 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using MaxMind.Db.SourceGenerator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace MaxMind.Db.SourceGenerator.Test +{ + public class MaxMindDbSourceGeneratorTest + { + [Fact] + public void GeneratesConstructorMetadataDeterministically() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class ConstructorModel + { + [Constructor] + internal ConstructorModel( + [MapKey("city", true)] string city, + [Inject("locales")] string[] locales, + [Network] Network network) + { + } + } + """; + + var first = RunGenerator(modelSource, aotDiagnostics: true); + var second = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Equal(first.Source, second.Source); + Assert.Contains("[global::System.Runtime.CompilerServices.ModuleInitializerAttribute]", first.Source); + Assert.Contains("new global::Models.ConstructorModel(", first.Source); + Assert.Contains("new global::MaxMind.Db.GeneratedMember[]", first.Source); + Assert.Contains( + "global::MaxMind.Db.GeneratedMember.Mapped(\"city\", typeof(global::System.String), true)", + first.Source); + Assert.Contains( + "global::MaxMind.Db.GeneratedMember.Injected(\"locales\", typeof(global::System.String[]))", + first.Source); + Assert.Contains( + "global::MaxMind.Db.GeneratedMember.Networked(typeof(global::MaxMind.Db.Network))", + first.Source); + Assert.DoesNotContain( + first.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG008"); + Assert.Empty(first.Errors); + } + + [Fact] + public void GeneratesPositionalRecordConstructor() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + [method: Constructor] + internal sealed record PositionalModel(string value); + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains("new global::Models.PositionalModel(", result.Source); + Assert.Contains( + "global::MaxMind.Db.GeneratedMember.Mapped(\"value\", typeof(global::System.String), false)", + result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesPrimaryClassConstructor() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + [method: Constructor] + internal sealed class PrimaryModel( + [MapKey("database_value")] string value) + { + internal string Value { get; } = value; + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains("new global::Models.PrimaryModel(", result.Source); + Assert.Contains( + "global::MaxMind.Db.GeneratedMember.Mapped(\"database_value\", typeof(global::System.String), false)", + result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesConcretePropertyModelWithInheritedAttributes() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal abstract record CityResponseBase + { + [MapKey("city")] + public string? City { get; init; } + + [Inject("locales")] + public string[] Locales { get; init; } = []; + } + + internal sealed record CityResponse : CityResponseBase; + """; + + var result = RunGenerator(modelSource); + + Assert.Contains("new global::Models.CityResponse", result.Source); + Assert.Contains("City = (global::System.String)values[0]!", result.Source); + Assert.Contains("Locales = (global::System.String[])values[1]!", result.Source); + Assert.Contains("var instance = new global::Models.CityResponse();", result.Source); + Assert.DoesNotContain("CityResponseBase),", result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void SupportsDeprecatedParameterAttribute() + { + const string modelSource = """ + #pragma warning disable CS0618 + using MaxMind.Db; + + namespace Models; + + internal sealed class DeprecatedModel + { + [Constructor] + internal DeprecatedModel([Parameter("database_name", true)] string value) + { + } + } + """; + + var result = RunGenerator(modelSource); + + Assert.Contains( + "global::MaxMind.Db.GeneratedMember.Mapped(\"database_name\", typeof(global::System.String), true)", + result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsDerivedMapKeyAttributesForAotAndFallsBackOtherwise() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class PrefixedKeyAttribute : MapKeyAttribute + { + internal PrefixedKeyAttribute(string name) + : base("prefix_" + name, true) + { + } + } + + internal sealed class DerivedAttributeModel + { + [Constructor] + internal DerivedAttributeModel([PrefixedKey("city")] string value) + { + } + } + + internal sealed class DerivedAttributePropertyModel + { + [PrefixedKey("country")] + internal string? Value { get; set; } + } + """; + + var normalResult = RunGenerator(modelSource); + var aotResult = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Empty(normalResult.Source); + Assert.Empty(normalResult.Diagnostics); + Assert.Empty(normalResult.Errors); + Assert.Equal( + 2, + aotResult.Diagnostics.Count(diagnostic => diagnostic.Id == "MMDBSG011")); + Assert.Empty(aotResult.Source); + Assert.Empty(aotResult.Errors); + } + + [Fact] + public void ReportsAotDiagnosticsOnlyWhenEnabled() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class InaccessibleModel + { + [Constructor] + private InaccessibleModel(string value) + { + } + } + """; + + var normalResult = RunGenerator(modelSource); + var aotResult = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.DoesNotContain( + normalResult.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG002"); + Assert.Contains( + aotResult.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG002"); + } + + [Fact] + public void FallsBackBeforeCSharp9() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models + { + internal sealed class ConstructorModel + { + [Constructor] + internal ConstructorModel(string value) + { + } + } + } + """; + + var normalResult = RunGenerator( + modelSource, + languageVersion: LanguageVersion.CSharp8); + var aotResult = RunGenerator( + modelSource, + aotDiagnostics: true, + languageVersion: LanguageVersion.CSharp8); + + Assert.Empty(normalResult.Source); + Assert.Empty(normalResult.Errors); + Assert.Contains( + aotResult.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG009"); + Assert.Empty(aotResult.Errors); + } + + [Fact] + public void ReportsCollectionOnlyGenerationBeforeCSharp9() + { + const string modelSource = """ + using System.Collections.Generic; + using MaxMind.Db; + + internal sealed class Lookup + { + internal void Run(Reader reader) + { + _ = reader.FindAll>(); + } + } + """; + + var result = RunGenerator( + modelSource, + aotDiagnostics: true, + languageVersion: LanguageVersion.CSharp8); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG009"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesRegistrationInCSharp9() + { + const string modelSource = """ + using MaxMind.Db; + + internal sealed class ConstructorModel + { + [Constructor] + internal ConstructorModel(string value) + { + } + } + """; + + var result = RunGenerator( + modelSource, + aotDiagnostics: true, + languageVersion: LanguageVersion.CSharp9); + + Assert.Contains("new global::MaxMind.Db.GeneratedMember[]", result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsFileLocalModelsForAot() + { + const string modelSource = """ + using MaxMind.Db; + + file sealed class FileModel + { + [Constructor] + internal FileModel(string value) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG001"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsHiddenAnnotatedPropertyForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal class BaseModel + { + [MapKey("utf8_string")] + internal string? Value { get; init; } + } + + internal sealed class ShadowModel : BaseModel + { + internal new int Value { get; init; } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG014"); + Assert.DoesNotContain("Models.ShadowModel", result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesOverriddenAnnotatedPropertyWithoutHidingDiagnostic() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal class OverrideBaseModel + { + [MapKey("utf8_string")] + internal virtual string? Value { get; set; } + } + + internal sealed class OverrideModel : OverrideBaseModel + { + internal override string? Value { get; set; } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG014"); + Assert.Contains("new global::Models.OverrideModel", result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsMapKeyCombinedWithInjectForAot() + { + const string modelSource = """ + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class ConflictModel + { + [Constructor] + internal ConflictModel( + [MapKey("ip")] [Inject("ip_address")] IPAddress? address = null) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG013"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsMapKeyCombinedWithNetworkForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed record NetworkConflictModel + { + [MapKey("net")] + [Network] + internal Network? Value { get; init; } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG013"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void InjectableMemberNamesDoNotCollideWithMapKeys() + { + const string modelSource = """ + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class InjectModel + { + [Constructor] + internal InjectModel( + [MapKey("city")] string? name = null, + [Inject("ip_address")] IPAddress? city = null) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG005"); + Assert.Contains("new global::Models.InjectModel(", result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsStructModelsForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal struct StructModel + { + [Constructor] + internal StructModel([MapKey("utf8_string")] string value) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG012"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsRecordStructPropertyModelsForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal record struct RecordStructModel + { + [MapKey("utf8_string")] + internal string? Value { get; init; } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG012"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void SkipsStructModelsWithoutAotDiagnostics() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal struct StructModel + { + [Constructor] + internal StructModel([MapKey("utf8_string")] string value) + { + } + } + """; + + var result = RunGenerator(modelSource); + + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG012"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsPositionalRecordWithoutConstructorAttributeForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed record PositionalModel([MapKey("v")] string Value); + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG016"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void IgnoresUnannotatedTypesWithoutDiagnostics() + { + const string modelSource = """ + using System; + using System.Collections.Generic; + + namespace Models; + + internal sealed class Unrelated : List, IDisposable + { + internal Unrelated(string name) + { + Name = name; + } + + internal string Name { get; } + + public void Dispose() + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsRequiredFieldsForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class RequiredFieldModel + { + [Constructor] + internal RequiredFieldModel([MapKey("utf8_string")] string value) + { + } + + internal required string Extra; + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG010"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsInaccessibleInheritedPropertyForAot() + { + const string baseSource = """ + using MaxMind.Db; + + namespace ExternalModels + { + public abstract class ExternalBase + { + [MapKey("value")] + public string? Value { get; internal init; } + } + } + """; + const string modelSource = """ + namespace Models; + + internal sealed class DerivedModel : ExternalModels.ExternalBase; + """; + var baseReference = CompileReference(baseSource); + + var result = RunGenerator( + modelSource, + aotDiagnostics: true, + additionalReferences: [baseReference]); + + var diagnostic = Assert.Single( + result.Diagnostics, + candidate => candidate.Id == "MMDBSG003"); + // The offending property lives in a referenced assembly, so without a + // fallback the warning lands at Location.None with no file or line. + Assert.True(diagnostic.Location.IsInSource); + Assert.Contains("DerivedModel", diagnostic.Location.SourceTree!.ToString()); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsOpenGenericModelsForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class GenericModel + { + [Constructor] + internal GenericModel(T value) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG004"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsModelsNestedInGenericTypesForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class Outer + { + internal sealed class NestedModel + { + [Constructor] + internal NestedModel(string value) + { + } + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Equal( + 1, + result.Diagnostics.Count(diagnostic => diagnostic.Id == "MMDBSG004")); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsDuplicateMapKeysForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class DuplicateModel + { + [Constructor] + internal DuplicateModel( + [MapKey("value")] string first, + [MapKey("value")] string second) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG005"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsMissingParameterlessConstructorsForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class PropertyModel + { + internal PropertyModel(string value) + { + } + + [MapKey("value")] + internal string? Value { get; set; } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG006"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsMultipleDeserializationConstructorsForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class AmbiguousModel + { + [Constructor] + internal AmbiguousModel(string value) + { + } + + [Constructor] + internal AmbiguousModel(long value) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG007"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsRequiredMembersWithoutSetsRequiredMembersForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class RequiredModel + { + [MapKey("value")] + public required string Value { get; init; } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG010"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesRequiredMembersWithSetsRequiredMembersConstructor() + { + const string modelSource = """ + using System.Diagnostics.CodeAnalysis; + using MaxMind.Db; + + namespace Models; + + internal sealed class RequiredModel + { + [SetsRequiredMembers] + internal RequiredModel() + { + Value = string.Empty; + } + + [MapKey("value")] + public required string Value { get; init; } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains("new global::Models.RequiredModel", result.Source); + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG010"); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesCollectionFactoriesAndAddDelegates() + { + const string modelSource = """ + using System.Collections.Generic; + using MaxMind.Db; + + namespace Models; + + internal sealed class CustomList : List; + internal sealed class CustomDictionary : Dictionary + where TKey : notnull; + internal sealed class NonGenericDictionary : Dictionary; + + internal sealed class CollectionModel + { + [Constructor] + internal CollectionModel( + ICollection collection, + IReadOnlyList readOnly, + LinkedList linked, + CustomList concrete, + IDictionary dictionary, + IReadOnlyDictionary readOnlyDictionary, + CustomDictionary concreteDictionary, + NonGenericDictionary nonGenericDictionary) + { + } + } + """; + + var result = RunGenerator(modelSource); + + Assert.Contains("RegisterCollection<", result.Source); + Assert.Contains("RegisterCollection, global::System.Int64>(", result.Source); + Assert.Contains("capacity => new global::System.Collections.Generic.List(capacity)", result.Source); + Assert.Contains("capacity => new global::System.Collections.Generic.LinkedList()", result.Source); + Assert.Contains("capacity => new global::Models.CustomList()", result.Source); + Assert.Contains("RegisterDictionary<", result.Source); + Assert.Contains("RegisterDictionary, global::System.String, global::System.Int64>(", result.Source); + Assert.Contains("capacity => new global::Models.CustomDictionary()", result.Source); + Assert.Contains( + "RegisterDictionary(", + result.Source); + Assert.Contains("capacity => new global::Models.NonGenericDictionary()", result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesCollectionsUsedByReaderCalls() + { + const string modelSource = """ + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class Lookup + { + internal void Run(Reader reader, IPAddress address) + { + _ = reader.Find>(address); + _ = reader.FindAll>(); + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + "RegisterDictionary, global::System.String, global::System.Object>(", + result.Source); + Assert.Contains( + "RegisterCollection, global::System.Int64>(", + result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void GeneratesCollectionsUsedByConditionalReaderCalls() + { + const string modelSource = """ + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class Lookup + { + internal void Run(Reader? reader, IPAddress address) + { + _ = reader?.Find>(address); + _ = reader?.FindAll>(); + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + "RegisterDictionary, global::System.String, global::System.Object>(", + result.Source); + Assert.Contains( + "RegisterCollection, global::System.Int64>(", + result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsClosedCollectionsContainingTypeParametersForAot() + { + const string modelSource = """ + using System.Collections.Generic; + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class Lookup + { + internal void Run(Reader reader, IPAddress address) + { + _ = reader.Find>(address); + _ = reader.FindAll>(); + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + // MMDBSG015 rather than MMDBSG008: the cause is the unresolved type + // parameter, and Dictionary being a collection is incidental. + Assert.Equal( + 2, + result.Diagnostics.Count(diagnostic => diagnostic.Id == "MMDBSG015")); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsCollectionsWithInaccessibleTypeArgumentsForAot() + { + const string modelSource = """ + using System.Collections.Generic; + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class Lookup + { + private sealed class PrivateModel; + + internal void Run(Reader reader, IPAddress address) + { + _ = reader.Find>(address); + _ = reader.Find>(address); + _ = reader.Find.Values>(address); + } + + internal sealed class Outer + { + internal sealed class Values : List + { + } + } + } + """; + + var normalResult = RunGenerator(modelSource); + var aotResult = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Empty(normalResult.Source); + Assert.Empty(normalResult.Diagnostics); + Assert.Empty(normalResult.Errors); + Assert.Equal( + 3, + aotResult.Diagnostics.Count(diagnostic => diagnostic.Id == "MMDBSG008")); + Assert.Empty(aotResult.Source); + Assert.Empty(aotResult.Errors); + } + + [Fact] + public void IgnoresGenericReaderWrappersWithoutGeneratingInvalidCode() + { + const string modelSource = """ + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class Lookup + { + internal T? Run(Reader reader, IPAddress address) where T : class + { + return reader.Find(address); + } + } + """; + + // Silent by design: model registration comes from declarations, so a + // wrapper over model lookups is fully registered and a warning here would + // be a false positive. MaxMind.GeoIP2's DatabaseReader.Execute is this + // shape, and it gets registrations for all of its response types. + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Empty(result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void IgnoresGenericReaderWrappersSilentlyWithoutAotDiagnostics() + { + const string modelSource = """ + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class Lookup + { + internal T? Run(Reader reader, IPAddress address) where T : class + { + return reader.Find(address); + } + } + """; + + var result = RunGenerator(modelSource); + + Assert.Empty(result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsUnsupportedCollectionsUsedByReaderCalls() + { + const string modelSource = """ + using System.Net; + using MaxMind.Db; + + namespace Models; + + internal sealed class Lookup + { + internal void Run(Reader reader, IPAddress address) + { + _ = reader.Find(address); + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG008"); + Assert.Empty(result.Source); + Assert.Empty(result.Errors); + } + + [Fact] + public void IgnoresUnrelatedGenericFindMethods() + { + const string modelSource = """ + using System.Collections.Concurrent; + + namespace Models; + + internal sealed class Lookup + { + private static T Find() where T : class => null!; + + internal void Run() + { + _ = Find>(); + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Empty(result.Source); + Assert.Empty(result.Diagnostics); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsValueTypeCollectionForAot() + { + const string modelSource = """ + using System.Collections.Immutable; + using MaxMind.Db; + + namespace Models; + + internal sealed class ImmutableModel + { + [Constructor] + internal ImmutableModel(ImmutableArray values) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG008"); + Assert.Empty(result.Errors); + } + + [Fact] + public void AllowsByteArrayWithoutDiagnostic() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class BytesModel + { + [Constructor] + internal BytesModel(byte[] value) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.DoesNotContain( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG008"); + Assert.Empty(result.Errors); + } + + [Fact] + public void ReportsUnsupportedArrayForAot() + { + const string modelSource = """ + using MaxMind.Db; + + namespace Models; + + internal sealed class ArrayModel + { + [Constructor] + internal ArrayModel(long[] values) + { + } + } + """; + + var result = RunGenerator(modelSource, aotDiagnostics: true); + + Assert.Contains( + result.Diagnostics, + diagnostic => diagnostic.Id == "MMDBSG008"); + } + + [Fact] + public void GeneratedCodeCanPopulateObsoleteProperties() + { + const string modelSource = """ + using System; + using MaxMind.Db; + + namespace Models; + + internal sealed record ObsoletePropertyModel + { + [Obsolete("Kept for database compatibility.")] + [MapKey("legacy")] + public string? Legacy { get; init; } + } + """; + + var result = RunGenerator(modelSource, warningsAsErrors: true); + + Assert.Contains("#pragma warning disable CS0436", result.Source); + Assert.Contains("#pragma warning disable CS0618", result.Source); + Assert.Contains("\"legacy\"", result.Source); + Assert.Contains("Legacy =", result.Source); + Assert.Empty(result.Errors); + } + + private static GeneratorResult RunGenerator( + string source, + bool aotDiagnostics = false, + bool warningsAsErrors = false, + LanguageVersion languageVersion = LanguageVersion.CSharp12, + IEnumerable? additionalReferences = null + ) + { + var parseOptions = new CSharpParseOptions(languageVersion); + var references = additionalReferences == null + ? References + : References.AddRange(additionalReferences); + var compilation = CSharpCompilation.Create( + "GeneratorTests", + [CSharpSyntaxTree.ParseText(source, parseOptions)], + references, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable, + generalDiagnosticOption: warningsAsErrors + ? ReportDiagnostic.Error + : ReportDiagnostic.Default)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new MaxMindDbSourceGenerator().AsSourceGenerator()], + parseOptions: parseOptions, + optionsProvider: new TestAnalyzerConfigOptionsProvider(aotDiagnostics)); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out var outputCompilation, + out var generatorDiagnostics); + + var runResult = driver.GetRunResult(); + var generatedSource = runResult.GeneratedTrees.Length == 0 + ? string.Empty + : Assert.Single(runResult.GeneratedTrees).GetText().ToString(); + var errors = outputCompilation.GetDiagnostics() + .Concat(generatorDiagnostics) + .Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) + .ToImmutableArray(); + return new GeneratorResult( + generatedSource, + errors, + runResult.Diagnostics); + } + + private static MetadataReference CompileReference(string source) + { + var compilation = CSharpCompilation.Create( + "GeneratorTestReference_" + Guid.NewGuid().ToString("N"), + [CSharpSyntaxTree.ParseText( + source, + new CSharpParseOptions(LanguageVersion.CSharp12))], + References, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable)); + using var stream = new MemoryStream(); + var result = compilation.Emit(stream); + Assert.True( + result.Success, + string.Join(Environment.NewLine, result.Diagnostics)); + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + private static readonly ImmutableArray References = + CreateReferences(); + + private static ImmutableArray CreateReferences() + { + var paths = ((string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES"))? + .Split(Path.PathSeparator) ?? []; + var references = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var path in paths.Append(typeof(ConstructorAttribute).Assembly.Location)) + { + references[path] = MetadataReference.CreateFromFile(path); + } + return references.Values.ToImmutableArray(); + } + + private sealed class GeneratorResult + { + internal GeneratorResult( + string source, + ImmutableArray errors, + ImmutableArray diagnostics + ) + { + Source = source; + Errors = errors; + Diagnostics = diagnostics; + } + + internal ImmutableArray Diagnostics { get; } + internal ImmutableArray Errors { get; } + internal string Source { get; } + } + + private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider + { + private readonly AnalyzerConfigOptions _globalOptions; + + internal TestAnalyzerConfigOptionsProvider(bool aotDiagnostics) + { + _globalOptions = new TestAnalyzerConfigOptions(aotDiagnostics); + } + + public override AnalyzerConfigOptions GlobalOptions => _globalOptions; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => + EmptyAnalyzerConfigOptions.Instance; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => + EmptyAnalyzerConfigOptions.Instance; + } + + private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions + { + private readonly bool _aotDiagnostics; + + internal TestAnalyzerConfigOptions(bool aotDiagnostics) + { + _aotDiagnostics = aotDiagnostics; + } + + public override bool TryGetValue(string key, out string value) + { + if (key == "build_property.MaxMindDbAotDiagnostics") + { + value = _aotDiagnostics ? "true" : "false"; + return true; + } + + value = string.Empty; + return false; + } + } + + private sealed class EmptyAnalyzerConfigOptions : AnalyzerConfigOptions + { + internal static EmptyAnalyzerConfigOptions Instance { get; } = new(); + + public override bool TryGetValue(string key, out string value) + { + value = string.Empty; + return false; + } + } + } +} diff --git a/MaxMind.Db.SourceGenerator/AnalyzerReleases.Shipped.md b/MaxMind.Db.SourceGenerator/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..eb7a443 --- /dev/null +++ b/MaxMind.Db.SourceGenerator/AnalyzerReleases.Shipped.md @@ -0,0 +1 @@ +; This file lists analyzer rules that have shipped. diff --git a/MaxMind.Db.SourceGenerator/AnalyzerReleases.Unshipped.md b/MaxMind.Db.SourceGenerator/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..0c5b386 --- /dev/null +++ b/MaxMind.Db.SourceGenerator/AnalyzerReleases.Unshipped.md @@ -0,0 +1,22 @@ +; This file lists analyzer rules that have not been released yet. + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +MMDBSG001 | MaxMind.Db.SourceGenerator | Warning | Model type is inaccessible to generated code +MMDBSG002 | MaxMind.Db.SourceGenerator | Warning | Model constructor is inaccessible to generated code +MMDBSG003 | MaxMind.Db.SourceGenerator | Warning | Model property cannot be assigned by generated code +MMDBSG004 | MaxMind.Db.SourceGenerator | Warning | Open generic models are unsupported +MMDBSG005 | MaxMind.Db.SourceGenerator | Warning | Model contains duplicate map keys +MMDBSG006 | MaxMind.Db.SourceGenerator | Warning | Property model lacks an accessible parameterless constructor +MMDBSG007 | MaxMind.Db.SourceGenerator | Warning | Model has multiple deserialization constructors +MMDBSG008 | MaxMind.Db.SourceGenerator | Warning | Collection type cannot be generated +MMDBSG009 | MaxMind.Db.SourceGenerator | Warning | Source generation requires C# 9 or later +MMDBSG010 | MaxMind.Db.SourceGenerator | Warning | Required members need a SetsRequiredMembers constructor +MMDBSG011 | MaxMind.Db.SourceGenerator | Warning | Derived MapKey attributes cannot be evaluated +MMDBSG012 | MaxMind.Db.SourceGenerator | Warning | Models must be classes or records +MMDBSG013 | MaxMind.Db.SourceGenerator | Warning | MapKey cannot be combined with Inject or Network +MMDBSG014 | MaxMind.Db.SourceGenerator | Warning | Model property is hidden by a more derived member +MMDBSG015 | MaxMind.Db.SourceGenerator | Warning | Lookup result type is not statically resolvable +MMDBSG016 | MaxMind.Db.SourceGenerator | Warning | Annotated model has no deserialization constructor diff --git a/MaxMind.Db.SourceGenerator/CollectionParser.cs b/MaxMind.Db.SourceGenerator/CollectionParser.cs new file mode 100644 index 0000000..92749e9 --- /dev/null +++ b/MaxMind.Db.SourceGenerator/CollectionParser.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace MaxMind.Db.SourceGenerator +{ + internal static class CollectionParser + { + internal static void Collect( + ITypeSymbol memberType, + Location? diagnosticLocation, + string ownerDescription, + Compilation compilation, + bool reportAotDiagnostics, + SourceProductionContext context, + IDictionary collections + ) + { + if (memberType is IArrayTypeSymbol arrayType) + { + if (arrayType.Rank == 1 && + arrayType.ElementType.SpecialType == SpecialType.System_Byte) + { + return; + } + + if (reportAotDiagnostics) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedCollection, + diagnosticLocation, + arrayType.ToDisplayString(), + ownerDescription)); + } + return; + } + + if (memberType is not INamedTypeSymbol namedType) + { + return; + } + + var typeName = SymbolHelpers.DisplayType(namedType); + if (!collections.ContainsKey(typeName)) + { + var result = Parse(namedType, compilation); + if (result.IsCollection) + { + if (result.Spec != null) + { + collections.Add(typeName, result.Spec); + } + else if (reportAotDiagnostics) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedCollection, + diagnosticLocation, + namedType.ToDisplayString(), + ownerDescription)); + } + } + } + + foreach (var typeArgument in namedType.TypeArguments) + { + Collect( + typeArgument, + diagnosticLocation, + ownerDescription, + compilation, + reportAotDiagnostics, + context, + collections); + } + } + + private static CollectionParseResult Parse( + INamedTypeSymbol type, + Compilation compilation + ) + { + var dictionaryInterface = FindConstructedInterface( + type, + compilation.GetTypeByMetadataName("System.Collections.Generic.IDictionary`2")); + var readOnlyDictionaryInterface = FindConstructedInterface( + type, + compilation.GetTypeByMetadataName( + "System.Collections.Generic.IReadOnlyDictionary`2")); + var dictionaryShape = dictionaryInterface ?? readOnlyDictionaryInterface; + if (dictionaryShape != null) + { + var keyType = dictionaryShape.TypeArguments[0]; + var valueType = dictionaryShape.TypeArguments[1]; + if (!SymbolHelpers.IsTypeAccessible(type, compilation) || + !SymbolHelpers.IsTypeAccessible(keyType, compilation) || + !SymbolHelpers.IsTypeAccessible(valueType, compilation)) + { + return CollectionParseResult.Unsupported; + } + var dictionaryDefinition = compilation.GetTypeByMetadataName( + "System.Collections.Generic.Dictionary`2"); + if (dictionaryDefinition == null) + { + return CollectionParseResult.Unsupported; + } + + var defaultDictionary = dictionaryDefinition.Construct(keyType, valueType); + if (HasImplicitConversion(compilation, defaultDictionary, type)) + { + return CollectionParseResult.Success(new CollectionSpec( + CollectionKind.Dictionary, + SymbolHelpers.DisplayType(type), + SymbolHelpers.DisplayType(keyType), + SymbolHelpers.DisplayType(valueType), + SymbolHelpers.DisplayType(defaultDictionary), + factoryUsesCapacity: true)); + } + + if (dictionaryInterface != null && CanConstruct(type, compilation)) + { + return CollectionParseResult.Success(new CollectionSpec( + CollectionKind.Dictionary, + SymbolHelpers.DisplayType(type), + SymbolHelpers.DisplayType(keyType), + SymbolHelpers.DisplayType(valueType), + SymbolHelpers.DisplayType(type), + factoryUsesCapacity: false)); + } + + return CollectionParseResult.Unsupported; + } + + var enumerableInterface = FindConstructedInterface( + type, + compilation.GetTypeByMetadataName("System.Collections.Generic.IEnumerable`1")); + if (enumerableInterface == null || type.SpecialType == SpecialType.System_String) + { + return CollectionParseResult.NotCollection; + } + + var elementType = enumerableInterface.TypeArguments[0]; + if (!SymbolHelpers.IsTypeAccessible(type, compilation) || + !SymbolHelpers.IsTypeAccessible(elementType, compilation)) + { + return CollectionParseResult.Unsupported; + } + var listDefinition = compilation.GetTypeByMetadataName( + "System.Collections.Generic.List`1"); + var collectionDefinition = compilation.GetTypeByMetadataName( + "System.Collections.Generic.ICollection`1"); + if (listDefinition == null || collectionDefinition == null) + { + return CollectionParseResult.Unsupported; + } + + var defaultList = listDefinition.Construct(elementType); + if (HasImplicitConversion(compilation, defaultList, type)) + { + return CollectionParseResult.Success(new CollectionSpec( + CollectionKind.Collection, + SymbolHelpers.DisplayType(type), + SymbolHelpers.DisplayType(elementType), + secondTypeArgument: null, + SymbolHelpers.DisplayType(defaultList), + factoryUsesCapacity: true)); + } + + var collectionInterface = collectionDefinition.Construct(elementType); + if (HasImplicitConversion(compilation, type, collectionInterface) && + CanConstruct(type, compilation)) + { + return CollectionParseResult.Success(new CollectionSpec( + CollectionKind.Collection, + SymbolHelpers.DisplayType(type), + SymbolHelpers.DisplayType(elementType), + secondTypeArgument: null, + SymbolHelpers.DisplayType(type), + factoryUsesCapacity: false)); + } + + return CollectionParseResult.Unsupported; + } + + private static INamedTypeSymbol? FindConstructedInterface( + INamedTypeSymbol type, + INamedTypeSymbol? interfaceDefinition + ) + { + if (interfaceDefinition == null) + { + return null; + } + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, interfaceDefinition)) + { + return type; + } + return type.AllInterfaces.FirstOrDefault(candidate => + SymbolEqualityComparer.Default.Equals( + candidate.OriginalDefinition, + interfaceDefinition)); + } + + private static bool HasImplicitConversion( + Compilation compilation, + ITypeSymbol from, + ITypeSymbol to + ) => compilation.ClassifyCommonConversion(from, to).IsImplicit; + + private static bool CanConstruct(INamedTypeSymbol type, Compilation compilation) + { + if (type.IsAbstract || !type.IsReferenceType || + !SymbolHelpers.IsTypeAccessible(type, compilation)) + { + return false; + } + + return type.InstanceConstructors.Any(constructor => + constructor.Parameters.Length == 0 && + SymbolHelpers.IsAccessible(constructor, compilation)); + } + + private readonly struct CollectionParseResult + { + private CollectionParseResult(bool isCollection, CollectionSpec? spec) + { + IsCollection = isCollection; + Spec = spec; + } + + internal bool IsCollection { get; } + internal CollectionSpec? Spec { get; } + + internal static CollectionParseResult NotCollection { get; } = new(false, null); + internal static CollectionParseResult Unsupported { get; } = new(true, null); + + internal static CollectionParseResult Success(CollectionSpec spec) => + new(true, spec); + } + } +} diff --git a/MaxMind.Db.SourceGenerator/Diagnostics.cs b/MaxMind.Db.SourceGenerator/Diagnostics.cs new file mode 100644 index 0000000..59bb6fc --- /dev/null +++ b/MaxMind.Db.SourceGenerator/Diagnostics.cs @@ -0,0 +1,137 @@ +using Microsoft.CodeAnalysis; + +namespace MaxMind.Db.SourceGenerator +{ + internal static class Diagnostics + { + private const string Category = "MaxMind.Db.SourceGenerator"; + + internal static readonly DiagnosticDescriptor InaccessibleType = new( + "MMDBSG001", + "Model type is inaccessible to the MaxMind DB source generator", + "Type '{0}' is not accessible from source-generated code", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor InaccessibleConstructor = new( + "MMDBSG002", + "Model constructor is inaccessible to the MaxMind DB source generator", + "The deserialization constructor for '{0}' is not accessible from source-generated code", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor InaccessibleProperty = new( + "MMDBSG003", + "Model property cannot be assigned by the MaxMind DB source generator", + "Property '{0}' on '{1}' must be an instance, non-indexed property with a getter and setter accessible from source-generated code", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor OpenGenericModel = new( + "MMDBSG004", + "Open generic MaxMind DB models are not supported", + "Type '{0}' is an open generic model and cannot use source-generated deserialization", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor DuplicateMapKey = new( + "MMDBSG005", + "MaxMind DB model contains a duplicate map key", + "Type '{0}' maps more than one member to the database key '{1}'", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor MissingParameterlessConstructor = new( + "MMDBSG006", + "Property model needs an accessible parameterless constructor", + "Type '{0}' needs a parameterless constructor accessible from source-generated code", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor MultipleDeserializationConstructors = new( + "MMDBSG007", + "Model has multiple MaxMind DB constructors", + "Type '{0}' has more than one constructor marked with ConstructorAttribute", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor UnsupportedCollection = new( + "MMDBSG008", + "Collection type is not supported by source-generated deserialization", + "Collection type '{0}' used by '{1}' cannot be created and populated by source-generated code", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor UnsupportedLanguageVersion = new( + "MMDBSG009", + "MaxMind DB source generation requires C# 9 or later", + "MaxMind DB source generation requires C# 9 or later because generated registrations use module initializers", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor RequiredMembers = new( + "MMDBSG010", + "Required model members need a SetsRequiredMembers constructor", + "Model '{0}' has required member '{1}', so its deserialization constructor must be marked with SetsRequiredMembersAttribute", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor DerivedMapKeyAttribute = new( + "MMDBSG011", + "Derived MapKey attributes are not supported by source-generated deserialization", + "Attribute '{0}' on model '{1}' derives from MapKeyAttribute and cannot be evaluated by source-generated deserialization", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor MissingDeserializationConstructor = new( + "MMDBSG016", + "Annotated model has no deserialization constructor", + "Type '{0}' has annotated constructor parameters but no constructor marked with ConstructorAttribute, so source-generated deserialization cannot activate it. A positional record needs the attribute on its primary constructor, as in [method: Constructor].", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor UnresolvableLookupType = new( + "MMDBSG015", + "Lookup result type is not statically resolvable", + "The type argument '{0}' of {1} still contains a type parameter, so no registration can be generated for this call", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor HiddenModelProperty = new( + "MMDBSG014", + "Model property is hidden by a more derived member", + "Property '{1}' on model '{0}' is annotated but hidden by a more derived member of the same name, so source-generated deserialization cannot assign it", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor ConflictingMemberAttributes = new( + "MMDBSG013", + "MapKey cannot be combined with Inject or Network", + "Member '{1}' on model '{0}' combines MapKey with Inject or Network, so source-generated deserialization cannot tell which supplies its value", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + internal static readonly DiagnosticDescriptor UnsupportedModelKind = new( + "MMDBSG012", + "MaxMind DB models must be classes or records", + "Type '{0}' is annotated for MaxMind DB deserialization but is not a class or record, so it cannot use source-generated deserialization", + Category, + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + } +} diff --git a/MaxMind.Db.SourceGenerator/MaxMind.Db.SourceGenerator.csproj b/MaxMind.Db.SourceGenerator/MaxMind.Db.SourceGenerator.csproj new file mode 100644 index 0000000..41f37e9 --- /dev/null +++ b/MaxMind.Db.SourceGenerator/MaxMind.Db.SourceGenerator.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + 14.0 + enable + true + true + true + true + latest + true + false + + + + + + + + + diff --git a/MaxMind.Db.SourceGenerator/MaxMindDbSourceGenerator.cs b/MaxMind.Db.SourceGenerator/MaxMindDbSourceGenerator.cs new file mode 100644 index 0000000..85cced0 --- /dev/null +++ b/MaxMind.Db.SourceGenerator/MaxMindDbSourceGenerator.cs @@ -0,0 +1,467 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace MaxMind.Db.SourceGenerator +{ + /// + /// Generates reflection-free activators for MaxMind DB model types. + /// + [Generator(LanguageNames.CSharp)] + public sealed class MaxMindDbSourceGenerator : IIncrementalGenerator + { + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var types = context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => IsModelTypeCandidate(node), + static (syntaxContext, cancellationToken) => + syntaxContext.SemanticModel.GetDeclaredSymbol( + (TypeDeclarationSyntax)syntaxContext.Node, + cancellationToken) as INamedTypeSymbol) + .Where(static type => type != null) + .Select(static (type, _) => type!); + + var collectionRoots = context.SyntaxProvider.CreateSyntaxProvider( + static (node, _) => IsReaderInvocationCandidate(node), + static (syntaxContext, cancellationToken) => + GetCollectionRoot(syntaxContext, cancellationToken)) + .Where(static root => root != null) + .Select(static (root, _) => root!); + + var aotDiagnostics = context.AnalyzerConfigOptionsProvider.Select( + static (options, _) => + options.GlobalOptions.TryGetValue( + "build_property.MaxMindDbAotDiagnostics", + out var value) && + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)); + + // N.B. This pipeline is not incremental in the way an IIncrementalGenerator + // normally is: CompilationProvider yields a new Compilation on essentially + // every keystroke, so the output node re-runs and re-parses every candidate. + // Fixing that properly means keeping symbols out of the pipeline entirely — + // parsing inside the syntax transform, returning equatable specs, and + // carrying diagnostics as data rather than as Diagnostic objects, since + // those hold a Location and so root the Compilation too. ModelParser and + // CollectionParser are built around live symbol walks for accessibility and + // generic resolution, so that is a redesign rather than a local change, and + // it is tracked separately rather than held against this release. + var generationInput = types.Collect() + .Combine(collectionRoots.Collect()) + .Combine(context.CompilationProvider) + .Combine(aotDiagnostics); + context.RegisterSourceOutput(generationInput, static (sourceContext, input) => + Generate( + sourceContext, + input.Left.Left.Left, + input.Left.Left.Right, + input.Left.Right, + input.Right)); + } + + private static bool IsModelTypeCandidate(SyntaxNode node) + { + if (node is not TypeDeclarationSyntax type) + { + return false; + } + + // A concrete model may inherit annotated properties without containing + // attributes itself, as a record deriving from an annotated abstract base + // does, so any type with a base list has to reach semantic analysis. That + // is broad — it admits most types in a project — but narrowing it to a + // syntactic attribute-name scan would silently drop inherited models, which + // is a supported shape. Otherwise, an MMDB model must have an attributed + // declaration, member, or constructor parameter. + if (type.BaseList != null) + { + return true; + } + + // A primary constructor's parameter list is always a direct child of the + // type declaration. Reading it that way rather than through + // TypeDeclarationSyntax.ParameterList keeps this compiling against the + // oldest Roslyn the generator supports, which only exposes that property on + // records. + var primaryConstructor = type.ChildNodes() + .OfType() + .FirstOrDefault(); + if (primaryConstructor != null && + (type.AttributeLists.Count > 0 || + primaryConstructor.Parameters.Any(static parameter => + parameter.AttributeLists.Count > 0))) + { + // Primary-constructor attributes use the method target and appear on + // the type declaration rather than a member node. + return true; + } + + foreach (var member in type.Members) + { + if (member.AttributeLists.Count > 0 || + member is BaseMethodDeclarationSyntax { ParameterList: { } parameterList } && + parameterList.Parameters.Any(static parameter => + parameter.AttributeLists.Count > 0)) + { + return true; + } + } + + return false; + } + + private static void Generate( + SourceProductionContext context, + ImmutableArray candidateTypes, + ImmutableArray collectionRoots, + Compilation compilation, + bool reportAotDiagnostics + ) + { + if (compilation.GetTypeByMetadataName("MaxMind.Db.SourceGeneratorSupport") == null) + { + return; + } + + var seenTypes = new HashSet(SymbolEqualityComparer.Default); + var specs = new List(); + foreach (var candidate in candidateTypes) + { + context.CancellationToken.ThrowIfCancellationRequested(); + if (!seenTypes.Add(candidate)) + { + continue; + } + + var spec = ModelParser.Parse( + candidate, compilation, reportAotDiagnostics, context); + if (spec != null) + { + specs.Add(spec); + } + } + + specs.Sort(static (left, right) => + StringComparer.Ordinal.Compare(left.TypeName, right.TypeName)); + var collections = new SortedDictionary(StringComparer.Ordinal); + foreach (var spec in specs) + { + foreach (var member in spec.Members) + { + context.CancellationToken.ThrowIfCancellationRequested(); + if (member.InjectableName != null || member.IsNetwork) + { + continue; + } + CollectionParser.Collect( + member.TypeSymbol, + spec.TypeSymbol.Locations.FirstOrDefault( + location => location.IsInSource), + spec.TypeSymbol.ToDisplayString(), + compilation, + reportAotDiagnostics, + context, + collections); + } + } + + foreach (var root in collectionRoots) + { + context.CancellationToken.ThrowIfCancellationRequested(); + if (SymbolHelpers.ContainsTypeParameter(root.TypeSymbol)) + { + // Only for a constructed type such as Find>, + // where the type argument is a collection that cannot be registered + // whatever T turns out to be. A bare T is deliberately silent: model + // registration comes from declarations, not from call sites, so a + // generic wrapper over model lookups is fully registered and warning + // there is a false positive. What a bare T cannot rule out is a + // collection type argument, or a model from an assembly that never + // ran the generator, and neither is visible from the wrapper. + if (reportAotDiagnostics && root.TypeSymbol is INamedTypeSymbol) + { + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnresolvableLookupType, + root.Location, + root.TypeSymbol.ToDisplayString(), + root.Description)); + } + continue; + } + CollectionParser.Collect( + root.TypeSymbol, + root.Location, + root.Description, + compilation, + reportAotDiagnostics, + context, + collections); + } + + if (specs.Count == 0 && collections.Count == 0) + { + return; + } + if (compilation is CSharpCompilation csharpCompilation && + csharpCompilation.LanguageVersion < LanguageVersion.CSharp9) + { + if (reportAotDiagnostics) + { + var location = specs.Count == 0 + ? collectionRoots[0].Location + : specs[0].TypeSymbol.Locations.FirstOrDefault( + candidate => candidate.IsInSource); + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.UnsupportedLanguageVersion, + location)); + } + return; + } + context.AddSource( + "MaxMind.Db.SourceGenerator.g.cs", + SourceText.From( + Render(specs, collections.Values, compilation), + Encoding.UTF8)); + } + + private static bool IsReaderInvocationCandidate(SyntaxNode node) + { + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + var genericName = invocation.Expression switch + { + GenericNameSyntax name => name, + MemberAccessExpressionSyntax { Name: GenericNameSyntax name } => name, + MemberBindingExpressionSyntax { Name: GenericNameSyntax name } => name, + _ => null, + }; + return genericName?.Identifier.ValueText is "Find" or "FindAll"; + } + + private static CollectionRoot? GetCollectionRoot( + GeneratorSyntaxContext context, + CancellationToken cancellationToken + ) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (context.SemanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is not + IMethodSymbol { IsGenericMethod: true, TypeArguments.Length: 1 } method || + method.Name is not ("Find" or "FindAll")) + { + return null; + } + + var readerType = context.SemanticModel.Compilation.GetTypeByMetadataName( + "MaxMind.Db.Reader"); + if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, readerType)) + { + return null; + } + + return new CollectionRoot( + method.TypeArguments[0], + invocation.GetLocation(), + $"Reader.{method.Name}"); + } + + private static string Render( + IReadOnlyList specs, + IEnumerable collections, + Compilation compilation + ) + { + var source = new StringBuilder(); + source.AppendLine("// "); + source.AppendLine("#nullable enable"); + source.AppendLine("#pragma warning disable CS0436 // Local module-initializer polyfill may shadow an inaccessible referenced polyfill"); + source.AppendLine("#pragma warning disable CS0612 // Obsolete member without a message"); + source.AppendLine("#pragma warning disable CS0618 // Obsolete member with a message"); + source.AppendLine(); + + var moduleInitializerAttribute = compilation.GetTypeByMetadataName( + "System.Runtime.CompilerServices.ModuleInitializerAttribute"); + if (moduleInitializerAttribute == null || + !compilation.IsSymbolAccessibleWithin( + moduleInitializerAttribute, + compilation.Assembly)) + { + source.AppendLine("namespace System.Runtime.CompilerServices"); + source.AppendLine("{"); + source.AppendLine(" [global::System.AttributeUsage(global::System.AttributeTargets.Method, Inherited = false)]"); + source.AppendLine(" internal sealed class ModuleInitializerAttribute : global::System.Attribute"); + source.AppendLine(" {"); + source.AppendLine(" }"); + source.AppendLine("}"); + source.AppendLine(); + } + + source.AppendLine("namespace MaxMind.Db.Generated"); + source.AppendLine("{"); + source.AppendLine(" internal static class SourceGeneratedRegistration"); + source.AppendLine(" {"); + source.AppendLine(" [global::System.Runtime.CompilerServices.ModuleInitializerAttribute]"); + source.AppendLine(" internal static void Register()"); + source.AppendLine(" {"); + + foreach (var spec in specs) + { + RenderRegistration(source, spec); + } + foreach (var collection in collections) + { + RenderCollectionRegistration(source, collection); + } + + source.AppendLine(" }"); + source.AppendLine(" }"); + source.AppendLine("}"); + return source.ToString().Replace("\r\n", "\n"); + } + + private static void RenderRegistration(StringBuilder source, TypeSpec spec) + { + source.Append(" global::MaxMind.Db.SourceGeneratorSupport.RegisterType<") + .Append(spec.TypeName).AppendLine(">("); + RenderActivator(source, spec); + RenderDefaultsFactory(source, spec); + RenderMembers(source, spec.Members); + source.AppendLine(" );"); + } + + private static void RenderCollectionRegistration( + StringBuilder source, + CollectionSpec spec + ) + { + if (spec.Kind == CollectionKind.Collection) + { + source.Append(" global::MaxMind.Db.SourceGeneratorSupport.RegisterCollection<") + .Append(spec.TypeName).Append(", ").Append(spec.FirstTypeArgument) + .AppendLine(">("); + source.Append(" capacity => new ").Append(spec.FactoryTypeName) + .Append(spec.FactoryUsesCapacity ? "(capacity)," : "(),") + .AppendLine(); + source.Append(" (collection, value) => ((global::System.Collections.Generic.ICollection<") + .Append(spec.FirstTypeArgument).Append(">)collection).Add((") + .Append(spec.FirstTypeArgument).AppendLine(")value!)"); + source.AppendLine(" );"); + return; + } + + var valueType = spec.SecondTypeArgument!; + source.Append(" global::MaxMind.Db.SourceGeneratorSupport.RegisterDictionary<") + .Append(spec.TypeName).Append(", ").Append(spec.FirstTypeArgument) + .Append(", ").Append(valueType).AppendLine(">("); + source.Append(" capacity => new ").Append(spec.FactoryTypeName) + .Append(spec.FactoryUsesCapacity ? "(capacity)," : "(),") + .AppendLine(); + source.Append(" (dictionary, key, value) => ((global::System.Collections.Generic.IDictionary<") + .Append(spec.FirstTypeArgument).Append(", ").Append(valueType) + .Append(">)dictionary).Add((").Append(spec.FirstTypeArgument) + .Append(")key!, (").Append(valueType).AppendLine(")value!)"); + source.AppendLine(" );"); + } + + private static void RenderActivator(StringBuilder source, TypeSpec spec) + { + source.Append(" values => new ").Append(spec.TypeName); + if (spec.ActivationKind == ActivationKind.Constructor) + { + source.AppendLine("("); + for (var i = 0; i < spec.Members.Length; i++) + { + var member = spec.Members[i]; + source.Append(" (").Append(member.TypeName) + .Append(")values[").Append(i).Append("]!"); + source.AppendLine(i == spec.Members.Length - 1 ? string.Empty : ","); + } + source.AppendLine(" ),"); + return; + } + + source.AppendLine(); + source.AppendLine(" {"); + for (var i = 0; i < spec.Members.Length; i++) + { + var member = spec.Members[i]; + source.Append(" ").Append(member.SourceName) + .Append(" = (").Append(member.TypeName).Append(")values[") + .Append(i).Append("]!"); + source.AppendLine(i == spec.Members.Length - 1 ? string.Empty : ","); + } + source.AppendLine(" },"); + } + + private static void RenderDefaultsFactory(StringBuilder source, TypeSpec spec) + { + if (spec.ActivationKind == ActivationKind.Constructor) + { + source.AppendLine(" () => new object?[]"); + source.AppendLine(" {"); + foreach (var member in spec.Members) + { + source.Append(" default(").Append(member.TypeName) + .AppendLine("),"); + } + source.AppendLine(" },"); + return; + } + + source.AppendLine(" () =>"); + source.AppendLine(" {"); + source.Append(" var instance = new ").Append(spec.TypeName) + .AppendLine("();"); + source.AppendLine(" return new object?[]"); + source.AppendLine(" {"); + foreach (var member in spec.Members) + { + source.Append(" instance.").Append(member.SourceName) + .AppendLine(","); + } + source.AppendLine(" };"); + source.AppendLine(" },"); + } + + private static void RenderMembers( + StringBuilder source, + ImmutableArray members + ) + { + source.AppendLine(" new global::MaxMind.Db.GeneratedMember[]"); + source.AppendLine(" {"); + foreach (var member in members) + { + source.Append(" global::MaxMind.Db.GeneratedMember."); + if (member.IsNetwork) + { + source.Append("Networked(typeof(").Append(member.TypeName) + .AppendLine(")),"); + continue; + } + if (member.InjectableName != null) + { + source.Append("Injected(") + .Append(SymbolDisplay.FormatLiteral( + member.InjectableName, quote: true)) + .Append(", typeof(").Append(member.TypeName).AppendLine(")),"); + continue; + } + source.Append("Mapped(") + .Append(SymbolDisplay.FormatLiteral(member.MapKey, quote: true)) + .Append(", typeof(").Append(member.TypeName).Append("), ") + .Append(member.AlwaysCreate ? "true" : "false").AppendLine("),"); + } + source.AppendLine(" }"); + } + } +} diff --git a/MaxMind.Db.SourceGenerator/Model.cs b/MaxMind.Db.SourceGenerator/Model.cs new file mode 100644 index 0000000..ba170be --- /dev/null +++ b/MaxMind.Db.SourceGenerator/Model.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace MaxMind.Db.SourceGenerator +{ + internal enum ActivationKind + { + Constructor, + Properties, + } + + internal sealed class TypeSpec + { + internal TypeSpec( + string typeName, + ActivationKind activationKind, + ImmutableArray members, + INamedTypeSymbol typeSymbol + ) + { + TypeName = typeName; + ActivationKind = activationKind; + Members = members; + TypeSymbol = typeSymbol; + } + + internal ActivationKind ActivationKind { get; } + internal ImmutableArray Members { get; } + internal string TypeName { get; } + internal INamedTypeSymbol TypeSymbol { get; } + } + + internal sealed class MemberSpec + { + internal MemberSpec( + string sourceName, + string typeName, + ITypeSymbol typeSymbol, + string mapKey, + string? injectableName, + bool isNetwork, + bool alwaysCreate + ) + { + SourceName = sourceName; + TypeName = typeName; + TypeSymbol = typeSymbol; + MapKey = mapKey; + InjectableName = injectableName; + IsNetwork = isNetwork; + AlwaysCreate = alwaysCreate; + } + + internal bool AlwaysCreate { get; } + internal string? InjectableName { get; } + internal bool IsNetwork { get; } + internal string MapKey { get; } + internal string SourceName { get; } + internal string TypeName { get; } + internal ITypeSymbol TypeSymbol { get; } + } + + internal enum CollectionKind + { + Collection, + Dictionary, + } + + internal sealed class CollectionSpec + { + internal CollectionSpec( + CollectionKind kind, + string typeName, + string firstTypeArgument, + string? secondTypeArgument, + string factoryTypeName, + bool factoryUsesCapacity + ) + { + Kind = kind; + TypeName = typeName; + FirstTypeArgument = firstTypeArgument; + SecondTypeArgument = secondTypeArgument; + FactoryTypeName = factoryTypeName; + FactoryUsesCapacity = factoryUsesCapacity; + } + + internal string FactoryTypeName { get; } + internal bool FactoryUsesCapacity { get; } + internal string FirstTypeArgument { get; } + internal CollectionKind Kind { get; } + internal string? SecondTypeArgument { get; } + internal string TypeName { get; } + } + + /// + /// A Find<T> or FindAll<T> type argument discovered at a + /// call site. This travels through the incremental pipeline, so it compares by + /// value: with reference equality every edit would be a cache miss for every + /// lookup in the compilation. + /// + internal sealed class CollectionRoot : IEquatable + { + internal CollectionRoot( + ITypeSymbol typeSymbol, + Location location, + string description + ) + { + TypeSymbol = typeSymbol; + Location = location; + Description = description; + } + + internal string Description { get; } + internal Location Location { get; } + internal ITypeSymbol TypeSymbol { get; } + + public bool Equals(CollectionRoot? other) => + other != null && + SymbolEqualityComparer.Default.Equals(TypeSymbol, other.TypeSymbol) && + Location.Equals(other.Location) && + Description == other.Description; + + public override bool Equals(object? obj) => Equals(obj as CollectionRoot); + + public override int GetHashCode() + { + var hash = SymbolEqualityComparer.Default.GetHashCode(TypeSymbol); + hash = (hash * 397) ^ Location.GetHashCode(); + return (hash * 397) ^ Description.GetHashCode(); + } + } +} diff --git a/MaxMind.Db.SourceGenerator/ModelParser.cs b/MaxMind.Db.SourceGenerator/ModelParser.cs new file mode 100644 index 0000000..e2ef360 --- /dev/null +++ b/MaxMind.Db.SourceGenerator/ModelParser.cs @@ -0,0 +1,541 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace MaxMind.Db.SourceGenerator +{ + internal static class ModelParser + { + private const string ConstructorAttributeName = "MaxMind.Db.ConstructorAttribute"; + private const string InjectAttributeName = "MaxMind.Db.InjectAttribute"; + private const string MapKeyAttributeName = "MaxMind.Db.MapKeyAttribute"; + private const string NetworkAttributeName = "MaxMind.Db.NetworkAttribute"; + private const string ParameterAttributeName = "MaxMind.Db.ParameterAttribute"; + private const string SetsRequiredMembersAttributeName = + "System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute"; + + internal static TypeSpec? Parse( + INamedTypeSymbol type, + Compilation compilation, + bool reportAotDiagnostics, + SourceProductionContext context + ) + { + // Abstract types are skipped silently: an annotated abstract base is the + // supported way to share members, and its concrete derived types are + // generated instead. + if (type.IsAbstract || type.IsStatic) + { + return null; + } + + var constructors = type.InstanceConstructors + .Where(constructor => + GetAttribute(constructor, ConstructorAttributeName) != null) + .ToImmutableArray(); + var properties = GetAnnotatedProperties(type, out var hiddenAnnotatedProperty); + + // Checked before the "nothing annotated" return below, because hiding the + // only annotated property is one of the ways to reach that state. Only the + // property path assigns members by name, so this does not apply to a + // constructor model. + if (constructors.Length == 0 && hiddenAnnotatedProperty != null) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.HiddenModelProperty, + hiddenAnnotatedProperty, + type, + type.ToDisplayString(), + hiddenAnnotatedProperty.Name); + return null; + } + + if (constructors.Length == 0 && properties.Length == 0) + { + // Candidate discovery admits far more types than are models, so silence + // is right here in general. Annotated constructor parameters are the + // exception: they say the author meant this type to be deserialized. A + // positional record is the common shape, where the attributes bind to + // the primary constructor's parameters and nothing carries + // ConstructorAttribute. + if (HasAnnotatedConstructorParameter(type)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.MissingDeserializationConstructor, + type, + type, + type.ToDisplayString()); + } + return null; + } + + // Reported only once the type is known to be annotated. Candidate discovery + // admits every type with a base list, so an earlier check here would warn + // about unrelated declarations. + if (type.TypeKind != TypeKind.Class) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.UnsupportedModelKind, + type, + type, + type.ToDisplayString()); + return null; + } + + if (SymbolHelpers.ContainsTypeParameter(type)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.OpenGenericModel, + type, + type, + type.ToDisplayString()); + return null; + } + + if (!SymbolHelpers.IsTypeAccessible(type, compilation)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.InaccessibleType, + type, + type, + type.ToDisplayString()); + return null; + } + + if (constructors.Length > 1) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.MultipleDeserializationConstructors, + type, + type, + type.ToDisplayString()); + return null; + } + + if (constructors.Length == 1) + { + return ParseConstructor( + type, constructors[0], compilation, reportAotDiagnostics, context); + } + + return ParseProperties( + type, properties, compilation, reportAotDiagnostics, context); + } + + private static TypeSpec? ParseConstructor( + INamedTypeSymbol type, + IMethodSymbol constructor, + Compilation compilation, + bool reportAotDiagnostics, + SourceProductionContext context + ) + { + if (!SymbolHelpers.IsAccessible(constructor, compilation)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.InaccessibleConstructor, + constructor, + type, + type.ToDisplayString()); + return null; + } + if (!SupportsRequiredMembers( + type, constructor, reportAotDiagnostics, context)) + { + return null; + } + + var members = ImmutableArray.CreateBuilder(constructor.Parameters.Length); + foreach (var parameter in constructor.Parameters) + { + var member = CreateMember( + parameter, + parameter.Type, + parameter.Name, + type, + reportAotDiagnostics, + context); + if (member == null) + { + return null; + } + members.Add(member); + } + + if (!HasUniqueMapKeys(type, members, reportAotDiagnostics, context)) + { + return null; + } + + return new TypeSpec( + SymbolHelpers.DisplayType(type), + ActivationKind.Constructor, + members.ToImmutable(), + type); + } + + private static TypeSpec? ParseProperties( + INamedTypeSymbol type, + ImmutableArray properties, + Compilation compilation, + bool reportAotDiagnostics, + SourceProductionContext context + ) + { + var constructor = type.InstanceConstructors.FirstOrDefault(candidate => + candidate.Parameters.Length == 0 && + SymbolHelpers.IsAccessible(candidate, compilation)); + if (constructor == null) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.MissingParameterlessConstructor, + type, + type, + type.ToDisplayString()); + return null; + } + if (!SupportsRequiredMembers( + type, constructor, reportAotDiagnostics, context)) + { + return null; + } + + var members = ImmutableArray.CreateBuilder(properties.Length); + foreach (var property in properties) + { + if (property.IsIndexer || property.IsStatic || + property.GetMethod == null || property.SetMethod == null || + !SymbolHelpers.IsAccessible(property.GetMethod, compilation) || + !SymbolHelpers.IsAccessible(property.SetMethod, compilation)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.InaccessibleProperty, + property, + type, + property.Name, + type.ToDisplayString()); + return null; + } + + var member = CreateMember( + property, + property.Type, + property.Name, + type, + reportAotDiagnostics, + context); + if (member == null) + { + return null; + } + members.Add(member); + } + + if (!HasUniqueMapKeys(type, members, reportAotDiagnostics, context)) + { + return null; + } + + return new TypeSpec( + SymbolHelpers.DisplayType(type), + ActivationKind.Properties, + members.ToImmutable(), + type); + } + + private static MemberSpec? CreateMember( + ISymbol symbol, + ITypeSymbol type, + string sourceName, + INamedTypeSymbol modelType, + bool reportAotDiagnostics, + SourceProductionContext context + ) + { + var mapKeyAttribute = GetAttribute(symbol, MapKeyAttributeName); + if (mapKeyAttribute != null && !IsSupportedMapKeyAttribute(mapKeyAttribute)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.DerivedMapKeyAttribute, + symbol, + modelType, + mapKeyAttribute.AttributeClass?.ToDisplayString() ?? "unknown", + modelType.ToDisplayString()); + return null; + } + var injectAttribute = GetAttribute(symbol, InjectAttributeName); + var networkAttribute = GetAttribute(symbol, NetworkAttributeName); + // An injectable or network member reads no database key, so an explicit + // MapKey alongside one of them is ambiguous. The reflection fallback reads + // the key and then overwrites it, which is a behaviour we do not want to + // reproduce silently. Dropping the model keeps that exact behaviour, since + // an unregistered model is what the fallback handles. + if (mapKeyAttribute != null && + (injectAttribute != null || networkAttribute != null)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.ConflictingMemberAttributes, + symbol, + modelType, + modelType.ToDisplayString(), + sourceName); + return null; + } + var mapKey = GetStringArgument(mapKeyAttribute, 0) ?? sourceName; + var alwaysCreate = GetBooleanArgument(mapKeyAttribute, 1); + var injectableName = GetStringArgument(injectAttribute, 0); + + return new MemberSpec( + EscapeIdentifier(sourceName), + SymbolHelpers.DisplayType(type), + type, + mapKey, + injectableName, + networkAttribute != null, + alwaysCreate); + } + + private static bool IsSupportedMapKeyAttribute(AttributeData attribute) + { + var attributeName = attribute.AttributeClass?.ToDisplayString(); + return attributeName == MapKeyAttributeName || + attributeName == ParameterAttributeName; + } + + private static ImmutableArray GetAnnotatedProperties( + INamedTypeSymbol type, + out IPropertySymbol? hiddenAnnotatedProperty + ) + { + hiddenAnnotatedProperty = null; + var propertiesByName = new Dictionary(StringComparer.Ordinal); + var declaredNames = new HashSet(StringComparer.Ordinal); + for (var current = type; current != null; current = current.BaseType) + { + foreach (var property in current.GetMembers().OfType()) + { + if (declaredNames.Add(property.Name)) + { + if (IsAnnotated(property)) + { + propertiesByName.Add(property.Name, property); + } + continue; + } + + // A more derived declaration already owns this name. Generated code + // emits an unqualified member reference, which binds to the most + // derived member, so an annotated base property here can never be + // the one assigned. Overrides do not reach this branch: GetAttribute + // walks the override chain, so an override is annotated and claims + // the name itself. + if (IsAnnotated(property) && + !propertiesByName.ContainsKey(property.Name)) + { + hiddenAnnotatedProperty = property; + } + } + } + + return propertiesByName.Values + .OrderBy(property => property.Name, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static bool IsAnnotated(IPropertySymbol property) => + GetAttribute(property, MapKeyAttributeName) != null || + GetAttribute(property, InjectAttributeName) != null || + GetAttribute(property, NetworkAttributeName) != null; + + private static AttributeData? GetAttribute(ISymbol symbol, string metadataName) + { + for (var current = symbol; current != null; current = GetOverriddenSymbol(current)) + { + var attribute = current.GetAttributes().FirstOrDefault(candidate => + IsAttribute(candidate.AttributeClass, metadataName)); + if (attribute != null) + { + return attribute; + } + } + return null; + } + + private static ISymbol? GetOverriddenSymbol(ISymbol symbol) => symbol switch + { + IPropertySymbol property => property.OverriddenProperty, + _ => null, + }; + + + private static bool IsAttribute(INamedTypeSymbol? attribute, string metadataName) + { + for (var current = attribute; current != null; current = current.BaseType) + { + if (current.ToDisplayString() == metadataName) + { + return true; + } + } + return false; + } + + private static string? GetStringArgument(AttributeData? attribute, int position) + { + if (attribute == null || attribute.ConstructorArguments.Length <= position) + { + return null; + } + return attribute.ConstructorArguments[position].Value as string; + } + + private static bool GetBooleanArgument(AttributeData? attribute, int position) + { + if (attribute == null || attribute.ConstructorArguments.Length <= position) + { + return false; + } + return attribute.ConstructorArguments[position].Value is bool value && value; + } + + private static bool HasUniqueMapKeys( + INamedTypeSymbol type, + ImmutableArray.Builder members, + bool reportAotDiagnostics, + SourceProductionContext context + ) + { + var keys = new HashSet(StringComparer.Ordinal); + foreach (var member in members) + { + // Only members that reach the decode dictionary can collide. An + // injectable or network member contributes no key — its MapKey is just + // the source name it defaulted to — so counting it here reports a + // collision the runtime would never see. + if (member.InjectableName != null || member.IsNetwork) + { + continue; + } + if (!keys.Add(member.MapKey)) + { + Report( + reportAotDiagnostics, + context, + Diagnostics.DuplicateMapKey, + type, + type, + type.ToDisplayString(), + member.MapKey); + return false; + } + } + return true; + } + + private static bool SupportsRequiredMembers( + INamedTypeSymbol type, + IMethodSymbol constructor, + bool reportAotDiagnostics, + SourceProductionContext context + ) + { + if (GetAttribute(constructor, SetsRequiredMembersAttributeName) != null) + { + return true; + } + + for (var current = type; current != null; current = current.BaseType) + { + var requiredProperty = current.GetMembers() + .FirstOrDefault(IsRequiredMember); + if (requiredProperty == null) + { + continue; + } + + Report( + reportAotDiagnostics, + context, + Diagnostics.RequiredMembers, + requiredProperty, + type, + type.ToDisplayString(), + requiredProperty.Name); + return false; + } + return true; + } + + private static bool HasAnnotatedConstructorParameter(INamedTypeSymbol type) => + type.InstanceConstructors.Any(constructor => + constructor.Parameters.Any(parameter => + GetAttribute(parameter, MapKeyAttributeName) != null || + GetAttribute(parameter, InjectAttributeName) != null || + GetAttribute(parameter, NetworkAttributeName) != null)); + + private static bool IsRequiredMember(ISymbol member) => member switch + { + IPropertySymbol property => property.IsRequired, + IFieldSymbol field => field.IsRequired, + _ => false, + }; + + private static string EscapeIdentifier(string identifier) => + SyntaxFacts.GetKeywordKind(identifier) == SyntaxKind.None && + SyntaxFacts.GetContextualKeywordKind(identifier) == SyntaxKind.None + ? identifier + : "@" + identifier; + + private static void Report( + bool enabled, + SourceProductionContext context, + DiagnosticDescriptor descriptor, + ISymbol symbol, + ISymbol fallbackSymbol, + params object[] messageArguments + ) + { + if (!enabled) + { + return; + } + + // An inherited member can come from a referenced assembly, where it has no + // source location and the diagnostic would land with no file or line. The + // model type is always declared in this compilation, so it stands in. + var location = SourceLocation(symbol) ?? SourceLocation(fallbackSymbol); + context.ReportDiagnostic(Diagnostic.Create( + descriptor, + location, + messageArguments)); + } + + private static Location? SourceLocation(ISymbol symbol) => + symbol.Locations.FirstOrDefault(location => location.IsInSource); + } +} diff --git a/MaxMind.Db.SourceGenerator/SymbolHelpers.cs b/MaxMind.Db.SourceGenerator/SymbolHelpers.cs new file mode 100644 index 0000000..443748f --- /dev/null +++ b/MaxMind.Db.SourceGenerator/SymbolHelpers.cs @@ -0,0 +1,91 @@ +using Microsoft.CodeAnalysis; + +namespace MaxMind.Db.SourceGenerator +{ + internal static class SymbolHelpers + { + private static readonly SymbolDisplayFormat TypeDisplayFormat = + SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); + + internal static string DisplayType(ITypeSymbol type) => + type.ToDisplayString(TypeDisplayFormat); + + internal static bool IsTypeAccessible( + ITypeSymbol type, + Compilation compilation + ) + { + if (type is IArrayTypeSymbol arrayType) + { + return IsTypeAccessible(arrayType.ElementType, compilation); + } + if (type is not INamedTypeSymbol namedType) + { + return type is not ITypeParameterSymbol; + } + + if (namedType.IsFileLocal || !IsAccessible(namedType, compilation)) + { + return false; + } + if (namedType.ContainingType != null && + !IsTypeAccessible(namedType.ContainingType, compilation)) + { + return false; + } + + foreach (var typeArgument in namedType.TypeArguments) + { + if (!IsTypeAccessible(typeArgument, compilation)) + { + return false; + } + } + return true; + } + internal static bool IsAccessible(ISymbol symbol, Compilation compilation) + { + if (symbol.DeclaredAccessibility == Accessibility.Public) + { + return true; + } + + return SymbolEqualityComparer.Default.Equals( + symbol.ContainingAssembly, + compilation.Assembly) && + symbol.DeclaredAccessibility is Accessibility.Internal or + Accessibility.ProtectedOrInternal; + } + + + internal static bool ContainsTypeParameter(ITypeSymbol type) + { + if (type is ITypeParameterSymbol) + { + return true; + } + if (type is IArrayTypeSymbol arrayType) + { + return ContainsTypeParameter(arrayType.ElementType); + } + if (type is not INamedTypeSymbol namedType) + { + return false; + } + if (namedType.ContainingType != null && + ContainsTypeParameter(namedType.ContainingType)) + { + return true; + } + foreach (var typeArgument in namedType.TypeArguments) + { + if (ContainsTypeParameter(typeArgument)) + { + return true; + } + } + return false; + } + } +} diff --git a/MaxMind.Db.Test/MaxMind.Db.Test.csproj b/MaxMind.Db.Test/MaxMind.Db.Test.csproj index bccbd7f..f1e87eb 100644 --- a/MaxMind.Db.Test/MaxMind.Db.Test.csproj +++ b/MaxMind.Db.Test/MaxMind.Db.Test.csproj @@ -37,6 +37,10 @@ + + diff --git a/MaxMind.Db.Test/ReaderTest.cs b/MaxMind.Db.Test/ReaderTest.cs index b3a34fa..b1f3c7b 100644 --- a/MaxMind.Db.Test/ReaderTest.cs +++ b/MaxMind.Db.Test/ReaderTest.cs @@ -498,6 +498,7 @@ public void TestNoConstructorNoParameterlessCtorThrows() var ex = Assert.Throws( () => reader.Find(IPAddress.Parse("1.1.1.1"))); Assert.Contains("no parameterless constructor found", ex.Message); + Assert.Contains("rebuild the assembly that declares the model", ex.Message); } [Fact] @@ -516,6 +517,7 @@ public void TestNoAnnotatedPropertiesThrows() var ex = Assert.Throws( () => reader.Find(IPAddress.Parse("1.1.1.1"))); Assert.Contains("No properties found", ex.Message); + Assert.Contains("rebuild the assembly that declares the model", ex.Message); } [Fact] diff --git a/MaxMind.Db.Test/SourceGeneratorSupportTest.cs b/MaxMind.Db.Test/SourceGeneratorSupportTest.cs new file mode 100644 index 0000000..199e3f6 --- /dev/null +++ b/MaxMind.Db.Test/SourceGeneratorSupportTest.cs @@ -0,0 +1,638 @@ +#region + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Numerics; +using System.Text; +using System.Threading.Tasks; +using MaxMind.Db.ReflectionFallback.TestModels; +using MaxMind.Db.Test.Helper; +using Xunit; + +#endregion + +namespace MaxMind.Db.Test +{ + public class SourceGeneratorSupportTest + { + private readonly string _testDataRoot = + Path.Combine(TestUtils.TestDirectory, "TestData", "MaxMind-DB", "test-data"); + + [Fact] + public void SourceGeneratedTestModelsAreRegistered() + { + Assert.True(SourceGeneratorSupport.TryGetTypeRegistration(typeof(TypeHolder), out _)); + Assert.True(SourceGeneratorSupport.TryGetCollectionRegistration(typeof(ICollection), out _)); + } + + [Fact] + public void ReflectionFallbackDeserializesModelsFromAssemblyWithoutGenerator() + { + Assert.False(SourceGeneratorSupport.TryGetTypeRegistration(typeof(ReflectionConstructorModel), out _)); + Assert.False(SourceGeneratorSupport.TryGetTypeRegistration(typeof(ReflectionPropertyModel), out _)); + Assert.False(SourceGeneratorSupport.TryGetCollectionRegistration(typeof(FallbackList), out _)); + + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + var address = IPAddress.Parse("1.1.1.1"); + + var constructorModel = reader.Find(address); + Assert.NotNull(constructorModel); + Assert.Equal("unicode! ☯ - ♫", constructorModel.Utf8String); + Assert.Equal([1, 2, 3], constructorModel.Values); + + var propertyModel = reader.Find(address); + Assert.NotNull(propertyModel); + Assert.Equal("unicode! ☯ - ♫", propertyModel.Utf8String); + Assert.Equal([1, 2, 3], propertyModel.Values); + Assert.Equal("preserved default", propertyModel.Missing); + } + + [Fact] + public void ReflectionFallbackDecodesEveryTypeHolderMember() + { + Assert.False(SourceGeneratorSupport.TryGetTypeRegistration(typeof(ReflectionTypeHolder), out _)); + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + var injectables = new InjectableValues(); + injectables.AddValue("injected", "injected string"); + + var record = reader.Find( + IPAddress.Parse("1.1.1.1"), injectables); + + Assert.NotNull(record); + Assert.True(record.Boolean); + Assert.Equal([0, 0, 0, 42], record.Bytes); + Assert.Equal("unicode! ☯ - ♫", record.Utf8String); + Assert.Equal(new List { 1, 2, 3 }, record.Array); + Assert.Equal(42.123456, record.Double, 9); + Assert.Equal(1.1F, record.Float, 5); + Assert.Equal(-268435456, record.Int32); + Assert.Equal(100, record.Uint16); + Assert.Equal(268435456, record.Uint32); + Assert.Equal(1152921504606846976UL, record.Uint64); + Assert.Equal( + BigInteger.Parse("1329227995784915872903807060280344576"), + record.Uint128); + + var mapX = record.Map.MapX; + Assert.Equal("hello", mapX.Utf8StringX); + Assert.Equal(new List { 7, 8, 9 }, mapX.ArrayX); + Assert.Equal("1.1.1.0/24", mapX.Network.ToString()); + + // AlwaysCreate through reflection, including injection and network on a + // member whose parent is absent from the database. + Assert.Equal("injected string", record.Nonexistant.Injected); + Assert.Equal("1.1.1.0/24", record.Nonexistant.Network.ToString()); + Assert.Equal( + "injected string", + record.Nonexistant.InnerNonexistant.Injected); + Assert.Equal( + "1.1.1.0/24", + record.Nonexistant.InnerNonexistant.Network.ToString()); + } + + [Fact] + public void ReflectionFallbackDecodesEveryPropertyHolderMember() + { + Assert.False(SourceGeneratorSupport.TryGetTypeRegistration(typeof(ReflectionPropTypeHolder), out _)); + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + var injectables = new InjectableValues(); + injectables.AddValue("injected", "injected string"); + + var record = reader.Find( + IPAddress.Parse("1.1.1.1"), injectables); + + Assert.NotNull(record); + Assert.True(record.Boolean); + Assert.Equal([0, 0, 0, 42], record.Bytes); + Assert.Equal("unicode! ☯ - ♫", record.Utf8String); + Assert.Equal(new List { 1, 2, 3 }, record.Array); + Assert.Equal(42.123456, record.Double, 9); + Assert.Equal(1.1F, record.Float, 5); + Assert.Equal(-268435456, record.Int32); + Assert.Equal(100, record.Uint16); + Assert.Equal(268435456, record.Uint32); + Assert.Equal(1152921504606846976UL, record.Uint64); + Assert.Equal( + BigInteger.Parse("1329227995784915872903807060280344576"), + record.Uint128); + Assert.Equal("injected string", record.Injected); + Assert.Equal("1.1.1.0/24", record.Network?.ToString()); + + var mapX = record.Map?.MapX; + Assert.NotNull(mapX); + Assert.Equal("hello", mapX.Utf8StringX); + Assert.Equal(new List { 7, 8, 9 }, mapX.ArrayX); + Assert.Equal("1.1.1.0/24", mapX.Network?.ToString()); + } + + [Fact] + public void ReflectionAlwaysCreateLeavesValueTypeMembersAtTheirDefault() + { + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + var address = IPAddress.Parse("1.1.1.1"); + + var constructorModel = + reader.Find(address); + Assert.NotNull(constructorModel); + Assert.Equal(0, constructorModel.AbsentValueType); + Assert.NotNull(constructorModel.AbsentModel); + + var propertyModel = reader.Find(address); + Assert.NotNull(propertyModel); + Assert.Equal(0, propertyModel.AbsentValueType); + Assert.NotNull(propertyModel.AbsentModel); + } + + [Fact] + public void GeneratedAlwaysCreateLeavesValueTypeMembersAtTheirDefault() + { + SourceGeneratorSupport.RegisterType( + values => new AlwaysCreateValueTypeGeneratedModel((long)values[0]!), + () => [default(long)], + [GeneratedMember.Mapped("no_such_key", typeof(long), true)]); + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + + var model = reader.Find( + IPAddress.Parse("1.1.1.1")); + + Assert.NotNull(model); + Assert.Equal(0, model.Value); + } + + [Fact] + public void DerivedMapKeyAttributesUseReflectionFallbackSemantics() + { + Assert.False(SourceGeneratorSupport.TryGetTypeRegistration(typeof(DerivedMapKeyFallbackModel), out _)); + Assert.False(SourceGeneratorSupport.TryGetTypeRegistration(typeof(DerivedMapKeyPropertyFallbackModel), out _)); + + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + var address = IPAddress.Parse("1.1.1.1"); + var constructorModel = reader.Find(address); + var propertyModel = reader.Find(address); + + Assert.NotNull(constructorModel); + Assert.Equal("unicode! ☯ - ♫", constructorModel.Utf8String); + Assert.NotNull(propertyModel); + Assert.Equal("unicode! ☯ - ♫", propertyModel.Utf8String); + Assert.NotNull(propertyModel.AlwaysCreated); + } + + [Fact] + public void RegisteredActivatorTakesPrecedenceOverReflection() + { + SourceGeneratorSupport.RegisterType( + values => new GeneratedAlwaysCreated((string)values[0]!), + () => [null], + [GeneratedMember.Injected("injected", typeof(string))]); + SourceGeneratorSupport.RegisterType( + values => new GeneratedModel( + (string)values[0]!, + (string)values[1]!, + (Network?)values[2], + (GeneratedAlwaysCreated)values[3]!), + () => [null, null, null, null], + [ + GeneratedMember.Mapped("utf8_string", typeof(string), false), + GeneratedMember.Injected("injected", typeof(string)), + GeneratedMember.Networked(typeof(Network)), + GeneratedMember.Mapped("missing", typeof(GeneratedAlwaysCreated), true), + ]); + + var injectables = new InjectableValues(); + injectables.AddValue("injected", "injected string"); + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + + var model = reader.Find(IPAddress.Parse("1.1.1.1"), injectables); + + Assert.NotNull(model); + Assert.Equal("unicode! ☯ - ♫", model.Utf8String); + Assert.Equal("injected string", model.Injected); + Assert.Equal("1.1.1.0/24", model.Network?.ToString()); + Assert.Equal("injected string", model.AlwaysCreated.Injected); + } + + [Fact] + public void RegisterTypeRejectsDefaultGeneratedMember() + { + var exception = Assert.Throws(() => + SourceGeneratorSupport.RegisterType( + _ => new InvalidGeneratedModel(), + () => [null], + [default])); + + Assert.Equal("members", exception.ParamName); + Assert.False(SourceGeneratorSupport.TryGetTypeRegistration(typeof(InvalidGeneratedModel), out _)); + } + + [Fact] + public void RegisterTypeDefensivelyCopiesMembers() + { + var members = new[] + { + GeneratedMember.Mapped("original", typeof(string), false), + }; + SourceGeneratorSupport.RegisterType( + _ => new CopiedMembersGeneratedModel(), + () => [null], + members); + + members[0] = GeneratedMember.Mapped("changed", typeof(long), false); + var activator = new TypeActivatorCreator() + .GetActivator(typeof(CopiedMembersGeneratedModel)); + + Assert.True(activator.DeserializationParameters.ContainsKey( + new Key(Encoding.UTF8.GetBytes("original")))); + Assert.Equal(typeof(string), + Assert.Single(activator.DeserializationParameters).Value.MemberType); + } + + [Fact] + public void GeneratedDefaultsMustMatchMemberCount() + { + SourceGeneratorSupport.RegisterType( + _ => new MismatchedDefaultsGeneratedModel(), + () => [], + [GeneratedMember.Mapped("value", typeof(string), false)]); + + var exception = Assert.Throws(() => + new TypeActivatorCreator() + .GetActivator(typeof(MismatchedDefaultsGeneratedModel))); + + Assert.Contains("match the registered member count", exception.Message); + } + + [Fact] + public void GeneratedDuplicateMapKeysAreRejectedOnFirstUse() + { + SourceGeneratorSupport.RegisterType( + _ => new DuplicateMapKeyGeneratedModel(), + () => [null, null], + [ + GeneratedMember.Mapped("value", typeof(string), false), + GeneratedMember.Mapped("value", typeof(string), false), + ]); + + var exception = Assert.Throws(() => + new TypeActivatorCreator() + .GetActivator(typeof(DuplicateMapKeyGeneratedModel))); + + Assert.Contains(nameof(DuplicateMapKeyGeneratedModel), exception.Message); + Assert.Contains("duplicate map key 'value'", exception.Message); + } + + [Fact] + public void DuplicateTypeRegistrationKeepsTheFirstRegistration() + { + SourceGeneratorSupport.RegisterType( + _ => new DuplicateRegistrationGeneratedModel(), + () => [null], + [GeneratedMember.Mapped("first", typeof(string), false)]); + SourceGeneratorSupport.RegisterType( + _ => new DuplicateRegistrationGeneratedModel(), + () => [null], + [GeneratedMember.Mapped("second", typeof(string), false)]); + + var activator = new TypeActivatorCreator() + .GetActivator(typeof(DuplicateRegistrationGeneratedModel)); + + Assert.True(activator.DeserializationParameters.ContainsKey( + new Key(Encoding.UTF8.GetBytes("first")))); + Assert.False(activator.DeserializationParameters.ContainsKey( + new Key(Encoding.UTF8.GetBytes("second")))); + } + + [Fact] + public void GeneratedDefaultFactoryExceptionsHaveDeserializationContext() + { + var inner = new InvalidOperationException("Default construction failed."); + SourceGeneratorSupport.RegisterType( + _ => new ThrowingDefaultsGeneratedModel(), + () => throw inner, + []); + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + + var exception = Assert.Throws(() => + reader.Find(IPAddress.Parse("1.1.1.1"))); + + Assert.Contains(nameof(ThrowingDefaultsGeneratedModel), exception.Message); + Assert.Same(inner, exception.InnerException); + } + + [Fact] + public void GeneratedActivatorMetadataIsReusedAcrossCreators() + { + var defaultsFactoryCalls = 0; + SourceGeneratorSupport.RegisterType( + values => new CachedMetadataGeneratedModel(values[0]!), + () => + { + defaultsFactoryCalls++; + return [new object()]; + }, + [GeneratedMember.Mapped("value", typeof(object), false)]); + + var first = new TypeActivatorCreator() + .GetActivator(typeof(CachedMetadataGeneratedModel)); + var second = new TypeActivatorCreator() + .GetActivator(typeof(CachedMetadataGeneratedModel)); + + Assert.NotSame(first, second); + Assert.Same(first.DeserializationParameters, second.DeserializationParameters); + Assert.NotSame(first.DefaultParameters, second.DefaultParameters); + Assert.NotSame(first.DefaultParameters[0], second.DefaultParameters[0]); + Assert.Equal(2, defaultsFactoryCalls); + } + + [Fact] + public void GeneratedRuntimeMembersAreNotDatabaseParameters() + { + SourceGeneratorSupport.RegisterType( + values => new GeneratedInjectedCollectionModel((string[])values[0]!), + () => [null], + [GeneratedMember.Injected("locales", typeof(string[]))]); + + var activator = new TypeActivatorCreator() + .GetActivator(typeof(GeneratedInjectedCollectionModel)); + + Assert.Empty(activator.DeserializationParameters); + Assert.Single(activator.InjectableParameters); + } + + [Fact] + public void RegisteredCollectionsTakePrecedenceOverReflection() + { + // The factories build canary subclasses so this cannot pass on the + // reflection fallback: ListActivatorCreator would produce a plain + // List, and DictionaryActivatorCreator a plain GeneratedDictionary. + SourceGeneratorSupport.RegisterCollection, long>( + capacity => new CanaryList(capacity), + (collection, value) => ((ICollection)collection).Add((long)value!)); + SourceGeneratorSupport.RegisterDictionary, string, object>( + _ => new CanaryDictionary(), + (dictionary, key, value) => + ((IDictionary)dictionary).Add((string)key!, value!)); + SourceGeneratorSupport.RegisterType( + values => new GeneratedCollectionModel( + (IReadOnlyList)values[0]!, + (GeneratedDictionary)values[1]!), + () => [null, null], + [ + GeneratedMember.Mapped("array", typeof(IReadOnlyList), false), + GeneratedMember.Mapped("map", typeof(GeneratedDictionary), false), + ]); + + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + + var model = reader.Find(IPAddress.Parse("1.1.1.1")); + + Assert.NotNull(model); + Assert.Equal([1L, 2L, 3L], model.Array); + Assert.IsType>(model.Array); + Assert.Single(model.Map); + Assert.IsType>(model.Map); + Assert.IsType>(model.Map["mapX"]); + } + + [Fact] + public void RegisteredNonGenericDictionaryTakesPrecedenceOverModelActivation() + { + SourceGeneratorSupport.RegisterDictionary( + _ => new GeneratedNonGenericDictionary(), + (dictionary, key, value) => + ((IDictionary)dictionary).Add((string)key!, value!)); + SourceGeneratorSupport.RegisterType( + _ => throw new InvalidOperationException("Model activator should not be used."), + () => [], + []); + SourceGeneratorSupport.RegisterType( + values => new GeneratedNonGenericDictionaryModel( + (GeneratedNonGenericDictionary)values[0]!), + () => [null], + [GeneratedMember.Mapped("map", typeof(GeneratedNonGenericDictionary), false)]); + using var reader = new Reader( + Path.Combine(_testDataRoot, "MaxMind-DB-test-decoder.mmdb")); + + var model = reader.Find( + IPAddress.Parse("1.1.1.1")); + + Assert.NotNull(model); + Assert.Single(model.Map); + Assert.IsType>(model.Map["mapX"]); + } + + [Fact] + public void GeneratedActivatorMetadataIsBuiltOnceUnderConcurrentUse() + { + SourceGeneratorSupport.RegisterType( + values => new ConcurrentMetadataGeneratedModel((string)values[0]!), + () => [null], + [GeneratedMember.Mapped("value", typeof(string), false)]); + var creators = new TypeActivatorCreator[32]; + var activators = new TypeActivator[creators.Length]; + + Parallel.For(0, creators.Length, index => + { + creators[index] = new TypeActivatorCreator(); + activators[index] = creators[index] + .GetActivator(typeof(ConcurrentMetadataGeneratedModel)); + }); + + // The metadata is published with a compare-exchange, so a racing caller can + // legitimately observe a different instance than the one it built. What must + // hold for every caller is that the metadata is complete and correct. + foreach (var activator in activators) + { + Assert.Single(activator.DeserializationParameters); + Assert.True(activator.DeserializationParameters.ContainsKey( + new Key(Encoding.UTF8.GetBytes("value")))); + } + } + + private sealed class CanaryList : List + { + internal CanaryList(int capacity) : base(capacity) + { + } + } + + private sealed class CanaryDictionary + : GeneratedDictionary + where TKey : notnull + { + } + + private sealed class ConcurrentMetadataGeneratedModel + { + internal ConcurrentMetadataGeneratedModel(string value) + { + Value = value; + } + + internal string Value { get; } + } + + private sealed class GeneratedAlwaysCreated + { + internal GeneratedAlwaysCreated(string injected) + { + Injected = injected; + } + + internal string Injected { get; } + } + + private sealed class GeneratedModel + { + internal GeneratedModel( + string utf8String, + string injected, + Network? network, + GeneratedAlwaysCreated alwaysCreated + ) + { + Utf8String = utf8String; + Injected = injected; + Network = network; + AlwaysCreated = alwaysCreated; + } + + internal GeneratedAlwaysCreated AlwaysCreated { get; } + internal string Injected { get; } + internal Network? Network { get; } + internal string Utf8String { get; } + } + + private sealed class InvalidGeneratedModel + { + } + + private sealed class CopiedMembersGeneratedModel + { + } + + private sealed class MismatchedDefaultsGeneratedModel + { + } + + private sealed class AlwaysCreateValueTypeGeneratedModel + { + internal AlwaysCreateValueTypeGeneratedModel(long value) + { + Value = value; + } + + internal long Value { get; } + } + + private sealed class DuplicateMapKeyGeneratedModel + { + } + + private sealed class DuplicateRegistrationGeneratedModel + { + } + + private sealed class ThrowingDefaultsGeneratedModel + { + } + + private sealed class CachedMetadataGeneratedModel + { + internal CachedMetadataGeneratedModel(object value) + { + Value = value; + } + + internal object Value { get; } + } + + private sealed class GeneratedCollectionModel + { + internal GeneratedCollectionModel( + IReadOnlyList array, + GeneratedDictionary map + ) + { + Array = array; + Map = map; + } + + internal IReadOnlyList Array { get; } + internal GeneratedDictionary Map { get; } + } + + private sealed class GeneratedInjectedCollectionModel + { + internal GeneratedInjectedCollectionModel(string[] locales) + { + Locales = locales; + } + + internal string[] Locales { get; } + } + + private sealed class GeneratedNonGenericDictionary : Dictionary + { + } + + private sealed class GeneratedNonGenericDictionaryModel + { + internal GeneratedNonGenericDictionaryModel(GeneratedNonGenericDictionary map) + { + Map = map; + } + + internal GeneratedNonGenericDictionary Map { get; } + } + + private class GeneratedDictionary : Dictionary + where TKey : notnull + { + } + } + + internal sealed class Utf8KeyAttribute : MapKeyAttribute + { + internal Utf8KeyAttribute(string name) + : base("utf8_" + name, true) + { + } + } + + internal sealed class DerivedMapKeyFallbackModel + { + [Constructor] + internal DerivedMapKeyFallbackModel([Utf8Key("string")] string utf8String) + { + Utf8String = utf8String; + } + + internal string Utf8String { get; } + } + + internal sealed class DerivedMapKeyPropertyFallbackModel + { + [Utf8Key("missing")] + internal DerivedMapKeyAlwaysCreated? AlwaysCreated { get; set; } + + [Utf8Key("string")] + internal string? Utf8String { get; set; } + } + + internal sealed class DerivedMapKeyAlwaysCreated + { + [MapKey("unused")] + internal string? Value { get; set; } + } +} diff --git a/MaxMind.Db.sln b/MaxMind.Db.sln index c0403cb..420edef 100644 --- a/MaxMind.Db.sln +++ b/MaxMind.Db.sln @@ -9,24 +9,136 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.Benchmark", "Max EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.Test", "MaxMind.Db.Test\MaxMind.Db.Test.csproj", "{15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.SourceGenerator", "MaxMind.Db.SourceGenerator\MaxMind.Db.SourceGenerator.csproj", "{B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.SourceGenerator.Test", "MaxMind.Db.SourceGenerator.Test\MaxMind.Db.SourceGenerator.Test.csproj", "{8C78E3E9-3BA3-460D-AB40-1824FE94C27F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.ReflectionFallback.TestModels", "MaxMind.Db.ReflectionFallback.TestModels\MaxMind.Db.ReflectionFallback.TestModels.csproj", "{CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.NetStandard.TestModels", "MaxMind.Db.NetStandard.TestModels\MaxMind.Db.NetStandard.TestModels.csproj", "{89C63078-2493-4555-B6FD-2B4F89D6BD2D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.NativeAot.App", "MaxMind.Db.NativeAot\App\MaxMind.Db.NativeAot.App.csproj", "{D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MaxMind.Db.NativeAot.Models", "MaxMind.Db.NativeAot\Models\MaxMind.Db.NativeAot.Models.csproj", "{6254EBD8-93CD-465F-935A-341EDA068844}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Debug|Any CPU.Build.0 = Debug|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Debug|x64.ActiveCfg = Debug|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Debug|x64.Build.0 = Debug|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Debug|x86.ActiveCfg = Debug|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Debug|x86.Build.0 = Debug|Any CPU {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Release|Any CPU.ActiveCfg = Release|Any CPU {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Release|Any CPU.Build.0 = Release|Any CPU - {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|Any CPU.Build.0 = Release|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Release|x64.ActiveCfg = Release|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Release|x64.Build.0 = Release|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Release|x86.ActiveCfg = Release|Any CPU + {923441A3-A9A9-425F-9ABD-73DF13E0A053}.Release|x86.Build.0 = Release|Any CPU {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Debug|x64.ActiveCfg = Debug|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Debug|x64.Build.0 = Debug|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Debug|x86.ActiveCfg = Debug|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Debug|x86.Build.0 = Debug|Any CPU {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Release|Any CPU.ActiveCfg = Release|Any CPU {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Release|Any CPU.Build.0 = Release|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Release|x64.ActiveCfg = Release|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Release|x64.Build.0 = Release|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Release|x86.ActiveCfg = Release|Any CPU + {F1051D87-38BE-4E9C-B7B3-9FA2DFB4FB56}.Release|x86.Build.0 = Release|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|x64.Build.0 = Debug|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Debug|x86.Build.0 = Debug|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|Any CPU.Build.0 = Release|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|x64.ActiveCfg = Release|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|x64.Build.0 = Release|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|x86.ActiveCfg = Release|Any CPU + {15A04FFF-BCC5-45F9-84E2-AB51B567E3E9}.Release|x86.Build.0 = Release|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Debug|x64.ActiveCfg = Debug|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Debug|x64.Build.0 = Debug|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Debug|x86.ActiveCfg = Debug|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Debug|x86.Build.0 = Debug|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Release|Any CPU.Build.0 = Release|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Release|x64.ActiveCfg = Release|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Release|x64.Build.0 = Release|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Release|x86.ActiveCfg = Release|Any CPU + {B51B39BB-D462-4BA1-9E7D-A7FBFB19B711}.Release|x86.Build.0 = Release|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Debug|x64.ActiveCfg = Debug|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Debug|x64.Build.0 = Debug|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Debug|x86.ActiveCfg = Debug|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Debug|x86.Build.0 = Debug|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Release|Any CPU.Build.0 = Release|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Release|x64.ActiveCfg = Release|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Release|x64.Build.0 = Release|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Release|x86.ActiveCfg = Release|Any CPU + {8C78E3E9-3BA3-460D-AB40-1824FE94C27F}.Release|x86.Build.0 = Release|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Debug|x64.ActiveCfg = Debug|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Debug|x64.Build.0 = Debug|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Debug|x86.ActiveCfg = Debug|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Debug|x86.Build.0 = Debug|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Release|Any CPU.Build.0 = Release|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Release|x64.ActiveCfg = Release|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Release|x64.Build.0 = Release|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Release|x86.ActiveCfg = Release|Any CPU + {CB1A76F7-A3D9-43B1-9E2E-43F2BE61A0E7}.Release|x86.Build.0 = Release|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Debug|x64.ActiveCfg = Debug|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Debug|x64.Build.0 = Debug|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Debug|x86.ActiveCfg = Debug|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Debug|x86.Build.0 = Debug|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Release|Any CPU.Build.0 = Release|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Release|x64.ActiveCfg = Release|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Release|x64.Build.0 = Release|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Release|x86.ActiveCfg = Release|Any CPU + {89C63078-2493-4555-B6FD-2B4F89D6BD2D}.Release|x86.Build.0 = Release|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Debug|x64.ActiveCfg = Debug|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Debug|x64.Build.0 = Debug|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Debug|x86.ActiveCfg = Debug|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Debug|x86.Build.0 = Debug|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Release|Any CPU.Build.0 = Release|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Release|x64.ActiveCfg = Release|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Release|x64.Build.0 = Release|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Release|x86.ActiveCfg = Release|Any CPU + {D7F4D2C7-47A5-420E-AD66-30BD26BCDFB8}.Release|x86.Build.0 = Release|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Debug|x64.ActiveCfg = Debug|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Debug|x64.Build.0 = Debug|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Debug|x86.ActiveCfg = Debug|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Debug|x86.Build.0 = Debug|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Release|Any CPU.Build.0 = Release|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Release|x64.ActiveCfg = Release|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Release|x64.Build.0 = Release|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Release|x86.ActiveCfg = Release|Any CPU + {6254EBD8-93CD-465F-935A-341EDA068844}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MaxMind.Db/Decoder.cs b/MaxMind.Db/Decoder.cs index fd575f1..1e2e681 100644 --- a/MaxMind.Db/Decoder.cs +++ b/MaxMind.Db/Decoder.cs @@ -315,8 +315,13 @@ private object DecodeMap( if (!expectedType.IsGenericType && expectedType.IsAssignableFrom(objDictType)) expectedType = objDictType; - // Currently we don't support non-dict generic types - if (expectedType.IsGenericType) + // Currently we don't support non-dict generic types. A non-generic type only + // decodes as a dictionary if one was registered for it, and the flag keeps + // that lookup off the path every model map takes: probing unconditionally + // measured ~2% slower on a City lookup. + if (expectedType.IsGenericType || + (SourceGeneratorSupport.HasNonGenericDictionaryRegistration && + SourceGeneratorSupport.TryGetDictionaryRegistration(expectedType, out _))) { return DecodeMapToDictionary(expectedType, offset, size, out outOffset, injectables, network); } @@ -327,8 +332,6 @@ private object DecodeMap( private object DecodeMapToDictionary(Type expectedType, long offset, int size, out long outOffset, InjectableValues? injectables, Network? network) { - IDictionary obj; - // Fast path for Dictionary (and parents). if (expectedType.IsAssignableFrom(typeof(Dictionary))) { @@ -340,29 +343,57 @@ private object DecodeMapToDictionary(Type expectedType, long offset, int size, o dic.Add(key, value); } - obj = dic; + outOffset = offset; + return dic; } - else + + // Fast path for Dictionary (and parents). + if (expectedType.IsAssignableFrom(typeof(Dictionary))) { - var genericArgs = expectedType.GetGenericArguments(); - if (genericArgs.Length != 2) + Dictionary dic = new(size); + for (var i = 0; i < size; i++) { - throw new DeserializationException( - $"Unexpected number of Dictionary generic arguments: {genericArgs.Length}"); + var key = Decode(offset, out offset); + var value = Decode(offset, out offset, injectables, network); + dic.Add(key, value); } - obj = (IDictionary)_dictionaryActivatorCreator.GetActivator(expectedType)(size); + outOffset = offset; + return dic; + } + if (SourceGeneratorSupport.TryGetDictionaryRegistration( + expectedType, out var registration)) + { + var generatedDictionary = registration.Factory(size); for (var i = 0; i < size; i++) { - var key = Decode(genericArgs[0], offset, out offset); - var value = Decode(genericArgs[1], offset, out offset, injectables, network); - obj.Add(key, value); + var key = Decode(registration.KeyType, offset, out offset); + var value = Decode( + registration.ValueType, offset, out offset, injectables, network); + registration.Add(generatedDictionary, key, value); } + + outOffset = offset; + return generatedDictionary; } - outOffset = offset; + var genericArgs = expectedType.GetGenericArguments(); + if (genericArgs.Length != 2) + { + throw new DeserializationException( + $"Unexpected number of Dictionary generic arguments: {genericArgs.Length}"); + } + + var obj = (IDictionary)_dictionaryActivatorCreator.GetActivator(expectedType)(size); + for (var i = 0; i < size; i++) + { + var key = Decode(genericArgs[0], offset, out offset); + var value = Decode(genericArgs[1], offset, out offset, injectables, network); + obj.Add(key, value); + } + outOffset = offset; return obj; } @@ -560,11 +591,15 @@ private long DecodeLong(Type expectedType, long offset, int size) /// /// /// +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "AOT", + "IL3050", + Justification = "Generated collection registrations return before this runtime generic construction path. This path serves only the documented fallback for unregistered collection types, which is unsupported in NativeAOT applications.")] +#endif private object DecodeArray(Type expectedType, int size, long offset, out long outOffset, InjectableValues? injectables, Network? network) { - object array; - // Fast path for List (and parents). if (expectedType != typeof(object) && expectedType.IsAssignableFrom(typeof(List))) { @@ -575,30 +610,53 @@ private object DecodeArray(Type expectedType, int size, long offset, out long ou list.Add(r); } - array = list; + outOffset = offset; + return list; } - else + + // Database values decoded as object use List. + if (expectedType == typeof(object) || expectedType.IsAssignableFrom(typeof(List))) { - var genericArgs = expectedType.GetGenericArguments(); - var argType = genericArgs.Length == 0 ? typeof(object) : genericArgs[0]; - var interfaceType = typeof(ICollection<>).MakeGenericType(argType); - if (interfaceType == null) + List list = new(size); + for (var i = 0; i < size; i++) { - throw new DeserializationException("Unexpected null generic type while decoding array"); + var value = Decode(offset, out offset, injectables, network); + list.Add(value); } - var addMethod = interfaceType.GetMethod("Add"); - if (addMethod == null) - { - throw new DeserializationException("Missing Add method when decoding array"); - } + outOffset = offset; + return list; + } - array = _listActivatorCreator.GetActivator(expectedType)(size); + if (SourceGeneratorSupport.TryGetCollectionRegistration( + expectedType, out var registration)) + { + var generatedCollection = registration.Factory(size); for (var i = 0; i < size; i++) { - var r = Decode(argType, offset, out offset, injectables, network); - addMethod.Invoke(array, [r]); + var value = Decode( + registration.ElementType, offset, out offset, injectables, network); + registration.Add(generatedCollection, value); } + + outOffset = offset; + return generatedCollection; + } + + var genericArgs = expectedType.GetGenericArguments(); + var argType = genericArgs.Length == 0 ? typeof(object) : genericArgs[0]; + var interfaceType = typeof(ICollection<>).MakeGenericType(argType); + var addMethod = interfaceType.GetMethod("Add"); + if (addMethod == null) + { + throw new DeserializationException("Missing Add method when decoding array"); + } + + var array = _listActivatorCreator.GetActivator(expectedType)(size); + for (var i = 0; i < size; i++) + { + var value = Decode(argType, offset, out offset, injectables, network); + addMethod.Invoke(array, [value]); } outOffset = offset; diff --git a/MaxMind.Db/DictionaryActivatorCreator.cs b/MaxMind.Db/DictionaryActivatorCreator.cs index e3f80dd..f3e8292 100644 --- a/MaxMind.Db/DictionaryActivatorCreator.cs +++ b/MaxMind.Db/DictionaryActivatorCreator.cs @@ -18,15 +18,19 @@ internal sealed class DictionaryActivatorCreator internal ObjectActivator GetActivator(Type expectedType) => _dictActivators.GetOrAdd(expectedType, DictionaryActivator); +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "AOT", + "IL3050", + Justification = "Generated dictionary registrations return before this runtime generic construction path. This path serves only the documented fallback for unregistered dictionary types, which is unsupported in NativeAOT applications.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Generated dictionary registrations return before this reflection path. This path serves only the documented fallback for unregistered dictionary types, which is unsupported in trimmed applications.")] +#endif private static ObjectActivator DictionaryActivator(Type expectedType) { var genericArgs = expectedType.GetGenericArguments(); - if (genericArgs.Length != 2) - { - throw new DeserializationException( - $"Unexpected number of Dictionary generic arguments: {genericArgs.Length}"); - } - ConstructorInfo? constructor; if (expectedType.GetTypeInfo().IsInterface) { diff --git a/MaxMind.Db/ListActivatorCreator.cs b/MaxMind.Db/ListActivatorCreator.cs index 54a92af..7ae916e 100644 --- a/MaxMind.Db/ListActivatorCreator.cs +++ b/MaxMind.Db/ListActivatorCreator.cs @@ -17,6 +17,16 @@ internal sealed class ListActivatorCreator internal ObjectActivator GetActivator(Type expectedType) => _listActivators.GetOrAdd(expectedType, ListActivator); +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "AOT", + "IL3050", + Justification = "Generated collection registrations return before this runtime generic construction path. This path serves only the documented fallback for unregistered collection types, which is unsupported in NativeAOT applications.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Generated collection registrations return before this reflection path. This path serves only the documented fallback for unregistered collection types, which is unsupported in trimmed applications.")] +#endif private static ObjectActivator ListActivator(Type expectedType) { var genericArgs = expectedType.GetGenericArguments(); diff --git a/MaxMind.Db/MaxMind.Db.csproj b/MaxMind.Db/MaxMind.Db.csproj index 6f38d69..4208a28 100644 --- a/MaxMind.Db/MaxMind.Db.csproj +++ b/MaxMind.Db/MaxMind.Db.csproj @@ -28,11 +28,13 @@ 5.0.0 $(VersionPrefix).0 14.0 + ..\MaxMind.Db.SourceGenerator\bin\$(Configuration)\netstandard2.0\MaxMind.Db.SourceGenerator.dll true enable latest true true + true true + + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)$(MaxMindDbAnalyzerPackSource)')) + @(MaxMindDbAnalyzerAssembly->'%(FullPath)') + + + + + + - + + + + True diff --git a/MaxMind.Db/ReflectionUtil.cs b/MaxMind.Db/ReflectionUtil.cs index 8bf6d32..6d12fc1 100644 --- a/MaxMind.Db/ReflectionUtil.cs +++ b/MaxMind.Db/ReflectionUtil.cs @@ -39,6 +39,11 @@ internal static ObjectActivator CreateActivator(ConstructorInfo constructor) var newExp = Expression.New(constructor, argsExp); var lambda = Expression.Lambda(typeof(ObjectActivator), newExp, paramExp); + // N.B. The AOT analyzer does not report IL3050 for + // LambdaExpression.Compile(), so a warning-free AOT-compatible build is not + // evidence that this path survives NativeAOT. Only models without a + // source-generated registration reach it, and the NativeAOT integration + // test under MaxMind.Db.NativeAot is what pins its actual behavior. return (ObjectActivator)lambda.Compile(); } @@ -74,6 +79,8 @@ internal static ObjectActivator CreateMemberInitActivator( var newExp = Expression.MemberInit(Expression.New(parameterlessCtor), bindings); var lambda = Expression.Lambda(typeof(ObjectActivator), newExp, paramExp); + // See the note on Compile() in CreateActivator: this path is unanalyzed and + // is covered only by the NativeAOT integration test. return (ObjectActivator)lambda.Compile(); } diff --git a/MaxMind.Db/SourceGeneratorSupport.cs b/MaxMind.Db/SourceGeneratorSupport.cs new file mode 100644 index 0000000..5a2223f --- /dev/null +++ b/MaxMind.Db/SourceGeneratorSupport.cs @@ -0,0 +1,489 @@ +#region + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.ComponentModel; +using System.Text; +using System.Threading; + +#endregion + +namespace MaxMind.Db +{ + /// + /// Infrastructure used by the MaxMind DB source generator. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class SourceGeneratorSupport + { + private static readonly ConcurrentDictionary + TypeRegistrations = new(); + private static readonly ConcurrentDictionary + CollectionRegistrations = new(); + private static readonly ConcurrentDictionary + DictionaryRegistrations = new(); + private static volatile bool _hasNonGenericDictionaryRegistration; + + /// + /// Whether any non-generic dictionary type has been registered. Generic + /// dictionaries are already routed by their arity, so decoding only needs to + /// look for a registration when this is . + /// + /// + /// Process-wide and write-once, like the registries themselves, which are + /// populated from module initializers and never reset. Setting it cannot + /// change the outcome for any other type: decoding still requires a + /// registration keyed by the exact type, so for every unrelated type the + /// lookup misses and the branch behaves as if this were still + /// . It only decides whether that lookup happens at + /// all, which is why a test registering a non-generic dictionary cannot + /// perturb tests that run after it. + /// + internal static bool HasNonGenericDictionaryRegistration + => _hasNonGenericDictionaryRegistration; + + /// + /// Registers source-generated deserialization metadata for a model type. + /// + /// The model type being registered. + /// Creates an instance from the ordered member values. + /// Creates the ordered default member values. + /// The ordered deserialization member metadata. + /// + /// Thrown when any argument is . + /// + /// + /// Thrown when a member was not created by one of the + /// factory methods. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void RegisterType( + Func activator, + Func defaultsFactory, + GeneratedMember[] members + ) + where T : class + { + if (activator == null) + { + throw new ArgumentNullException(nameof(activator)); + } + if (defaultsFactory == null) + { + throw new ArgumentNullException(nameof(defaultsFactory)); + } + if (members == null) + { + throw new ArgumentNullException(nameof(members)); + } + + var registeredMembers = (GeneratedMember[])members.Clone(); + for (var i = 0; i < registeredMembers.Length; i++) + { + if (registeredMembers[i].MemberType == null) + { + throw new ArgumentException( + "Members must be created with a GeneratedMember factory method.", + nameof(members)); + } + } + + // N.B. Validation here is limited to what cannot vary by type. Generated + // registrations all run from a single module initializer, so anything + // thrown from this method leaves the generated registration class + // permanently uninitializable and disables generated activation for every + // type in that assembly. Consistency checks that involve the member set as + // a whole belong in GeneratedTypeActivatorRegistration, where they run on + // first use and fail only the offending type. + TypeRegistrations.TryAdd(typeof(T), new GeneratedTypeActivatorRegistration( + typeof(T), + args => activator(args), + defaultsFactory, + registeredMembers)); + } + + /// + /// Registers source-generated creation and mutation delegates for a + /// collection type. + /// + /// The declared collection type. + /// The collection element type. + /// + /// Creates a collection using the decoded item count as a capacity hint. + /// + /// + /// Adds a decoded element to the collection. This stays untyped so that the + /// cast lives in generated code rather than in a wrapper delegate on the + /// per-element decode path. + /// + /// + /// Thrown when any argument is . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void RegisterCollection( + Func factory, + Action add + ) + where TCollection : class + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + if (add == null) + { + throw new ArgumentNullException(nameof(add)); + } + + CollectionRegistrations.TryAdd( + typeof(TCollection), + new GeneratedCollectionRegistration( + typeof(TElement), capacity => factory(capacity), add)); + } + + /// + /// Registers source-generated creation and mutation delegates for a + /// dictionary type. + /// + /// The declared dictionary type. + /// The dictionary key type. + /// The dictionary value type. + /// + /// Creates a dictionary using the decoded item count as a capacity hint. + /// + /// + /// Adds a decoded key and value to the dictionary. This stays untyped for + /// the same reason as the collection overload. + /// + /// + /// Thrown when any argument is . + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static void RegisterDictionary( + Func factory, + Action add + ) + where TDictionary : class + { + if (factory == null) + { + throw new ArgumentNullException(nameof(factory)); + } + if (add == null) + { + throw new ArgumentNullException(nameof(add)); + } + + if (DictionaryRegistrations.TryAdd( + typeof(TDictionary), + new GeneratedDictionaryRegistration( + typeof(TKey), + typeof(TValue), + capacity => factory(capacity), + add)) && + !typeof(TDictionary).IsGenericType) + { + _hasNonGenericDictionaryRegistration = true; + } + } + + internal static bool TryGetCollectionRegistration( + Type type, + out GeneratedCollectionRegistration registration + ) => CollectionRegistrations.TryGetValue(type, out registration!); + + internal static bool TryGetDictionaryRegistration( + Type type, + out GeneratedDictionaryRegistration registration + ) => DictionaryRegistrations.TryGetValue(type, out registration!); + + internal static bool TryGetTypeRegistration( + Type type, + out GeneratedTypeActivatorRegistration registration + ) => TypeRegistrations.TryGetValue(type, out registration!); + } + + /// + /// Which source of data supplies a source-generated member's value. + /// + internal enum GeneratedMemberKind + { + Mapped, + Injected, + Networked, + } + + /// + /// Describes one member used by source-generated MaxMind DB deserialization. + /// A member draws its value from exactly one source, so instances are created + /// through , or + /// rather than a constructor that could express a + /// combination none of the decode paths can resolve. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public readonly struct GeneratedMember + { + private GeneratedMember( + GeneratedMemberKind kind, + string? mapKey, + Type memberType, + string? injectableName, + bool alwaysCreate + ) + { + Kind = kind; + MapKey = mapKey; + MemberType = memberType; + InjectableName = injectableName; + AlwaysCreate = alwaysCreate; + } + + /// + /// Creates metadata for a member read from a database map key. + /// + /// The database map key for the member. + /// The type of the member. + /// + /// Whether the member is created even when the key is absent. + /// + /// The member metadata. + /// + /// Thrown when or + /// is . + /// + public static GeneratedMember Mapped( + string mapKey, + Type memberType, + bool alwaysCreate + ) => new( + GeneratedMemberKind.Mapped, + mapKey ?? throw new ArgumentNullException(nameof(mapKey)), + memberType ?? throw new ArgumentNullException(nameof(memberType)), + null, + alwaysCreate); + + /// + /// Creates metadata for a member supplied from injectable values. + /// + /// The injectable name for the member. + /// The type of the member. + /// The member metadata. + /// + /// Thrown when or + /// is . + /// + public static GeneratedMember Injected( + string injectableName, + Type memberType + ) => new( + GeneratedMemberKind.Injected, + null, + memberType ?? throw new ArgumentNullException(nameof(memberType)), + injectableName ?? throw new ArgumentNullException(nameof(injectableName)), + false); + + /// + /// Creates metadata for a member that receives the matched network. + /// + /// The type of the member. + /// The member metadata. + /// + /// Thrown when is . + /// + public static GeneratedMember Networked(Type memberType) => new( + GeneratedMemberKind.Networked, + null, + memberType ?? throw new ArgumentNullException(nameof(memberType)), + null, + false); + + internal bool AlwaysCreate { get; } + internal string? InjectableName { get; } + internal GeneratedMemberKind Kind { get; } + internal string? MapKey { get; } + internal Type MemberType { get; } + } + + internal sealed class GeneratedCollectionRegistration + { + internal GeneratedCollectionRegistration( + Type elementType, + Func factory, + Action add + ) + { + ElementType = elementType; + Factory = factory; + Add = add; + } + + internal Action Add { get; } + internal Type ElementType { get; } + internal Func Factory { get; } + } + + internal sealed class GeneratedDictionaryRegistration + { + internal GeneratedDictionaryRegistration( + Type keyType, + Type valueType, + Func factory, + Action add + ) + { + KeyType = keyType; + ValueType = valueType; + Factory = factory; + Add = add; + } + + internal Action Add { get; } + internal Func Factory { get; } + internal Type KeyType { get; } + internal Type ValueType { get; } + } + + internal sealed class GeneratedTypeActivatorRegistration + { + private readonly ObjectActivator _activator; + private readonly Func _defaultsFactory; + private readonly GeneratedMember[] _members; + private readonly Type _type; + private ActivatorMetadata? _cachedMetadata; + + internal GeneratedTypeActivatorRegistration( + Type type, + Func activator, + Func defaultsFactory, + GeneratedMember[] members + ) + { + _type = type; + _activator = args => activator(args); + _defaultsFactory = defaultsFactory; + _members = members; + } + + internal TypeActivator CreateActivator() + { + object?[] defaultParameters; + try + { + defaultParameters = (object?[])_defaultsFactory().Clone(); + } + catch (Exception ex) + { + throw new DeserializationException( + $"The source-generated default value factory for {_type} threw an exception", + ex); + } + if (defaultParameters.Length != _members.Length) + { + throw new DeserializationException( + "Source-generated default member values must match the registered member count."); + } + + var metadata = GetOrCreateMetadata(); + // Nulling the slot is what makes SetAlwaysCreatedParams construct the + // member. A non-nullable value type has no model to construct, and the + // reflection path leaves its default in place, so nulling it here would + // send the decoder off to activate something like System.Int32 as a model. + foreach (var member in metadata.AlwaysCreatedParameters) + { + if (!TypeActivator.IsNonNullableValueType(member.MemberType)) + { + defaultParameters[member.Position] = null; + } + } + + return new TypeActivator( + _activator, + metadata.DeserializationParameters, + metadata.Injectables, + metadata.NetworkParameters, + metadata.AlwaysCreatedParameters, + defaultParameters); + } + + private ActivatorMetadata GetOrCreateMetadata() + { + var cachedMetadata = Volatile.Read(ref _cachedMetadata); + if (cachedMetadata != null) + { + return cachedMetadata; + } + + var metadata = BuildMetadata(); + return Interlocked.CompareExchange(ref _cachedMetadata, metadata, null) ?? metadata; + } + + private ActivatorMetadata BuildMetadata() + { + var deserializationParameters = + new Dictionary(_members.Length); + var injectables = new List>(); + var networkParameters = new List(); + var alwaysCreatedParameters = new List(); + + for (var i = 0; i < _members.Length; i++) + { + var registeredMember = _members[i]; + var member = new DeserializationMember(i, registeredMember.MemberType); + switch (registeredMember.Kind) + { + case GeneratedMemberKind.Injected: + injectables.Add( + new KeyValuePair( + registeredMember.InjectableName!, member)); + break; + case GeneratedMemberKind.Networked: + networkParameters.Add(member); + break; + default: + var key = new Key( + Encoding.UTF8.GetBytes(registeredMember.MapKey!)); + if (deserializationParameters.ContainsKey(key)) + { + throw new DeserializationException( + $"Source-generated metadata for {_type} contains the " + + $"duplicate map key '{registeredMember.MapKey}'."); + } + deserializationParameters.Add(key, member); + if (registeredMember.AlwaysCreate) + { + alwaysCreatedParameters.Add(member); + } + break; + } + } + + return new ActivatorMetadata( + deserializationParameters, + injectables.ToArray(), + networkParameters.ToArray(), + alwaysCreatedParameters.ToArray()); + } + + private sealed class ActivatorMetadata + { + internal ActivatorMetadata( + Dictionary deserializationParameters, + KeyValuePair[] injectables, + DeserializationMember[] networkParameters, + DeserializationMember[] alwaysCreatedParameters + ) + { + DeserializationParameters = deserializationParameters; + Injectables = injectables; + NetworkParameters = networkParameters; + AlwaysCreatedParameters = alwaysCreatedParameters; + } + + internal DeserializationMember[] AlwaysCreatedParameters { get; } + internal Dictionary DeserializationParameters { get; } + internal KeyValuePair[] Injectables { get; } + internal DeserializationMember[] NetworkParameters { get; } + } + } +} diff --git a/MaxMind.Db/TypeActivatorCreator.cs b/MaxMind.Db/TypeActivatorCreator.cs index a10a40e..b649feb 100644 --- a/MaxMind.Db/TypeActivatorCreator.cs +++ b/MaxMind.Db/TypeActivatorCreator.cs @@ -12,24 +12,21 @@ namespace MaxMind.Db { /// - /// Wraps either a (constructor-based activation) - /// or a (property-based activation) so the decoder - /// can treat both uniformly. + /// Identifies the destination slot and type for a deserialized constructor + /// argument or property. /// internal sealed class DeserializationMember { internal int Position { get; } internal Type MemberType { get; } - internal string? Name { get; } internal DeserializationMember(ParameterInfo param) { Position = param.Position; MemberType = param.ParameterType; - Name = param.Name; } - internal DeserializationMember(int position, Type memberType, string? name) + internal DeserializationMember(int position, Type memberType) { if (position < 0) { @@ -37,7 +34,6 @@ internal DeserializationMember(int position, Type memberType, string? name) } Position = position; MemberType = memberType ?? throw new ArgumentNullException(nameof(memberType)); - Name = name; } } @@ -77,26 +73,56 @@ internal TypeActivator( } } +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2067", + Justification = "Generated type registrations supply default values and bypass this method. This method serves only the documented reflection fallback for unregistered models, which is unsupported in trimmed applications.")] +#endif private static object? DefaultValue(Type type) { - if (type.GetTypeInfo().IsValueType && Nullable.GetUnderlyingType(type) == null) + if (IsNonNullableValueType(type)) { return System.Activator.CreateInstance(type); } return null; } + + /// + /// Whether a member of this type has no null state, and so cannot be + /// signalled as absent by a null default. Both activation paths use this to + /// decide whether an AlwaysCreate member can be constructed at all. + /// + internal static bool IsNonNullableValueType(Type type) + => type.GetTypeInfo().IsValueType && Nullable.GetUnderlyingType(type) == null; } internal sealed class TypeActivatorCreator { + private const string TrimmedModelGuidance = + " If this application was trimmed or published with NativeAOT and these members " + + "exist in source, rebuild the assembly that declares the model with the MaxMind.Db " + + "source generator and resolve any MMDBSG diagnostics."; + private readonly ConcurrentDictionary _typeConstructors = new(); internal TypeActivator GetActivator(Type expectedType) => _typeConstructors.GetOrAdd(expectedType, ClassActivator); +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Generated type registrations return before this reflection path. This path serves only the documented fallback for unregistered models, which is unsupported in trimmed applications.")] +#endif private static TypeActivator ClassActivator(Type expectedType) { + if (SourceGeneratorSupport.TryGetTypeRegistration(expectedType, out var registration)) + { + return registration.CreateActivator(); + } + var constructors = expectedType.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) .Where(c => c.IsDefined(typeof(ConstructorAttribute), true)) @@ -166,6 +192,12 @@ private static TypeActivator ConstructorBasedActivator(ConstructorInfo construct networkParams.ToArray(), alwaysCreated.ToArray()); } +#if NET8_0_OR_GREATER + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2070", + Justification = "Generated type registrations return before this reflection path. This path serves only the documented fallback for unregistered models, which is unsupported in trimmed applications.")] +#endif private static TypeActivator PropertyBasedActivator(Type expectedType) { var parameterlessCtor = expectedType.GetConstructor( @@ -176,7 +208,8 @@ private static TypeActivator PropertyBasedActivator(Type expectedType) { throw new DeserializationException( $"No constructor found for {expectedType} with the MaxMind.Db.Constructor attribute " - + "and no parameterless constructor found for property-based activation"); + + "and no parameterless constructor found for property-based activation." + + TrimmedModelGuidance); } var properties = expectedType @@ -191,7 +224,8 @@ private static TypeActivator PropertyBasedActivator(Type expectedType) { throw new DeserializationException( $"No properties found on {expectedType} with the MapKey, Inject, or Network " - + "attributes for property-based activation"); + + "attributes for property-based activation." + + TrimmedModelGuidance); } var paramNameTypes = new Dictionary(); @@ -210,7 +244,7 @@ private static TypeActivator PropertyBasedActivator(Type expectedType) + "for property-based activation"); } - var member = new DeserializationMember(position, prop.PropertyType, prop.Name); + var member = new DeserializationMember(position, prop.PropertyType); var injectableAttribute = prop.GetCustomAttributes().FirstOrDefault(); if (injectableAttribute != null) @@ -262,9 +296,15 @@ private static TypeActivator PropertyBasedActivator(Type expectedType) defaultParameters[i] = orderedProperties[i].GetValue(tempInstance); } // Override AlwaysCreate defaults to null so SetAlwaysCreatedParams triggers. + // A non-nullable value type has nothing to construct and no null state to + // signal with, so it keeps its default — matching the constructor path, + // which never overrides these. foreach (var ac in alwaysCreated) { - defaultParameters[ac.Position] = null; + if (!TypeActivator.IsNonNullableValueType(ac.MemberType)) + { + defaultParameters[ac.Position] = null; + } } var activator = ReflectionUtil.CreateMemberInitActivator( diff --git a/MaxMind.Db/buildTransitive/MaxMind.Db.targets b/MaxMind.Db/buildTransitive/MaxMind.Db.targets new file mode 100644 index 0000000..4a86e4e --- /dev/null +++ b/MaxMind.Db/buildTransitive/MaxMind.Db.targets @@ -0,0 +1,18 @@ + + + + true + + + + + + diff --git a/README.md b/README.md index 3de5b90..d37bda0 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,108 @@ This API fully supports use in multi-threaded applications. In such applications, we suggest creating one `Reader` object and sharing that among threads. +## NativeAOT and Trimming + +The `MaxMind.Db` NuGet package includes a C# source generator that enables +trim-safe, reflection-free deserialization for NativeAOT applications. The +generator is included automatically; no additional package or registration is +required. It needs the .NET SDK 7.0.100 or later; an older SDK reports `CS9057` +and skips the generator, leaving models on the reflection fallback. The +generator reports a diagnostic for any annotated model it cannot generate, in +whichever project declares that model. + +The generator supports both model styles shown above: + +- A non-generic model with exactly one accessible `[Constructor]`-annotated + constructor. +- A non-generic property-based model with an accessible parameterless + constructor and accessible annotated getters and setters. Attributes on + inherited properties are supported, including concrete records whose + annotations are declared on an abstract base record. + +Generated collection activation supports common generic interfaces such as +`ICollection`, `IReadOnlyList`, `IDictionary`, and +`IReadOnlyDictionary`. Concrete collection and dictionary types +are supported when they implement the corresponding mutable interface and have +an accessible parameterless constructor; this includes types such as +`LinkedList`. The generator discovers these types when they are model members +or closed generic arguments in direct `Reader.Find` and `Reader.FindAll` +calls. + +There are several current limitations: + +- Source generation is supported for C# models. Other .NET languages continue to + use the reflection fallback, which is not guaranteed to work after trimming or + with NativeAOT. +- Source generation requires C# 9 or later because generated registrations use + module initializers. Earlier C# versions continue to use the reflection + fallback in non-AOT builds. +- A generic wrapper around `Find` or `FindAll` is fine for models. Models + are registered from their declarations, not from lookup sites, so a method + like `T Lookup(Reader reader, IPAddress address)` still resolves generated + activation for every model declared in a generator-enabled project. + + What such a wrapper cannot carry is a **collection** result type. Collection + and dictionary types have no annotated declaration to find, so they are + discovered from the lookup site, and a wrapper hides which one is used. Use a + concrete type argument at the call site — `Find>` + rather than `Lookup>` — or make the collection a + member of a model. The same applies to a result type chosen at run time. + + No diagnostic is reported for a wrapper, because the generator cannot tell + from the call site whether the eventual type argument is a registered model or + an unregistered collection, and warning on every wrapper would be a false + positive for the common case. A constructed type that still contains a type + parameter, such as `Find>`, is reported as `MMDBSG015`. + +- Generic model classes are not supported, closed or otherwise. Models are + discovered from their declarations, so the generator only ever sees the + unbound definition and reports `MMDBSG004`, even where every use is a closed + construction such as `Find>`. +- Models must be classes or records. Annotated structs and record structs are + reported as `MMDBSG012`. A constructor-based struct then falls back to + reflection and works; a property-based struct or record struct fails at run + time, in a plain JIT build as much as under NativeAOT, because reflection does + not surface a struct's implicit parameterless constructor and there is nothing + to activate unless one is declared explicitly. +- MMDB array values cannot be deserialized into CLR array model members. Use a + supported generic collection instead. `byte[]` remains supported for MMDB byte + values. +- Private or protected model constructors, types, property getters, and property + setters cannot be called by the generated code. Use `public` or `internal` + accessibility. +- Models with `required` members must mark the constructor used for + deserialization with `SetsRequiredMembersAttribute`. For property models, this + is the accessible parameterless constructor. + +Treat these diagnostics as build errors rather than suppressing them; each one +means a model would fall back to reflection, which is not guaranteed to work +after trimming or with NativeAOT. They are reported by default, including in a +model class library that knows nothing about how it will be published. That is +deliberate: an application's `PublishAot` does not propagate across a +`ProjectReference`, so keying the diagnostics off it would silence them in the +one compilation that can report them. To turn them off in a project that will +never be trimmed: + +```xml + + false + +``` + +Because that property decides whether the diagnostics are produced at all, +setting `dotnet_diagnostic.MMDBSG0NN.severity` in `.editorconfig` has no effect +once it is `false`. + +A separately packaged model library must be built or rebuilt with a version of +`MaxMind.Db` that includes the source generator. Updating only the application +cannot add registrations to an already compiled model assembly; precompiled +model libraries without generated registrations are not guaranteed to work after +trimming or with NativeAOT. The absence of a source-generator diagnostic does +not validate models from an already-compiled referenced assembly. If that +assembly has no generated registrations, its reflection fallback is unsupported +and may fail at run time after trimming or with NativeAOT. + ## Format The MaxMind DB format is an open format for quickly mapping IP addresses to diff --git a/dev-bin/test-native-aot.sh b/dev-bin/test-native-aot.sh new file mode 100644 index 0000000..2a2edf8 --- /dev/null +++ b/dev-bin/test-native-aot.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly native_aot_rid="${1:?usage: test-native-aot.sh }" +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly repository_root +readonly library_project="$repository_root/MaxMind.Db/MaxMind.Db.csproj" +readonly app_project="$repository_root/MaxMind.Db.NativeAot/App/MaxMind.Db.NativeAot.App.csproj" +readonly netstandard_models_project="$repository_root/MaxMind.Db.NetStandard.TestModels/MaxMind.Db.NetStandard.TestModels.csproj" +readonly package_directory="$repository_root/artifacts/native-aot-packages" + +# Derived rather than hard-coded so this cannot drift from the library, and read +# with -getProperty so the same value is used for the pack and for the reference. +version_prefix="$(dotnet msbuild "$library_project" \ + -getProperty:VersionPrefix -p:TargetFramework=net8.0)" +readonly version_prefix +readonly package_version="$version_prefix-aot-ci" + +target_framework="$(dotnet msbuild "$app_project" -getProperty:TargetFramework)" +readonly target_framework + +restore_directory="$(mktemp -d)" +readonly restore_directory +cleanup() { + rm -rf "$restore_directory" +} +trap cleanup EXIT + +# A previous run's package has the same file name, and NuGet would happily restore +# the stale one from this folder. +rm -rf "$package_directory" +mkdir -p "$package_directory" + +dotnet pack "$library_project" \ + --configuration Release \ + --output "$package_directory" \ + -p:PackageVersion="$package_version" + +# EmitCompilerGeneratedFiles lets the build assert the generator actually ran for a +# package consumer, which the app's own model project already checks for itself. +dotnet build "$netstandard_models_project" \ + --configuration Release \ + -p:MaxMindDbPackageVersion="$package_version" \ + -p:RestoreAdditionalProjectSources="$package_directory" \ + -p:RestorePackagesPath="$restore_directory" \ + -p:RestoreNoCache=true \ + -p:EmitCompilerGeneratedFiles=true + +netstandard_generated_count="$(find "$repository_root/MaxMind.Db.NetStandard.TestModels/obj" \ + -name 'MaxMind.Db.SourceGenerator.g.cs' | wc -l)" +if [[ "$netstandard_generated_count" -eq 0 ]]; then + echo "The source generator produced no registrations for the .NET Standard" \ + "consumer. Its models would fall back to reflection." >&2 + exit 1 +fi + +dotnet publish "$app_project" \ + --configuration Release \ + --runtime "$native_aot_rid" \ + -p:MaxMindDbPackageVersion="$package_version" \ + -p:RestoreAdditionalProjectSources="$package_directory" \ + -p:RestorePackagesPath="$restore_directory" \ + -p:RestoreNoCache=true + +readonly publish_directory="$repository_root/MaxMind.Db.NativeAot/App/bin/Release/$target_framework/$native_aot_rid/publish" +if [[ ! -d "$publish_directory" ]]; then + echo "No publish output at $publish_directory." >&2 + exit 1 +fi + +if [[ "$native_aot_rid" == win-* ]]; then + "$publish_directory/MaxMind.Db.NativeAot.App.exe" +else + "$publish_directory/MaxMind.Db.NativeAot.App" +fi diff --git a/releasenotes.md b/releasenotes.md index 92fe6e3..46906d8 100644 --- a/releasenotes.md +++ b/releasenotes.md @@ -1,5 +1,29 @@ # Release Notes +## 5.2.0 (YYYY-MM-DD) + +- Added NativeAOT and trimming support for C# model deserialization. The NuGet + package now includes a source generator for constructor-based and + property-based models, including models with annotated properties inherited + from abstract base records. +- Added reflection-free source-generated activation for supported generic + collection and dictionary interfaces and concrete types, including closed + collection types used directly with `Find` and `FindAll`. +- Reused immutable source-generated activation metadata across readers, reducing + the time and allocation cost of the first model lookup on a new reader. +- Enabled trim, AOT, and single-file compatibility analysis. +- Added the `MMDBSG001` through `MMDBSG016` diagnostics, which report model + shapes the generator cannot support so that they are caught at build time + rather than after trimming or an AOT publish. They are reported by default in + any project that declares models; set the `MaxMindDbAotDiagnostics` MSBuild + property to `false` to turn them off. +- Added the `MaxMind.Db.SourceGeneratorSupport` and `MaxMind.Db.GeneratedMember` + public types. They exist for generated code to call and are not intended to be + used directly. +- Fixed a `[MapKey(..., true)]` member of a non-nullable value type throwing + during property-based activation instead of keeping its default. This affected + the reflection path before this release and is now consistent across both. + ## 5.1.0 (2026-05-22) - `FileAccessMode.MemoryMapped` now creates an unnamed file-backed memory-mapped