/SqlMarshal

Generates data access using stored procedures

Primary LanguageC#OtherNOASSERTION

SqlMarshal

NuGet

NativeAOT-friendly mini-ORM which care about nullability checks.

This project generates typed functions for accessing custom SQL and stored procedures. Goal of this project to be AOT friendly. Database connection can be used from the DbContext of DbConnection objects.

How to use

Add SqlMarshal Nuget package using

dotnet add package SqlMarshal

Then create your data context class inside your project.

public class PersonInformation
{
    public int PersonId { get; set; }

    public string? PersonName { get; set; }
}

public partial class DataContext
{
    private DbConnection connection;

    public DataContext(DbConnection connection) => this.connection = connection;

    [SqlMarshal("persons_list")]
    public partial IList<PersonInformation> GetPersons();

    [SqlMarshal]
    public partial IList<PersonInformation> GetPersonFromSql([RawSql]string sql, int id);
}

You can have simplified repository generator. Detection happens by name, and I plan to automatically support more naming conventions.

public class Product
{
    public int Id { get; set; }

    public string? Code { get; set; }
    public string? ProductTitle { get; set; }
    public string? Description { get; set; }
}

public partial class DataContext
{
    private DbConnection connection;

    public DataContext(DbConnection connection) => this.connection = connection;

    public partial IList<Product> FetchAll();

    public partial Product? FetchById(int id);

    public partial int Count();

    public partial void DeleteAll();

    public partial void DeleteById(int id);

    public partial void Insert(int id, string code, string productTitle, string description);

    public partial void Update(int id, string code, string productTitle, string description);
}

You can override column names and table names of the entity using attributes from System.ComponentModel.DataAnnotations.Schema namespace.

Temporary limitations or plans

Current version of library has several limitations which not because it cannot be implemented reasonably, but because there was lack of time to think through all options. So I list all current limitations, so any user would be aware about them. I think about these options like about plan to implement them.

  • No ability to specify length of input/output string parameters, or type varchar/nvarchar.
  • Simplified ORM for just mapping object properties from DbDataReader
  • Ability to specify fields in code in the order different then returned from SQL.
  • Automatic generation of DbSet inside DbContext, since when working with stored procedures this is most likely burden.
  • FormattableString support not implemented.

Examples

Managing connections

Generated code does not interfere with the connection opening and closing. It is responsibility of developer to properly wrap code in the transaction and open connections.

public partial class DataContext
{
    private DbConnection connection;

    public DataContext(DbConnection connection) => this.connection = connection;

    [SqlMarshal("persons_list")]
    public partial IList<Item> GetResult();
}
...

var connection = new SqlConnection("......");
connection.Open();
try
{
    var dataContext = new DataContext(connection);
    var items = dataContext.GetResult();
    // Do work on items here.
}
finally
{
    connection.Close();
}

Same rule applies to code which uses DbContext.

Additional samples

In the repository located sample application which I use for testing, but they can be helpful as usage examples.

Performance

Now I only hope (because no measurements yet) that performance would be on par with Dapper or better. At least right now generated code is visible and can be reason about.

DbConnection examples

Stored procedures which returns resultset

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("persons_list")]
    public partial IList<Item> GetResult();
}

This code translated to EXEC persons_list. When generated code retrieve data reader it starts iterating properties in the Item class in the same order as they are declared and read values from the row. Order different then declaration order not supported now.

Adding parameters

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("persons_search")]
    public partial IList<Item> GetResults(string name, string city);
}

This code translated to EXEC persons_search @name, @city. Generated code do not use named parameters.

Executing SQL

If stored procedure seems to be overkill, then you can add string parameter with attribute [RawSql] and SQL passed to the function would be executed.

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal]
    public partial IList<PersonInformation> GetResultFromSql([RawSql]string sql, int maxId);
}

Output parameters

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("persons_search_ex")]
    public partial IList<Item> GetResults2(string name, string city, out int totalCount);
}

This code translated to EXEC persons_search @name, @city, @total_count OUTPUT. Value returned in the @total_count parameter, saved to the int totalCount variable.

Procedure which returns single row

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("persons_by_id")]
    public partial Item GetResults(int personId);
}

This code translated to EXEC persons_by_id @person_id. From mapped result set taken just single item, first one.

Scalar results

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("total_orders")]
    public partial int GetTotal(int clientId);
}

