Com.H.Data.Common
10.1.0.12
dotnet add package Com.H.Data.Common --version 10.1.0.12
NuGet\Install-Package Com.H.Data.Common -Version 10.1.0.12
<PackageReference Include="Com.H.Data.Common" Version="10.1.0.12" />
<PackageVersion Include="Com.H.Data.Common" Version="10.1.0.12" />
<PackageReference Include="Com.H.Data.Common" />
paket add Com.H.Data.Common --version 10.1.0.12
#r "nuget: Com.H.Data.Common, 10.1.0.12"
#:package Com.H.Data.Common@10.1.0.12
#addin nuget:?package=Com.H.Data.Common&version=10.1.0.12
#tool nuget:?package=Com.H.Data.Common&version=10.1.0.12
Com.H.Data.Common
Run SQL on any ADO.NET connection and get the rows back as strongly typed objects (ExecuteQuery<T>, ExecuteQueryAsync<T>) or as dynamic objects (ExecuteQuery, ExecuteQueryAsync), streamed as IEnumerable<T> or IAsyncEnumerable<T>. The methods extend DbConnection and DbCommand, and ExecuteCommand / ExecuteCommandAsync run statements that return no rows. Parameters written as {{name}} are always sent as real database parameters, never concatenated into the SQL. Their names can hold any characters, so a JSON body's keys work as they are ({{first name}}, {{e-mail}}). An error the database raises after the rows (SQL Server's THROW after a SELECT) is thrown to you when you read the rows to their end; stopping earlier (First, FirstOrDefault, Take, a foreach left with break) can lose it.
Works with every ADO.NET database — SQL Server, PostgreSQL, MySQL, SQLite, Oracle, ODBC, OleDb, and more. ODBC/OleDb databases that normally require positional ? parameters are transparently given the same friendly {{named}} parameter syntax, so you write one style of query for all providers.
Since the library executes arbitrary SQL, you can run anything your database supports: stored procedures, CTEs, window functions, user-defined functions, temp tables, dynamic SQL, and more.
For source code and documentation, kindly visit the project's github page https://github.com/H7O/Com.H.Data.Common
Supported runtimes
The package ships builds for:
| Target | Runtimes it covers |
|---|---|
net10.0 |
.NET 10 |
net9.0 |
.NET 9 |
net8.0 |
.NET 8 |
netstandard2.0 |
.NET Framework 4.6.1+ (including 4.8.1), .NET Core 2.0+, Mono 5.4+, Xamarin, Unity, UWP |
The full feature set — including IAsyncEnumerable<dynamic>, IAsyncDisposable, await foreach, typed-generic queries via ExecuteQuery<T>, nested JSON/XML parsing, and the named-parameter transform for ODBC/OleDb — is available on every target, including .NET Standard 2.0. Legacy .NET Framework 4.x consumers get the same async streaming API as .NET 10 consumers.
Parameters
Write {{name}} in the SQL wherever a value goes. Each one is sent as a real database parameter (@vxv_1_name on SQL Server, :vxv_1_name on Oracle, ? on ODBC and OleDb), so a value is never part of the SQL text.
- Values come from an anonymous object, an object's public properties, an
IDictionary<string, object>, aSystem.Text.Json.JsonElementor JSON text. A JSON object's top-level keys are the names: a string arrives asstring, a number asdouble,trueandfalseasbool, and a nested object or array as its JSON text. - Names can hold any characters except a line break and
}}, and can't end with}(the closing}}would start there), so a JSON body's keys work as they are:{{first name}},{{e-mail}},{{prénom}}. Spaces count too:{{ name }}is namedname, notname. The name you write is independent of the SQL parameter's name, which keeps only ASCII letters, digits and underscores and is made unique. You never rename an input to suit the database. - Names are matched ignoring case:
{{Email}}and{{email}}are the same value and one parameter. A name used several times is one parameter too (on ODBC and OleDb, one?per use, each bound to that value). - A missing or null value is
NULL: the query still runs, so test an optional input withIS NULL. This needs a parameters object, even an empty one (new { }): called without one (or withnull), the SQL is sent as written,{{name}}included, and fails. - Other delimiters: pass a regular expression with the groups
open_marker,paramandclose_marker, such as(?<open_marker>\[\[)(?<param>.*?)?(?<close_marker>\]\])for[[name]]. - Several sources: pass a
List<DbQueryParams>, each with its ownDataModelandQueryParamsRegex. A placeholder takes its value from the last source in the list whose pattern matches it and whose value isn't null. So a marker only one pattern matches ({qs{city}}below) reads only that source, and one several patterns match ({{first name}}) falls back to an earlier source when a later one has no value. Every source keeps its own place in the list. Sources with the exact same pattern string act as one source, though: when a later one has a name, even with a null value, the earlier ones with that pattern skip it, so its null falls back only to sources with other patterns. So give a source its own pattern when its nulls should fall back to an earlier source like it. A source listed between two that share a pattern takes a placeholder where its own pattern reads that same placeholder: with both markers, every use of its text, as its own pass would; with one marker or none, only the uses the shared pattern matches there too (as the text stands when that pattern reads it, not the uses it recovers from the text as written after a stray marker). It binds the plain spelling of its spaced placeholder ({{id}}for{{ id }}) only where its own pass still has that placeholder, or where they have a value for that spelling: not through one it takes from them under another name (id) where they have none (that use then gets what it would without this source), nor through one a stray marker pairs past. Where it reads a longer or shorter one at that spot (:x-1against:x), the earlier source's value is used. On ODBC and OleDb it takes nothing in one more case: see the ODBC & OleDb note under Supported databases.
using Com.H.Data.Common;
using System.Text.Json;
// A request body, passed as it arrives: no DTO, no renamed keys.
var body = JsonDocument.Parse("""
{ "first name": "Ann", "e-mail": "ann@example.com", "age": 31 }
""").RootElement;
await dc.ExecuteCommandAsync(@"
INSERT INTO people (first_name, email, age)
VALUES ({{first name}}, {{e-mail}}, {{age}})", body);
// The same body together with a query string.
var queryString = new Dictionary<string, object> { ["city"] = "Lisbon" };
var sources = new List<DbQueryParams>
{
new() { DataModel = body, QueryParamsRegex = @"(?<open_marker>\{\{|\{j\{)(?<param>.*?)?(?<close_marker>\}\})" },
new() { DataModel = queryString, QueryParamsRegex = @"(?<open_marker>\{\{|\{qs\{)(?<param>.*?)?(?<close_marker>\}\})" },
};
// {{first name}} comes from the body, because the query string has no value for it.
// {j{age}} reads only the body, and {qs{city}} only the query string.
await using var people = await dc.ExecuteQueryAsync(@"
SELECT * FROM people
WHERE first_name = {{first name}} AND age >= {j{age}} AND city = {qs{city}}", sources);
Filling text templates
Fill puts values into plain text (a message, a URL, a JSON document) with the same placeholders, sources and precedence as the query methods. It inserts each value as text instead of sending it as a parameter, so never use it to build SQL.
// The sources from the Parameters section: a JSON body, then a query string.
var message = "Hi {{first name}}, you are {{age}}. City: {qs{city}}".Fill(sources);
// "Hi Ann, you are 31. City: Lisbon"
- A value is inserted once: text inside a value that looks like a placeholder (
{{other}}, or another source's marker) stays as it is, so a value someone else supplied can't pull in another source's value. - A missing or null value becomes
nullReplacementString(empty by default). A placeholder that no pattern matches stays as it is. - Where the text is replaced: with an open and a close marker, the text a placeholder takes up (
{{ name }}under a pattern that allows spaces) is replaced wherever it appears, once the pattern has matched it on its own somewhere in the text as written. A stray open marker before a placeholder's only use pairs with that use's close marker, and the whole span between them is then a placeholder of its own. When no source has a value for it, another source's stray open marker can take a use of it that way too. If the span holds a placeholder another source has filled, the span usually stays as written instead, and a placeholder the pattern never matched on its own there stays unfilled. With one marker (:name), the text is replaced wherever it doesn't go on into a longer name: a letter, digit or underscore after it, or a longer placeholder the pattern matched. So:dateleaves:date_fromalone, and:date-fromtoo under a pattern that allows-in names. With neither marker, only the pattern's matches are replaced. - Names are matched ignoring case, unless you pass
caseSensitive: true:{{NAME}}is then filled only from aNAMEvalue. valueConverterturns each value into text. It is called once for each name in each source that fills it, however many times and with whichever markers the name appears. Without it,ToString()is used.
Sample 1
This sample demonstrates how to execute a simple query without parameters on a SQL Server Database.
To run this sample, you need to:
- Create a new console application
- Add NuGet package Com.H.Data.Common
- Add NuGet package Microsoft.Data.SqlClient
- Copy and paste the following code into your Program.cs file:
using Com.H.Data.Common;
using System.Data.Common;
using Microsoft.Data.SqlClient;
string conStr = @"connection string goes here";
using DbConnection dc = new SqlConnection(conStr);
// ^ note the use of DbConnection instead of SqlConnection. The extension methods
// are defined on DbConnection
// Also note the use of 'using' keyword to ensure proper disposal of the connection
using var result = dc.ExecuteQuery("select 'John' as name, '123' as phone");
// ^ returns DbQueryResult<dynamic> which implements IEnumerable<dynamic> and IDisposable
// You can also return DbQueryResult<T> where T is your data model class by using the ExecuteQuery<T> method.
// The 'using' keyword ensures proper disposal of database resources (reader and optionally connection)
// Example: using var result = dc.ExecuteQuery<YourDataModelClass>("select 'John' as name, '123' as phone");
// Also, returns DbAsyncQueryResult<dynamic> when called asynchronously via dc.ExecuteQueryAsync()
// or dc.ExecuteQueryAsync<T>()
// And for executing a command that does not return any data, you can use the ExecuteCommand()
// or ExecuteCommandAsync() methods
foreach (var item in result)
{
System.Console.WriteLine($"name = {item.name}, phone = {item.phone}");
}
Note: The returned DbQueryResult (whether
DbQueryResult<dynamic>orDbQueryResult<T>) implements theIEnumerable<T>interface andIDisposableinterface, which means you can use it anywhereIEnumerable<T>is expected and should be disposed properly using theusingkeyword.<br/> For example, you can use it in a foreach loop, or pass it to LINQ methods like Where, Select, etc.<br/> The asynchronous version returnsDbAsyncQueryResult<T>which implementsIAsyncEnumerable<T>andIAsyncDisposable.
Sample 2
This sample demonstrates how to pass parameters to your SQL query
using Com.H.Data.Common;
using System.Data.Common;
using Microsoft.Data.SqlClient;
string conStr = @"your connection string goes here";
using DbConnection dc = new SqlConnection(conStr);
// ^ note the use of 'using' keyword to ensure proper disposal of the connection
var queryParams = new { name = "Jane" };
// ^ queryParams could be an anonymous object (similar to the example above)
// or the following types:
// 1) IDictionary<string, object>
// 2) Normal object with properties that match the parameter names in the query
// 3) JSON string
// 4) System.Text.Json.JsonElement (useful when building Web APIs, allows passing
// JsonElement input directly from a web client)
// Example 1: var queryParams = new Dictionary<string, object> { { "name", "Jane" } }
// Example 2: var queryParams = new MyCustomParamClass { name = "John" }
// Example 3: var queryParams = "{\"name\":\"Jane\"}"
// Example 4: var queryParams = System.Text.Json.JsonDocument.Parse("{\"name\":\"John\"}").RootElement
using var result = dc.ExecuteQuery(@"
select * from (values
('John', '55555'),
('Jane', '44444')) as t (name, phone)
where name = {{name}}", queryParams
);
// ^ note the use of curly braces around the parameter name in the query.
// This is a special syntax that allows you to pass parameters to your query.
// The parameter name is matched to the queryParams object's property names ignoring case
// (see Parameters above).
// It also protects you from SQL injection attacks and is configurable to use other
// delimiters by passing a regular expression
// Also note the use of 'using' keyword to ensure proper disposal of database resources
// Example 1: using `[[` and `]]` instead of `{{` and `}}` dc.ExecuteQuery(@"
// select * from (values ('John', '55555'), ('Jane', '44444')) as t (name, phone)
// where name = [[name]]",
// queryParams, @"(?<open_marker>\[\[)(?<param>.*?)?(?<close_marker>\]\])" );
// Example 2: using `|` instead of `{{` and `}}` dc.ExecuteQuery(@"
// select * from (values ('John', '55555'), ('Jane', '44444')) as t (name, phone)
// where name = |name|",
// queryParams, @"(?<open_marker>\|)(?<param>.*?)?(?<close_marker>\|)" );
foreach (var item in result)
{
System.Console.WriteLine($"name = {item.name}, phone = {item.phone}");
}
Sample 3
This sample demonstrates how to return nested hierarchical data from a query.
using Com.H.Data.Common;
using System.Data.Common;
using Microsoft.Data.SqlClient;
string conStr = @"your connection string goes here";
using DbConnection dc = new SqlConnection(conStr);
// ^ note the use of 'using' keyword to ensure proper disposal of the connection
using var result = dc.ExecuteQuery(@"
SELECT
'John' as [name],
(select * from (values
('55555', 'Mobile'),
('44444', 'Work'))
as t (number, [type]) for json path) AS {type{json{phones}}}");
// ^ note the use of 'using' keyword to ensure proper disposal of database resources
foreach (var person in result)
{
Console.WriteLine($"name = {person.name}");
Console.WriteLine("--- phones ---");
foreach (var phone in person.phones)
{
System.Console.WriteLine($"{phone.type} = {phone.number}");
}
}
Microsoft SQL Server natively supports returning JSON data from a query using the FOR JSON clause.
The normal example of returning JSON data from a query would look like this:
SELECT
'John' as [name],
(select * from (values
('55555', 'Mobile'),
('44444', 'Work')) as t (number, [type]) for json path) AS phones
However, the above query returns a JSON string that you would have to parse in your application.
This library automatically takes care of that parsing process for you and returns a dynamic object that you can access using the property names in the query.
To tell the library to parse the nested JSON data, you just need to enclose the property name (that you expect to have json string) in the following syntax: {type{json{your_property_name}}}.
In our example above, we are filling the property phones with JSON string. Hence we used the syntax {type{json{phones}}} to tell the library to parse the JSON string and fill the phones property with the parsed JSON data.
Note: Another syntax for parsing XML string is
{type{xml{your_property_name}}}.
Sample 4
This sample shows when a reader is still open, and how to close it before you run another query on the same connection.
A reader stays open while its rows are being read (an enumerator you hold, or a foreach that is still running) and for a result you haven't read. On SQL Server without MARS, and on other databases that allow one open reader per connection, another query on that connection then throws There is already an open DataReader associated with this Connection which must be closed first. Reading the rows to their end closes the reader, and so does an enumerator disposed before the end (First, FirstOrDefault, Take, a foreach left with break). To close it yourself, dispose the result or call CloseReader.
using Com.H.Data.Common;
using System.Data.Common;
using Microsoft.Data.SqlClient;
string conStr = @"your connection string goes here";
using DbConnection dc = new SqlConnection(conStr);
// ^ note the use of 'using' keyword to ensure proper disposal of the connection
using var result = dc.ExecuteQuery("SELECT * FROM Users");
using var users = result.GetEnumerator();
users.MoveNext(); // the reader stays open while this enumerator is held
result.CloseReader(); // closes the reader; disposing the result does too
using var anotherResult = dc.ExecuteQuery("SELECT * FROM Orders"); // Safe! No reader is open on dc
// ^ note the use of 'using' keyword to ensure proper disposal of database resources
Errors raised after the rows you read
Only the first result set's rows are returned. Reading them to the end also moves through the rest of the batch, so an error raised after them (SQL Server's THROW or RAISERROR after a SELECT, or a later statement that fails) is thrown to you. The later result sets' rows are skipped, not read: SQLite computes only a later result set's first row, so an error raised in one of its other rows is not raised, and System.Data.Odbc reports such an error in some batches and not in others, so don't rely on it. Stopping early does not do that: an enumerator disposed before it reports the end of the rows (a foreach left with break, First or FirstOrDefault, Take, even on the last row) closes the reader. Many providers, Microsoft.Data.SqlClient, System.Data.SqlClient, Microsoft.Data.Sqlite and System.Data.OleDb among them, discard the remaining rows, the later statements' results and their errors when the reader closes, so the call looks like a success. Some read the rest of the batch when the reader closes and throw from there, so stopping early can throw: Npgsql (PostgreSQL) throws the batch's error, and System.Data.Odbc throws when a later statement or its first row fails, while an error raised in a row not yet read is reported in some batches and dropped in others.
When you need only some of the rows but must not miss such an error, read the rest too, or raise the error before the statement that returns rows:
await using var result = await dc.ExecuteQueryAsync(@"
SELECT id, name FROM Users WHERE id = {{id}};
UPDATE Users SET last_seen = GETDATE() WHERE id = {{id}};", new { id = 7 });
var rows = new List<dynamic>();
await foreach (var row in result) rows.Add(row); // reads to the end, so a failing UPDATE throws here
var user = rows.FirstOrDefault();
Sample 5
This sample demonstrates how to use the library in an ASP.NET Core Web API controller with dependency injection.
Key Benefits:
- No DTOs needed: Work directly with dynamic query results without creating DTOs (Data Transfer Objects) or entity classes. This eliminates boilerplate code and allows you to focus on your business logic rather than maintaining mapping classes.
- Externalize queries: Store your SQL queries in external files, allowing you to modify business logic and data retrieval without recompiling your application. This is especially useful for rapid iteration and production hotfixes.
First, register the DbConnection in your Program.cs:
using Microsoft.Data.SqlClient;
using System.Data.Common;
var builder = WebApplication.CreateBuilder(args);
// Register DbConnection as scoped (new connection per request)
builder.Services.AddScoped<DbConnection>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string not found");
return new SqlConnection(connectionString);
});
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Then use it in your controller:
using Com.H.Data.Common;
using Microsoft.AspNetCore.Mvc;
using System.Data.Common;
using System.Text.Json;
namespace YourApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly DbConnection _connection;
public UsersController(DbConnection connection)
{
_connection = connection;
}
// GET: api/users
[HttpGet]
public async Task<IActionResult> GetAllUsers()
{
// ExecuteQueryAsync returns DbAsyncQueryResult which implements IAsyncEnumerable
// Register it for disposal when the request completes
var result = await _connection.ExecuteQueryAsync("SELECT id, name, email FROM Users");
HttpContext.Response.RegisterForDisposeAsync(result);
// Return the result directly - ASP.NET Core will stream it as JSON
return Ok(result);
}
// GET: api/users/{id}
[HttpGet("{id}")]
public async Task<IActionResult> GetUserById(int id)
{
var queryParams = new { id };
await using var result = await _connection.ExecuteQueryAsync(
"SELECT id, name, email FROM Users WHERE id = {{id}}",
queryParams);
var user = await result.FirstOrDefaultAsync();
if (user == null)
return NotFound();
return Ok(user);
}
// POST: api/users/search
[HttpPost("search")]
public async Task<IActionResult> SearchUsers([FromBody] JsonElement searchParams)
{
// JsonElement can be passed directly as query parameters
// Client can send: { "name": "John", "minAge": 25 }
var result = await _connection.ExecuteQueryAsync(@"
SELECT id, name, email, age
FROM Users
WHERE name LIKE '%' + {{name}} + '%'
AND age >= {{minAge}}",
searchParams);
// Register for disposal when the request completes
HttpContext.Response.RegisterForDisposeAsync(result);
// Return the result directly - it will be streamed to the client
return Ok(result);
}
// POST: api/users
[HttpPost]
public async Task<IActionResult> CreateUser([FromBody] JsonElement userData)
{
// For insert/update/delete operations, use ExecuteCommandAsync
await _connection.ExecuteCommandAsync(@"
INSERT INTO Users (name, email, age)
VALUES ({{name}}, {{email}}, {{age}})",
userData);
return Ok(new { message = "User created successfully" });
}
// GET: api/users/{id}/orders
// Example with nested JSON data
[HttpGet("{id}/orders")]
public async Task<IActionResult> GetUserWithOrders(int id)
{
var queryParams = new { userId = id };
await using var result = await _connection.ExecuteQueryAsync(@"
SELECT
u.id,
u.name,
u.email,
(SELECT o.orderId, o.orderDate, o.total
FROM Orders o
WHERE o.userId = u.id
FOR JSON PATH) AS {type{json{orders}}}
FROM Users u
WHERE u.id = {{userId}}",
queryParams);
var user = await result.FirstOrDefaultAsync();
if (user == null)
return NotFound();
return Ok(user);
}
}
}
Note:
- The
DbConnectionis registered as scoped, meaning a new connection is created per HTTP request and automatically disposed when the request completes- You don't need to manually dispose the connection - ASP.NET Core's DI container handles that
- For production environments, also consider:
- Implementing proper error handling and logging
- Adding authentication and authorization
- Implementing request validation
- Using connection pooling (enabled by default in most ADO.NET providers)
Sample 6
This sample demonstrates how to externalize your SQL queries in a configuration file, allowing you to modify queries without recompiling your application.
Note: To use XML configuration files, you'll need to install the
Microsoft.Extensions.Configuration.XmlNuGet package:dotnet add package Microsoft.Extensions.Configuration.Xml
First, create an XML file named queries.xml in your project root:
<?xml version="1.0" encoding="utf-8" ?>
<settings>
<queries>
<get_all_users>
<![CDATA[
SELECT id, name, email, createdDate
FROM Users
ORDER BY createdDate DESC
]]>
</get_all_users>
<search_users>
<![CDATA[
SELECT id, name, email, age
FROM Users
WHERE name LIKE '%' + {{name}} + '%'
AND age >= {{minAge}}
ORDER BY name
]]>
</search_users>
<get_user_with_orders>
<![CDATA[
SELECT
u.id,
u.name,
u.email,
(SELECT o.orderId, o.orderDate, o.total
FROM Orders o
WHERE o.userId = u.id
FOR JSON PATH) AS {type{json{orders}}}
FROM Users u
WHERE u.id = {{userId}}
]]>
</get_user_with_orders>
</queries>
</settings>
Configure your Program.cs to load the queries file with hot-reload support:
using Microsoft.Data.SqlClient;
using System.Data.Common;
var builder = WebApplication.CreateBuilder(args);
// Load queries from external XML file with hot-reload enabled
builder.Configuration
.SetBasePath(AppContext.BaseDirectory)
.AddXmlFile("queries.xml", optional: false, reloadOnChange: true);
// Register DbConnection as scoped
builder.Services.AddScoped<DbConnection>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string not found");
return new SqlConnection(connectionString);
});
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Now use the queries in your controller:
using Com.H.Data.Common;
using Microsoft.AspNetCore.Mvc;
using System.Data.Common;
using System.Text.Json;
namespace YourApi.Controllers
{
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly DbConnection _connection;
private readonly IConfiguration _configuration;
public UsersController(DbConnection connection, IConfiguration configuration)
{
_connection = connection;
_configuration = configuration;
}
// GET: api/users
[HttpGet]
public async Task<IActionResult> GetAllUsers()
{
// Load query from configuration file
var query = _configuration["queries:get_all_users"]
?? throw new InvalidOperationException("Query 'get_all_users' not found");
var result = await _connection.ExecuteQueryAsync(query);
HttpContext.Response.RegisterForDisposeAsync(result);
return Ok(result);
}
// POST: api/users/search
[HttpPost("search")]
public async Task<IActionResult> SearchUsers([FromBody] JsonElement searchParams)
{
// Load query from configuration file
var query = _configuration["queries:search_users"]
?? throw new InvalidOperationException("Query 'search_users' not found");
var result = await _connection.ExecuteQueryAsync(query, searchParams);
HttpContext.Response.RegisterForDisposeAsync(result);
return Ok(result);
}
// GET: api/users/{id}/orders
[HttpGet("{id}/orders")]
public async Task<IActionResult> GetUserWithOrders(int id)
{
// Load query from configuration file
var query = _configuration["queries:get_user_with_orders"]
?? throw new InvalidOperationException("Query 'get_user_with_orders' not found");
var queryParams = new { userId = id };
await using var result = await _connection.ExecuteQueryAsync(query, queryParams);
var user = await result.FirstOrDefaultAsync();
if (user == null)
return NotFound();
return Ok(user);
}
}
}
Note:
- With
reloadOnChange: true, you can modify queries in thequeries.xmlfile and the changes will be picked up automatically without restarting the application- This is extremely useful for production hotfixes, query optimization, and rapid iteration
- Make sure to set the XML file's "Copy to Output Directory" property to "Copy if newer" or "Copy always" in your project settings
- XML is recommended over JSON for storing queries because XML's
CDATAtags allow you to write queries without worrying about escaping special characters (quotes, backslashes, etc.) that would be required in JSON files- You can organize queries into multiple files for better maintainability (e.g.,
users_queries.xml,orders_queries.xml,reports_queries.xml) by callingAddXmlFile()multiple times:builder.Configuration .AddXmlFile("users_queries.xml", optional: false, reloadOnChange: true) .AddXmlFile("orders_queries.xml", optional: false, reloadOnChange: true) .AddXmlFile("reports_queries.xml", optional: false, reloadOnChange: true);
Sample 7
This sample demonstrates how to dynamically load multiple configuration files based on settings, allowing you to maintain a modular configuration structure.
First, create a config folder in your project root to organize your configuration files. This keeps your project structure clean and separates queries from application code.
Then, create your appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=myserver;Database=mydb;User Id=myuser;Password=mypass;"
},
"AdditionalConfigurations": {
"Paths": [
"config/users_queries.xml",
"config/orders_queries.xml",
"config/reports_queries.xml",
"config/custom_settings.json"
]
}
}
Configure your Program.cs to dynamically load all configuration files:
using Microsoft.Data.SqlClient;
using System.Data.Common;
var builder = WebApplication.CreateBuilder(args);
// Dynamically load additional configuration files specified in appsettings.json
var additionalConfigsSection = builder.Configuration.GetSection("AdditionalConfigurations:Paths");
if (additionalConfigsSection.Exists())
{
var additionalConfigPaths = additionalConfigsSection.Get<List<string>>();
if (additionalConfigPaths?.Any() == true)
{
foreach (var configPath in additionalConfigPaths)
{
// Determine file type by extension and load accordingly
var extension = Path.GetExtension(configPath);
if (extension.Equals(".xml", StringComparison.OrdinalIgnoreCase))
{
builder.Configuration.AddXmlFile(configPath, optional: false, reloadOnChange: true);
}
else if (extension.Equals(".json", StringComparison.OrdinalIgnoreCase))
{
builder.Configuration.AddJsonFile(configPath, optional: false, reloadOnChange: true);
}
// Add more file types as needed (e.g., .ini, .yaml, etc.)
}
}
}
// Register DbConnection as scoped
builder.Services.AddScoped<DbConnection>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string not found");
return new SqlConnection(connectionString);
});
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
Note:
- This pattern allows you to maintain a master configuration file that references other configuration files
- You can add or remove query files by simply updating the
appsettings.jsonwithout changing code- Important: If you add new configuration files to the
AdditionalConfigurations:Pathslist, you'll need to restart the application (or recycle the app pool in IIS) for the changes to take effect, since configuration files are loaded during application startup (before thebuilder.Build()call)- However, once files are loaded, you can freely add new queries or modify existing queries within those files without restarting - all changes are picked up automatically thanks to
reloadOnChange: true. This means you can continuously update your queries in production without any downtime!- This is especially useful for large applications where different teams maintain different query sets
- You can mix XML and JSON files based on your needs (remember: XML with CDATA is better for SQL queries)
- Consider organizing files by feature or domain (e.g.,
config/users_queries.xml,config/inventory_queries.xml,config/analytics_queries.xml)- Don't forget to set the "Copy to Output Directory" property to "Copy if newer" or "Copy always" for all files in the
configfolder
What other databases this library supports?
This library works with any ADO.NET provider that implements DbConnection and DbCommand. While the samples above use SQL Server, you can use the same code with PostgreSQL, MySQL, SQLite, Oracle, and many others—just swap out the connection class.
Supported databases (tested and auto-configured)
The following databases are automatically recognized and require no additional configuration:
| Database | ADO.NET Provider | Works out of the box |
|---|---|---|
| SQL Server | Microsoft.Data.SqlClient, System.Data.SqlClient | ✅ |
| PostgreSQL | Npgsql | ✅ |
| MySQL / MariaDB | MySql.Data, MySqlConnector | ✅ |
| SQLite | Microsoft.Data.Sqlite | ✅ |
| Oracle | Oracle.ManagedDataAccess | ✅ |
| DB2 | IBM.Data.DB2 | ✅ |
| Firebird | FirebirdSql.Data.FirebirdClient | ✅ |
| SAP HANA | Sap.Data.Hana | ✅ |
| Snowflake | Snowflake.Data | ✅ |
| ClickHouse | ClickHouse.Client | ✅ |
| Teradata | Teradata.Client | ✅ |
| Informix | IBM.Data.Informix | ✅ |
| DuckDB | DuckDB.NET.Data | ✅ |
| Vertica | Vertica.Data | ✅ |
| SingleStore (MemSQL) | SingleStoreConnector | ✅ |
| SAP SQL Anywhere | Sap.Data.SQLAnywhere | ✅ |
| ODBC | System.Data.Odbc | ✅ |
| OleDb | System.Data.OleDb | ✅ |
PostgreSQL-compatible databases: CockroachDB, TimescaleDB, YugabyteDB, CrateDB, and other PostgreSQL wire-compatible databases work automatically through the Npgsql provider — no additional configuration needed.
ODBC & OleDb — named parameters on positional-parameter databases: ODBC and OleDb databases natively use positional
?parameters, where parameter order matters and each occurrence requires its own parameter object. This library transparently handles the conversion — you write your queries using the same friendly{{named}}parameter syntax as any other database, and the library automatically transforms them into correctly ordered positional?parameters behind the scenes. This means you get the same clean, readable, named-parameter experience across all ADO.NET providers. Placeholders take their values by the same rules as on other databases, including with several parameter sources. One difference: here only the pattern's matches are replaced, while on other databases a placeholder that has a value is also replaced by its text where the pattern doesn't match it (a use after a stray{{in a string, which the pattern pairs with it, or a use the pattern skips). A NULL placeholder is replaced only where the pattern matches it, on every database. And a source listed between two that share a pattern takes nothing here where another source with a value has a placeholder that overlaps one of this source's pattern's matches without being the same one (a stray{{paired past one,:date-xwhere this source reads:date): the earlier source's value is used. That source counts if it is listed after this one and the last source with its exact pattern string is listed no later than the last of the two. The placeholders of a source whose last source with its exact pattern string is listed after the last of the two are filled before any of the three reads the query, so they don't count; those of the other sources (one listed before this one, say) are still as written then. The overlap is the one in the query as it then stands.
Using a database not listed above?
If you're using a less common database provider that isn't in the list above, the library will still work. However, you may need to configure the parameter prefix manually.
What is a parameter prefix? When you use parameterized queries like WHERE id = {{id}}, the library converts your placeholder into a database-specific parameter (e.g., @vxv_1_id for SQL Server, :vxv_1_id for Oracle). Different databases use different prefix symbols.
The library automatically detects the correct prefix for all databases listed above. For unlisted providers, it defaults to @ (which works for most databases). If your database uses a different prefix, set the fallback:
// Only needed for unrecognized database providers that don't use '@'
Com.H.Data.Common.AdoNetExt.DefaultParameterPrefix = "<your database's parameter prefix>";
You can also check what prefix the library detected for any connection:
string prefix = Com.H.Data.Common.AdoNetExt.GetParameterPrefix(connection);
Console.WriteLine($"Using parameter prefix: {prefix}");
What other features this library has?
This small library has several other options that allow for more advanced features that might not be of much use to most, hence samples for those features have been left out in this quick how to documentation.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETStandard 2.0
- Microsoft.Bcl.AsyncInterfaces (>= 10.0.12)
- Microsoft.CSharp (>= 4.7.0)
- System.Text.Json (>= 10.0.12)
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
-
net9.0
- No dependencies.
NuGet packages (4)
Showing the top 4 NuGet packages that depend on Com.H.Data.Common:
| Package | Downloads |
|---|---|
|
Com.H.EF.Relational
Adds ExecuteQuery, ExecuteQueryAsync, ExecuteCommand, and ExecuteCommandAsync extension methods to EF Core's DbContext. A thin wrapper around Com.H.Data.Common that brings the same features to Entity Framework users. |
|
|
Com.H.Threading.Scheduler.VP.Sql
Sql server value processor for Com.H.Threading.Scheduler library |
|
|
Com.H.Extensions.Logging
Database ILogger implementation |
|
|
Com.H.Text.Template2
Connects the Com.H templating engine to any ADO.NET database via Com.H.Data.Common. Queries embedded in templates are executed with real SQL parameters rather than textual substitution. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 10.1.0.12 | 107 | 10/10/2026 |
| 10.1.0.11 | 84 | 10/9/2026 |
| 10.1.0.10 | 146 | 10/6/2026 |
| 10.1.0.9 | 314 | 7/29/2026 |
| 10.1.0.7 | 220 | 4/21/2026 |
| 10.1.0.6 | 143 | 4/21/2026 |
| 10.1.0.5 | 140 | 4/21/2026 |
| 10.1.0.4 | 238 | 4/5/2026 |
| 10.1.0.3 | 333 | 2/18/2026 |
| 10.1.0.2 | 167 | 2/17/2026 |
| 10.1.0.1 | 174 | 2/4/2026 |
| 10.1.0 | 166 | 2/4/2026 |
| 10.0.0.3 | 172 | 2/1/2026 |
| 10.0.0.2 | 210 | 12/21/2025 |
| 10.0.0.1 | 194 | 12/20/2025 |
| 10.0.0 | 386 | 11/13/2025 |
| 9.0.1.7 | 280 | 10/30/2025 |
| 9.0.1.6 | 279 | 10/28/2025 |
| 9.0.1.5 | 275 | 10/23/2025 |
| 9.0.1.4 | 281 | 10/7/2025 |
Fill fills placeholders by the rules the query methods bind them with, and every parameter source keeps its place. 1) One name under two markers of one pattern ({{id}} and {j{id}}, under a pattern that accepts both) gets its value at every marker. Only the first marker found used to be filled: the other was left as text, emptied, or filled from a set the list ranks lower. 2) Fill inserts a value once and never reads it again: placeholder text inside a value ({{other}}, or another set's marker such as {s{key}}) stays as it is. It used to be filled again, by its own set or by a set listed before it (the sets are applied last to first), or emptied by the null replacement. So a value someone else supplied could pull in another set's value (a secret, say) that the template never placed there, and a value holding an open marker could swallow the placeholder after it. 3) With several sets, the latest set in the list with a non-null value fills a placeholder, whatever order the markers are written in. 4) caseSensitive: true also replaces case-sensitively: {{NAME}} is no longer filled from a name value. 5) With a one-marker pattern (:name), a name that goes on from another name (:date_from, or :date-from under a pattern that allows "-") keeps its own value, null included, on every path, where the shorter name's pattern matches it as a longer placeholder somewhere in the text (one that skips that spot, with a lookbehind, gives the named path and Fill the shorter name's value there). Fill used to give it the shorter name's value, and so did the named-parameter path for :date-from under a pattern that allows "-", when it was null or its value came from a set listed before the shorter name's. A pattern with no open_marker and close_marker groups has Fill replace only its matches, not the bare name everywhere. 6) valueConverter is called once per name and source, not once per marker or spelling: {{id}}, {j{id}} and {{ID}} from one source make one call. 7) A null entry in a list of DbQueryParams is skipped by Fill, ReduceToUnique and the query methods. It used to throw NullReferenceException. 8) On every path, a pattern no longer reads into a placeholder that a set read before it has already replaced. A pattern that can match digits or symbols (\d+, \w+, [^\w\s]) used to throw FormatException or lose a value there, and a stray open marker that paired with a close marker past such a placeholder made the text between them one placeholder: Fill dropped that text, and the query methods bound it as one NULL. On the named-parameter path and in Fill, the placeholder whose close marker the stray marker takes is still filled where the pattern matches it on its own in the text as written; on the named-parameter path only when it has a value, as a NULL placeholder is replaced only where its pattern matches it. 9) Fill throws ArgumentException for text that contains every private-use character (U+E000 to U+F8FF) whenever it is given parameters, as the query methods do: one is needed to mark placeholders while they are resolved. Finding it takes one pass over the text. With no parameters (null) or an empty list it returns the text as it is, as before; the query methods throw for an empty list. 10) A source's values no longer move past the sources listed between it and a later source with the exact same pattern string, on any path. Such sources are read as one, in the place of the last of them, and a placeholder used to take the earlier one's value even over a source listed between them (a body source listed before a query-string source beat it when a third source shared the body's pattern). They are still read as one, a later one's value for a name deciding even when it is null, but a source listed between them that has a value for the name, and whose pattern reads that placeholder where it is written, now takes it: with both markers, every use of its text, as that source's own pass would; with one marker or none, only the uses the shared pattern matches there too, as the text stands when it reads it: a use it recovers from the text as written after a stray marker (item 8) keeps the earlier source's value, also where 10.1.0.11 gave it the source listed between. Where its pattern reads a longer or shorter placeholder at that spot (:x-1 against :x), the earlier source's value is used, as before: give the sources different pattern strings then. It binds the plain spelling of its spaced placeholder ({{id}} for {{ id }}) only where its own pass still has that placeholder, or where the sources that share the pattern have a value for that spelling. On ODBC and OleDb, where only the patterns' matches are replaced, it also takes nothing where another source with a value has a placeholder that overlaps one of its pattern's matches without being the same one (a stray {{ paired past one), if that source is listed after it and the last source with that source's exact pattern string is listed no later than the last of the two that share the pattern. 11) A pattern that allows text between a marker and the name ({{ name }} under (?<open_marker>\{\{)\s*(?<param>\w+)\s*(?<close_marker>\}\})) has a placeholder with a value replaced on every path. Fill used to leave it as written, and the query methods on named-parameter providers left it in the SQL (ODBC and OleDb already replaced it). Its plain spelling ({{name}}) is bound too, where the pattern also takes that for a placeholder of the name.