Skip to content

Compile time Mapper Validation

Vadim Loboda edited this page Nov 7, 2026 · 1 revision

Compile-time mapper validation (Roslyn)

Artisan.Orm discovers mappers by reflection. The Roslyn analyzer checks their contracts while you edit and build C# code, so a missing mapper method can be caught before the application runs or connects to SQL Server.

The central contract is the second argument of MapperFor: RequiredMethod declares what a mapper promises to support. The analyzer checks that promise as well as the requirements of actual Artisan calls.

Availability: this page describes the analyzer and fixes being prepared for 4.1.0. Do not assume that an older installed Artisan.ORM package includes them. See 4.1.0 mapper self-checks.

Why RequiredMethod is useful for self-checking

The first attribute argument identifies the entity: MapperFor(typeof(Product)) registers a mapper for Product. The optional second argument records the capabilities that the author intends this mapper to have.

This turns an intention into a checkable contract:

  • A mapper intended only for entity reads promises CreateObject.
  • A mapper intended for entity reads and table-valued parameters promises AllMain.
  • A mapper intended only to convert objects to table data promises BothForDataTable.

Without that declaration, a missing method can be intentional: the library supports partial mappers. It cannot infer that a developer forgot a write method rather than deliberately created a read-only mapper. RequiredMethod supplies that missing information.

The contract is useful when copying a mapper, renaming or deleting a method, changing its signature, or preparing a mapper for operations that have not been written yet. For example, accidentally deleting CreateDataRow from an AllMain mapper violates its contract even if no current call exports that entity to a table. It also makes the intended capabilities visible to a reviewer without reading every call site.

Runtime validation already used this declaration during lazy mapper discovery. Roslyn adds earlier feedback in the editor and during the build, including for mapper declarations that have no callers. Runtime validation remains in place.

RequiredMethod checks the presence of usable methods. It does not prove that their bodies return correct data: an empty stub can satisfy a signature while still breaking the application. Implement the intended behavior instead of adding stubs just to silence an error.

The second argument: values and combinations

The constructor accepts params RequiredMethod[]. Multiple arguments combine their requirements; duplicates do not add requirements.

Value Methods that must be present Typical purpose
CreateObject CreateObject Read entities
CreateObjectRow CreateObjectRow Read ObjectRow values
BothForObject CreateObject, CreateObjectRow Both reader-to-object forms
BothForDataTable CreateDataTable, CreateDataRow Convert entities to table data, for example TVPs or bulk copy
AllMain CreateObject, CreateDataTable, CreateDataRow Read entities and convert them to table data
All All four methods Both reading forms and table-data conversion

[MapperFor(typeof(Product), RequiredMethod.AllMain)] is equivalent to [MapperFor(typeof(Product), RequiredMethod.CreateObject, RequiredMethod.BothForDataTable)].

Use comma-separated arguments, not bitwise |. RequiredMethod is an ordinary enum, not a flags enum. A bitwise combination can accidentally select a different valid value or produce an invalid registration (ART007).

The values are minimum requirements, not a list of enabled methods. Extra methods are still discovered and validated. Omitting the second argument, as in [MapperFor(typeof(Product))], means no declared method-presence requirements; it does not mean All, and it does not disable validation of existing methods or calls.

Example: an incomplete AllMain mapper

This deliberately incomplete example reports two ART002 errors: CreateDataTable and CreateDataRow are missing. No call to the mapper is needed to trigger them.

using Artisan.Orm;
using Microsoft.Data.SqlClient;

public sealed class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
}

[MapperFor(typeof(Product), RequiredMethod.AllMain)]
public static class ProductMapper
{
    // Assumes the SELECT returns non-null int Id and string Name, in that order.
    public static Product CreateObject(SqlDataReader reader) => new Product
    {
        Id = reader.GetInt32(0),
        Name = reader.GetString(1)
    };
}

If this mapper is intended only for entity reads, change the attribute to [MapperFor(typeof(Product), RequiredMethod.CreateObject)]. The method already fulfills that narrower promise.

