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