This code translated to EXEC total_orders @client_id. Instead of executing over data reader, ExecuteScalar called.

Sequence results

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("total_orders")]
    public partial IList<string> GetStrings(int clientId);
}

This code translated to EXEC total_orders @client_id. First columns of the returning result set mapped to the sequence. If you want return more then one columns, and do not want create classes, you can use tuples

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("total_orders")]
    public partial IList<(string, int)> GetPairs(int clientId);
}

Join transactions

Not implemented.

Without results

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("process_data")]
    public partial void ProcessData(int year);
}

This code translated to EXEC process_data @year. No data was returned, ExecuteNonQuery called.

DbContext examples

Stored procedures which returns resultset

public partial class DataContext
{
    private CustomDbContext dbContext;

    [SqlMarshal("persons_list")]
    public partial IList<Item> GetResult();
}

This code translated to EXEC persons_list. Underlying assumption that in the custom context there definition of the DbSet<Item>.

Adding parameters

public partial class DataContext
{
    private CustomDbContext dbContext;

    [SqlMarshal("persons_search")]
    public partial IList<Item> GetResults(string name, string city);
}

This code translated to EXEC persons_search @name, @city. Generated code do not use named parameters.

Output parameters

public partial class DataContext
{
    private CustomDbContext dbContext;

    [SqlMarshal("persons_search_ex")]
    public partial IList<Item> GetResults2(string name, string city, out int totalCount);
}

This code translated to EXEC persons_search @name, @city, @total_count OUTPUT. Value returned in the @total_count parameter, saved to the int totalCount variable.

Procedure which returns single row

public partial class DataContext
{
    private CustomDbContext dbContext;

    [SqlMarshal("persons_by_id")]
    public partial Item GetResults(int personId);
}

This code translated to EXEC persons_by_id @person_id. From mapped result set taken just single item, first one.

Scalar results

public partial class DataContext
{
    private CustomDbContext dbContext;

    [SqlMarshal("total_orders")]
    public partial int GetTotal(int clientId);
}

This code translated to EXEC total_orders @client_id. Instead of executing over data reader, ExecuteScalar called.

Without results

public partial class DataContext
{
    private CustomDbContext dbContext;

    [SqlMarshal("process_data")]
    public partial void ProcessData(int year);
}

This code translated to EXEC process_data @year. No data was returned, ExecuteNonQuery called.

Join transactions

Generated code automatically join any transaction opened using DbContext.Database.BeginTransaction().

Alternative options

Async methods

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("total_orders")]
    public partial Task<int> GetTotal(int clientId);
}

or

public partial class DataContext
{
    private CustomDbContext dbContext;

    [SqlMarshal("persons_search")]
    public partial Task<IList<Item>> GetResults(string name, string city);
}

Nullable parameters

The codegen honor nullable parameters. If you specify parameter as non-nullable, it will not work with NULL values in the database, if you specify that null allowed, it properly convert NULL to null values in C#.

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("get_error_message")]
    public partial string? GetErrorMessage(int? clientId);
}

Bidirectional parameters

If you have parameters which act as input and output parameters, you can specify them as ref values. Codegen read values after SQL was executed.

public partial class DataContext
{
    private DbConnection connection;

    [SqlMarshal("get_error_message")]
    public partial string? GetErrorMessage(ref int? clientId);
}

Pass connection as parameter

Instead of having DbConnection as a field of the class, it can be passed as parameter, and even be placed in the extension method.

public static partial class DataContext
{
    [SqlMarshal("persons_list")]
    public static partial IList<Item> GetResult(DbConnection connection);

    [SqlMarshal("persons_by_id")]
    public static partial Item GetResults(DbConnection connection, int personId);
}

Pass transaction as parameter

If you want finegrained control over transactions, if you pass DbTransaction as parameter, generated code will set it to DbCommand or EF context will join that transaction using Database.UseTransaction.

public static partial class DataContext
{
    [SqlMarshal("persons_list")]
    public static partial IList<Item> GetResult(DbTransaction tran);

    [SqlMarshal("persons_by_id")]
    public static partial Item GetResults(DbTransaction tran, int personId);
}

CancellationToken support

You can add CancellationToken inside your code and it would be propagated inside ADO.NET calls. You can use that with DbContext too.

public static partial class DataContext
{
    [SqlMarshal("total_orders")]
    public partial Task<int> GetTotal(DbConnection connection, int clientId, CancellationToken cancellationToken);
}