If it is intended for both reads and table-data conversion, keep AllMain and add the following methods inside the same ProductMapper class:

public static System.Data.DataTable CreateDataTable()
{
    var table = new System.Data.DataTable("dbo.ProductTableType");
    table.Columns.Add("Id", typeof(int)).AllowDBNull = false;
    table.Columns.Add("Name", typeof(string)).AllowDBNull = false;
    return table;
}

public static object[] CreateDataRow(Product product) => new object[]
{
    product.Id,
    product.Name
};

For a TVP, the database must already contain a matching dbo.ProductTableType. The analyzer does not create or inspect that SQL type. See User-defined table types.

Migration note: earlier runtime code checked only CreateObject for AllMain. The current fix enforces all three documented methods in both runtime and analyzer validation. Review the mapper's intended capabilities when an old declaration starts reporting errors; removing the requirement does not implement the missing functionality.

A declared contract and an operation's needs are independent

Two complementary checks answer different questions:

Check Question Example failure
Mapper declaration Does this mapper fulfill the capabilities its author promised? AllMain with no CreateDataRow: ART002, even without callers
Operation Can the registered mapper perform this particular operation? A read-only mapper used by ToDataTable: ART006

For example, use the read-only version of ProductMapper above, with RequiredMethod.CreateObject and without either DataTable method. Its declaration is valid, but this call reports ART006 for each missing DataTable function:

public static class ProductExport
{
    public static System.Data.DataTable Export(
        System.Collections.Generic.IEnumerable<Product> products) =>
        products.ToDataTable();
}

This fragment uses using Artisan.Orm; from the preceding example. Removing the second attribute argument would not make this call valid. Likewise, ReadToObjectRows<Product>() needs CreateObjectRow, even when the attribute only requests CreateObject.

Registered-mapper ReadTo* operations require the appropriate reader function. The analyzer covers synchronous and asynchronous reads, streaming, trees, dictionaries, multiple result sets, and method-group references. Dictionary operations check the value type; multiple-result operations check each result type. Mapper-based ToDataTable, AddTableParam, AddTableRowParam, BulkCopy, and BulkCopyAsync require both DataTable functions.

As operations, inline-mapper overloads, and supported scalar read paths do not require a registered mapper. A repository's Action<SqlCommand> argument configures the command; it is not an inline row mapper. Direct MappingManager.GetCreateObjectFunc<T> and GetCreateObjectRowFunc<T> calls are also checked, while nullable/Try-style getters may be used without a registration.

In 4.1.0, scalar exemptions include enums and nullable enums consistently with the runtime numeric conversion path, including dictionary values. This does not validate that database values name defined enum members.

Diagnostic reference

All seven rules have default severity Error, so they fail the build.

ID Meaning What to check
ART001 A required mapper was not found. Register a mapper for the entity in source or a referenced assembly, or use an appropriate inline-mapper / As overload.
ART002 A method required by RequiredMethod was not found by runtime lookup. Implement the required method, or correct the declared contract if it overstates the mapper's intended capabilities.
ART003 A discovered method cannot bind to its runtime delegate. Correct its static, non-generic declaration, parameters, or return type.
ART004 More than one mapper registers the same entity. Keep one registration, including registrations in other assemblies and inherited attributes.
ART005 Reflection cannot choose a method unambiguously. Remove ambiguous overloads; CreateDataTable is looked up by name alone.
ART006 An operation needs a function its mapper cannot provide. Implement the function or choose an operation that fits the mapper. This check is independent of RequiredMethod.
ART007 A registration is invalid. Correct invalid targets, open generic mapper classes, null requirements arrays, or undefined enum values.

For mappers in referenced assemblies, ART006 can also report a broken declared contract or an unusable discovered method at the call site, because runtime mapper initialization would fail. A read call can therefore expose missing DataTable methods on a referenced AllMain mapper even though that call only needs CreateObject.

The conventional methods for Product are:

