A compile-time data accessor generator. You declare partial methods with attributes and
(optionally) 2-way SQL, and a Roslyn Source Generator emits the full ADO.NET implementation β
connection handling, command setup, parameter binding, and row mapping β as plain, readable C#.
- Build-time code generation β no reflection, no IL emit, no runtime proxy. What runs is code you can read.
- 2-way SQL β SQL files (or inline SQL) that are valid SQL as-is, with comment-based directives for binding and dynamic conditions.
- Near hand-written performance β 0.95β1.13x of hand-written ADO.NET, with identical allocations; 1.4β2.2x faster than Dapper (see Benchmarks).
- Native AOT compatible β the generated code is fully static.
- Compile-time diagnostics β misuse (missing SQL file, invalid return type, unmappable entity, conflicting attributes, broken 2-way SQL, ...) fails the build with a precise
SDAdiagnostic instead of a runtime surprise.
Install Usa.Smart.Data.Accessor.
Define a model and a data accessor. The accessor is a partial class marked [DataAccessor];
each data method is a partial method marked with an execution-kind attribute
([Execute] / [ExecuteScalar] / [Query] / [QueryFirst] / [ExecuteReader]).
public sealed class DataEntity
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public int Type { get; set; }
}using Smart.Data.Accessor.Attributes;
[DataAccessor]
public partial class ExampleAccessor
{
[Execute]
public partial int Create();
[Query]
public partial IReadOnlyList<DataEntity> QueryDataList();
[Query]
public partial IReadOnlyList<DataEntity> QueryByType(int type);
}Add the SQL files under a Sql folder, named {ClassName}.{MethodName}.sql
(the package wires **/Sql/*.sql as generator inputs automatically; the folder name can be
changed with the SmartDataAccessor_SqlFolder MSBuild property):
Accessor/
ExampleAccessor.cs
Sql/
ExampleAccessor.Create.sql
ExampleAccessor.QueryDataList.sql
ExampleAccessor.QueryByType.sql
A method whose name ends with Async also accepts the file name with that suffix omitted, so
QueryDataListAsync() resolves ExampleAccessor.QueryDataList.sql. The exact
{ClassName}.{MethodName}.sql wins when both files exist, and a sync/async method pair can share
a single file.
-- ExampleAccessor.QueryByType.sql
SELECT Id, Name, Type FROM Data
WHERE Type = /*@ type */1
ORDER BY IdUse it. Without a DbConnection parameter, the accessor takes an IDbProvider
(from Usa.Smart.Data) in its constructor and
manages open/close per call:
using Smart.Data;
var accessor = new ExampleAccessor(
new DelegateDbProvider(() => new SqliteConnection(connectionString)));
accessor.Create();
var list = accessor.QueryByType(1);That is all β no configuration classes, no runtime setup. Everything is resolved at build time.
The SQL files are valid SQL as-is (they run unchanged in your SQL tools); directives live in comments:
| Directive | Meaning |
|---|---|
/*@ name */dummy |
Bind the method parameter name. The literal after the comment is a placeholder for tooling and is replaced at build time |
/*% if (cond) { */ ... /*% } */ |
Dynamic block β the condition is real C# over the method parameters, evaluated at runtime with zero string parsing |
/*@ ids */(...) |
An IEnumerable<T> parameter expands to an IN list at runtime (empty lists become (NULL)) |
/*# expr */dummy |
Raw substitution β the value of the C# expression becomes part of the SQL text itself (a sort column, ASC / DESC, ...), not a bound parameter |
/*+ hint */ |
Query hint β emitted into the SQL text verbatim (plain /* */ and -- comments are stripped) |
/*!using Ns */, /*!helper Type */ |
Add using / using static to the generated file so the if conditions can call helpers |
SELECT * FROM Data
/*% if (name != null) { */
WHERE Name LIKE /*@ name */'A%'
/*% } */
ORDER BY IdStatic SQL (no dynamic blocks) is embedded as a single string literal β no StringBuilder, no runtime work.
/*@ name */ always produces a bound parameter, and a parameter can never stand for an identifier β
a column name, a sort direction, a table name. That is what /*# expr */ is for: the value of the
expression is written into the SQL text itself.
[Query]
public partial IReadOnlyList<DataEntity> QueryOrderBy(string sort);SELECT Id, Name, Type FROM Data ORDER BY /*# sort */IdQueryOrderBy("Name DESC") runs ... ORDER BY Name DESC. As with /*@ */, the literal after the
comment (Id) is only a placeholder that keeps the file valid SQL, and is replaced at build time.
-
The expression is plain C# over the method parameters β
/*# sort */,/*# args.Sort */,/*# Resolve(sort) */β and is appended withStringBuilder.Append, so any type works (string,int, an enum, ...) andnullappends nothing. -
The value is not escaped or quoted β this is string concatenation into SQL. Never substitute unvalidated input. Take a closed set (an enum) and map it to the column name in a
/*!helper */class, so the SQL text can only ever be one of your own strings:public static class SortHelper { public static string Resolve(SortKey key) => key switch { SortKey.Name => "Name", SortKey.Type => "Type DESC", _ => "Id" }; }
/*!helper MyApp.SortHelper */ SELECT Id, Name, Type FROM Data ORDER BY /*# Resolve(sort) */Id
-
A raw marker makes the statement dynamic, so it is built through
StringBuilderPoolat runtime instead of being emitted as a single string literal. -
Only
/*@ */names are matched against the method parameters; the text of/*# */is emitted as-is, so a typo surfaces as a C# compile error on the generated line.
Ordinary comments never reach cmd.CommandText β a /* ... */ block or a -- line comment collapses
into a single space in the emitted SQL. Optimizer hints are written as comments too, so they get their
own marker: /*+ ... */ is passed through verbatim.
SELECT /*+ INDEX(Data IX_Data_Type) */ Id, Name, Type FROM Data
WHERE Type = /*@ type */1- The hint stays exactly where it is written, and the spacing on each side follows the source β a
database that expects the hint immediately after
SELECTgets it there. - The body is normalized to
/*+ body */, so/*+INDEX(t i)*/is emitted as/*+ INDEX(t i) */. - The text is never interpreted; it goes to the database as-is (Oracle / MySQL-style comment hints β
SQL Server's
OPTION (...)is plain SQL and needs no marker). - A hint does not make the statement dynamic: SQL with hints but no dynamic blocks is still emitted as a single string literal.
Short queries can skip the file and put the 2-way SQL directly on the method (same pipeline and directives; C# 11 raw string literals work well for multi-line SQL). SQL parse errors point at the exact position inside the literal.
[Query]
[Sql("SELECT * FROM Data WHERE Type >= /*@ minType */0 ORDER BY Id")]
public partial IReadOnlyList<DataEntity> QueryByTypeInline(int minType);Every data method combines two orthogonal choices:
- Execution kind (required) β how the command runs:
[Execute](ExecuteNonQuery),[ExecuteScalar],[Query](rows),[QueryFirst](single row or null),[ExecuteReader](raw reader). - Command source (optional) β where the SQL comes from: SQL file (default),
[Sql("...")]inline,[DirectSql](runtime SQL string parameter),[Procedure("name")](stored procedure), or a QueryBuilder attribute ([Insert]etc.).
Combinations compose naturally ([Query] + [Procedure], [ExecuteReader] + [DirectSql], ...).
The execution kind is never implied β omitting it is a compile-time error (SDA0108).
Supported return shapes include int / void, scalars, List<T> / IList<T> / IReadOnlyList<T>,
IEnumerable<T> (streaming iterator), T? (single row), DbDataReader, and the async forms
(Task<...> / ValueTask<...> / IAsyncEnumerable<T>).
Rows map to plain classes or records by case-insensitive column-name matching, resolved once per query (not per row):
- Columns are matched to public settable/init properties (or record primary-constructor parameters).
[Name("COL")]overrides the name;[Ignore]excludes a member. ([Name]on the entity class is the builder table name, see Query builders.) [Naming(NamingConvention.SnakeCaseLower)](method/class/assembly scope, like[BindPrefix]) converts the default names instead of annotating every property βUserIdmatchesuser_idwithout a[Name]. An explicit[Name]always wins.- Subset selects just work β properties without a matching column are left untouched (property initializers survive). Extra columns are ignored. Dynamic column sets (UNION etc.) are fine.
records,init-only andrequiredmembers are fully supported.- DB NULL maps to
nullfor nullable members anddefaultfor non-nullable ones. [NotNullColumn]skips the per-columnIsDBNullcheck for NOT NULL columns (hot-path opt-in).[TypeHandler(typeof(Converter))]maps custom representations via static, AOT-friendly converters:
public sealed class DateTimeToTicksConverter : IValueConverter<long, DateTime>
{
public static DateTime FromDb(long value) => new(value, DateTimeKind.Utc);
public static long ToDb(DateTime value) => value.Ticks;
}
public sealed class EventEntity
{
public long Id { get; set; }
[TypeHandler(typeof(DateTimeToTicksConverter))]
public DateTime OccurredAt { get; set; }
}CRUD without writing SQL β the builder attributes generate the statement from the entity shape
([Key], [Name], [DatabaseManaged], [Ignore]):
[Insert(typeof(DataEntity), Table = "Data")]
[Execute]
public partial int Insert(DataEntity entity);
[Select(typeof(DataEntity), Table = "Data")]
[Query]
public partial IReadOnlyList<DataEntity> SelectAll();
[Count(typeof(DataEntity), Table = "Data")]
[ExecuteScalar]
public partial long CountAll();The table name is resolved as Table = on the method β [Name] on the entity class β the entity
type name, so a class-level [Name] avoids repeating Table = on every method:
[Name("Data")]
public sealed class DataEntity { ... }
[Insert(typeof(DataEntity))]
[Execute]
public partial int Insert(DataEntity entity); // INSERT INTO "Data" ...Standard (ANSI) builders ([Insert] / [Update] / [Delete] / [Count] / [Select] /
[SelectSingle] / [Truncate]) ship in the core package; [Limit] / [Offset] parameters give
paging with the proper dialect per provider. [Naming(...)] converts the default table name
(the entity type name) and the default column names to snake_case etc. (Table =, a class-level
[Name] and a property [Name] still win). Provider packages add dialect features:
| Package | Attributes | Extras |
|---|---|---|
Usa.Smart.Data.Accessor.Builders.SqlServer |
SqlInsert, ... , SqlMerge |
OUTPUT clause, MERGE upsert |
Usa.Smart.Data.Accessor.Builders.Postgres |
PgInsert, ... , PgUpsert |
RETURNING clause, ON CONFLICT upsert |
Usa.Smart.Data.Accessor.Builders.MySql |
MySqlInsert, ... , MySqlUpsert, MySqlInsertIgnore, MySqlReplace |
ON DUPLICATE KEY UPDATE, INSERT IGNORE, REPLACE INTO |
Writing a builder generator for another provider is supported and documented
(docs/generator-guide.md, Japanese: generator-guide.ja.md).
[Procedure("usp_Calc")]
[ExecuteScalar]
public partial int Calc(DbConnection con, CalcArgs args); // scalar return = procedure RETURN value
[Procedure("usp_Conv")]
[Execute]
public partial void Conv(DbConnection con, ConvArgs args);Parameters map by name; out / ref parameters (sync) or [Direction(Output/InputOutput)]
properties on a POCO argument (sync and async) receive the output values.
// SQL passed at runtime (explicit opt-in; injection safety is the caller's responsibility)
[DirectSql]
[Execute]
public partial int ExecuteDirect(string sql, [Direction(ParameterDirection.Output)] out int rows);
// Raw reader; CommandBehavior can be opted in (the caller controls the read order,
// so SequentialAccess is safe here for large BLOB/TEXT streaming)
[ExecuteReader]
[ReaderBehavior(CommandBehavior.SequentialAccess)]
public partial DbDataReader QueryReader();[ExecuteReader] returns a wrapper that disposes the command (and, for provider-owned
connections, the connection) together with the reader β a single using on the caller side.
[DirectSql] replaces the whole statement. To substitute a single fragment β a sort column, a table
name β into an otherwise normal 2-way SQL statement, use the /*# expr */ marker instead
(Raw substitution under 2-way SQL).
Two patterns, chosen per method by the signature:
- Pattern A β the method takes a
DbConnection(orDbTransaction); you own the connection. - Pattern B β no connection parameter; the accessor gets an
IDbProvider(orIDbProviderSelector+[Provider("name")]for multi-database) via its constructor and opens/closes per call.
With Microsoft.Extensions.DependencyInjection, declare a static partial extension method marked
[DataAccessorRegistration]. The generator fills in the implementation: every accessor of the
compilation is registered as a singleton whose constructor dependencies (IDbProvider /
IDbProviderSelector and [Inject] services) are resolved from the provider.
public static partial class ServiceCollectionExtensions
{
[DataAccessorRegistration]
public static partial IServiceCollection AddDataAccessors(this IServiceCollection services);
}builder.Services.AddSingleton<IDbProvider>(
new DelegateDbProvider(() => new SqliteConnection(connectionString)));
builder.Services.AddDataAccessors(); // generated: AddSingleton<T>(static p => new T(p.GetRequiredService<IDbProvider>()))
app.MapGet("/data", (ExampleAccessor accessor) => accessor.QueryDataList());Namespace = "MyApp.Data"limits the method to the accessors of that namespace and its sub-namespaces; several attributes union- An accessor is registered under its first implemented interface, or under the concrete type when it has none
- The declaring project needs
Microsoft.Extensions.DependencyInjection.Abstractions. A library declares the method itself (public) and the application calls it - The generated code is static (
newplusGetRequiredService<T>()), so it stays on the Native AOT path
The runtime alternative
Usa.Smart.Data.Accessor.Extensions.DependencyInjection
offers AddDataAccessors(), which registers the accessors recorded by the generated module
initializers. When the accessors live in a separate assembly (a data-layer library), pass that
assembly so its module initializers run before registration β otherwise the lazy assembly load can
leave the registry empty and AddDataAccessors() silently registers nothing:
builder.Services.AddDataAccessors(typeof(MyAccessor).Assembly);Usa.Smart.Data.Accessor.Resolver
provides the same for Usa.Smart.Resolver
(config.UseDataAccessors()), including keyed multi-source setups.
| Attribute | Purpose |
|---|---|
[MethodName("Alias")] |
SQL-file name alias for same-name overloads |
[CommandTimeout(30)] / [Timeout(30)] |
cmd.CommandTimeout |
[DbType(...)], [DbType<TEnum>(...)], [AnsiString], [SqlSize] |
Parameter type/size qualifiers (incl. provider-specific enum types) |
[BindPrefix('@')] |
Override the parameter marker per method/class/assembly |
[Naming(NamingConvention.SnakeCaseLower)] |
Default-name conversion (snake_case / lower / upper) per method/class/assembly when [Name] is absent |
[Inject] |
Inject a service into the accessor, usable from SQL if conditions |
[DataAccessorRegistration] |
Marks a static partial IServiceCollection extension whose body the generator fills with the accessor registrations (Namespace narrows the set) |
[TypeMap], [AccessorProfile], [ExecuteConfig] |
Class/profile-scoped type mapping defaults |
The generated code is static (no reflection); publishing with PublishAot=true is verified,
including the DI integrations.
Mock-connection measurements (mapping layer only, BenchmarkDotNet, .NET 10 / x64), 100 rows, ratio vs hand-written ADO.NET baseline:
| Scenario | Generated vs hand-written | vs Dapper | Allocations |
|---|---|---|---|
| 1 column | 0.95β0.98x (faster) | ~2.2x faster | identical to hand-written |
| 3 columns (enum) | 0.97β0.99x | ~2.1x faster | identical |
| 10 columns (class/record) | 1.02β1.07x | ~1.4β1.5x faster | identical |
| Subset (10 props / 2 cols) | 1.10β1.13x | ~1.9x faster | identical |
With a real database, network/query time dominates and the differences shrink further (relative order unchanged).
| Package | Contents |
|---|---|
Usa.Smart.Data.Accessor |
Runtime + core source generator + standard builders |
Usa.Smart.Data.Accessor.Extensions.DependencyInjection |
AddDataAccessors() for Microsoft.Extensions.DependencyInjection |
Usa.Smart.Data.Accessor.Resolver |
UseDataAccessors() for Smart.Resolver |
Usa.Smart.Data.Accessor.Builders.SqlServer / .Postgres / .MySql |
Provider-specific query builders |
docs/generator-guide.mdβ how to write a third-party provider builder generator (Japanese:generator-guide.ja.md)Example.ConsoleApplication/Example.WebApplicationβ runnable samples (SQLite)
Beyond the unit/functional test suites, the following have been verified end to end:
- Real databases β the provider builder dialects run against real servers:
SQL Server (LocalDB:
MERGEupsert,OUTPUT, paging, stored procedures), PostgreSQL 17 (ON CONFLICT DO UPDATE,RETURNING, paging), MySQL-compatible MariaDB 11 (ON DUPLICATE KEY UPDATE,INSERT IGNORE,REPLACE, paging), and SQLite (samples and functional tests). - Native AOT β native publish + run verified with both
Microsoft.Data.SqliteandMicrosoft.Data.SqlClient. - Package consumption β a fresh project referencing only the produced nupkgs
(no project references) generates and executes correctly, including the transitive
.sqlauto-import targets. - Generated code compilation β the generator test harness compiles the generated code and fails on any compile error, in addition to text/diagnostic assertions.