Method Parameter Return type
CreateObject Microsoft.Data.SqlClient.SqlDataReader Product
CreateObjectRow Microsoft.Data.SqlClient.SqlDataReader Artisan.Orm.ObjectRow
CreateDataTable None System.Data.DataTable
CreateDataRow Product object[]

Methods must be public, static, non-generic, and compatible with the runtime delegate. The mapper class itself need not be static. Fields or properties containing delegates are not mapper methods. A private method or incompatible input parameters can cause ART002 because reflection does not find the required method; a found method with an incompatible return type causes ART003.

Checks follow reflection lookup and delegate compatibility rather than requiring textual equality of types. For example, IDataRecord can accept a SqlDataReader, and a derived reference return type can satisfy a base return type. Inherited static methods are not found by the runtime's default lookup. See Mappers for complete mapping examples and implementation notes for compatibility details.

Using the analyzer in Visual Studio and builds

For a package built from the current source, a normal C# PackageReference to Artisan.ORM loads the bundled analyzer automatically. No separate analyzer package, registration, or source generator is needed. The compiler host must provide Roslyn 4.8 or later, such as .NET SDK 8.0.100 or later; this requirement is independent of the application's target framework. Explicitly disabling analyzers through MSBuild settings disables these checks.

The analyzer runs during builds and live document analysis. Visual Studio can show diagnostics at the attribute or operation in the editor and in Build + IntelliSense. Active-document checks do not require full-solution background analysis; registrations in other source files and referenced assemblies are still considered.

If a build fails but the combined Error List is empty, compare Build Only with IntelliSense Only and inspect the Build output. Earlier development versions reported mapper declarations only at compilation end. The current implementation uses live semantic-model and operation analysis so those diagnostics also participate in editor analysis. Merely adding a CompilationEnd diagnostic tag did not resolve that earlier issue.

The analyzer does not run in the deployed application. Runtime mapper discovery and validation remain necessary.

A direct project reference to the runtime library does not itself carry NuGet analyzer assets. In this repository, consuming projects explicitly attach Artisan.Orm.Analyzers.csproj with OutputItemType="Analyzer" and ReferenceOutputAssembly="false".

Limits and intentional runtime-only registrations

The analyzer examines source and referenced assemblies. It cannot guarantee runtime assembly load order or resolve every dynamic/reflection-driven call. Calls with unresolved entity type parameters in user-defined generic wrappers are deferred to runtime. Custom attribute subclasses that compute their registrations dynamically cannot generally be resolved.

It does not run SQL or mapper bodies, verify reader column order, SQL types or nullability, or compare a DataTable schema with a database UDTT. Keep integration tests for these contracts.

If a project intentionally receives mappers from a runtime-only assembly or a downstream host, the following .editorconfig setting opts out of ART001 only:

[*.cs]
artisan_orm.allow_external_mappers = true

Narrow the file pattern where possible. ART002–ART007, including the declared RequiredMethod contract, remain active. For a single exceptional call, a local #pragma warning disable ART001 / restore is another option. These exceptions are for intentional runtime composition, not a replacement for implementing a mapper.

Verification in the repository

The analyzer tests cover all RequiredMethod values, combined requirements, declaration checks without callers, operation requirements, live analysis, and reflection/delegate compatibility. Run them on both supported test frameworks from the solution root:

dotnet test Artisan.Orm.Analyzers.Tests/Artisan.Orm.Analyzers.Tests.csproj -f net8.0
dotnet test Artisan.Orm.Analyzers.Tests/Artisan.Orm.Analyzers.Tests.csproj -f net10.0

eng/Verify-AnalyzerPackage.ps1 additionally checks the packaged analyzer with separate consumer projects, including expected successful and failed builds and runtime AllMain validation with analyzers disabled. These analyzer/package checks do not need SQL Server. See development and verification for SDK requirements and script options.

See also: RequiredMethod reference, Mappers, Inline mappers, and Auto-mapping.

Clone this wiki locally