Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Drivers/DbDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ public virtual ISet<string> GetUsingDirectivesForQueries()
.AddRangeIf([
"Dapper"
], Options.UseDapper)
.AddRangeIf([
"System.Runtime.CompilerServices"
], Options.UseAsyncEnumerable && Options.WithCancellationToken)
.AddRangeExcludeNulls(GetUsingDirectivesForColumnMappings());
}

Expand Down
7 changes: 7 additions & 0 deletions Drivers/Generators/CancellationGen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ public string TrailingArgument()
return enabled ? $", {TokenName}" : string.Empty;
}

// Suffix for an IAsyncEnumerable consumed with await foreach; Dapper's QueryUnbufferedAsync has no
// CommandDefinition overload, so the token can only cancel the enumeration, not the in-flight command.
public string EnumerationSuffix()
{
return enabled ? $".WithCancellation({TokenName})" : string.Empty;
}

// Wraps flat Dapper call arguments in a CommandDefinition carrying the token; pass-through when disabled.
public string WrapDapperArgs(string flatArgs)
{
Expand Down
100 changes: 98 additions & 2 deletions Drivers/Generators/ManyDeclareGen.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ public class ManyDeclareGen(DbDriver dbDriver)

public MemberDeclarationSyntax Generate(string queryTextConstant, string argInterface, string returnInterface, Query query)
{
var parametersStr = CommonGen.GetMethodParameterList(argInterface, query.Params, dbDriver.Cancellation.MethodParameter());
var returnType = $"Task<List<{returnInterface}>>";
var asyncEnumerable = dbDriver.Options.UseAsyncEnumerable;
var cancellationParam = dbDriver.Cancellation.MethodParameter();
if (asyncEnumerable && cancellationParam != string.Empty)
cancellationParam = $"[EnumeratorCancellation] {cancellationParam}";
var parametersStr = CommonGen.GetMethodParameterList(argInterface, query.Params, cancellationParam);
var returnType = asyncEnumerable ? $"IAsyncEnumerable<{returnInterface}>" : $"Task<List<{returnInterface}>>";
return ParseMemberDeclaration($$"""
public async {{returnType}} {{query.Name.ToMethodName(dbDriver.Options.WithAsyncSuffix)}}({{parametersStr}})
{
Expand All @@ -32,6 +36,9 @@ private string GetMethodBody(string queryTextConstant, string returnInterface, Q
var sqlVar = sqlTextTransform != string.Empty ? Variable.TransformedSql.AsVarName() : queryTextConstant;
var transactionProperty = Variable.Transaction.AsPropertyName();

if (dbDriver.Options.UseAsyncEnumerable)
return GetAsyncEnumerableBody(sqlTextTransform, dapperParams, sqlVar, returnInterface, query, useDapper);

var noTxBody = useDapper ? GetDapperNoTxBody(sqlVar, returnInterface, query) : GetDriverNoTxBody(sqlVar, returnInterface, query);
var withTxBody = useDapper ? GetDapperWithTxBody(sqlVar, returnInterface, query) : GetDriverWithTxBody(sqlVar, returnInterface, query);

Expand All @@ -46,6 +53,95 @@ private string GetMethodBody(string queryTextConstant, string returnInterface, Q
""";
}

// Streaming variant: rows are yielded as they are read, no intermediate List is built.
private string GetAsyncEnumerableBody(string sqlTextTransform, string dapperParams, string sqlVar,
string returnInterface, Query query, bool useDapper)
{
var transactionProperty = Variable.Transaction.AsPropertyName();
var noTxBody = useDapper ? GetDapperStreamNoTxBody(sqlVar, returnInterface, query) : GetDriverStreamNoTxBody(sqlVar, returnInterface, query);
var withTxBody = useDapper ? GetDapperStreamWithTxBody(sqlVar, returnInterface, query) : GetDriverStreamWithTxBody(sqlVar, returnInterface, query);
return $$"""
{{sqlTextTransform}}
{{dapperParams}}
if (this.{{transactionProperty}} == null)
{
{{noTxBody}}
yield break;
}
{{withTxBody}}
""";
}

private string GetDapperStreamNoTxBody(string sqlVar, string returnInterface, Query query)
{
var connectionCommands = dbDriver.EstablishConnection(query);
var dapperArgs = CommonGen.GetDapperArgs(query);
var returnType = dbDriver.AddNullableSuffixIfNeeded(returnInterface, true);
return connectionCommands.GetConnectionOrDataSource.WrapBlock(
$"""
await foreach (var row in {Variable.Connection.AsVarName()}.QueryUnbufferedAsync<{returnType}>({sqlVar}{dapperArgs}){dbDriver.Cancellation.EnumerationSuffix()})
yield return row;
"""
);
}

private string GetDapperStreamWithTxBody(string sqlVar, string returnInterface, Query query)
{
var transactionProperty = Variable.Transaction.AsPropertyName();
var returnType = dbDriver.AddNullableSuffixIfNeeded(returnInterface, true);
var queryParams = query.Params.Count == 0 ? "null" : Variable.QueryParams.AsVarName();
return $$"""
{{dbDriver.TransactionConnectionNullExcetionThrow}}
await foreach (var row in this.{{transactionProperty}}.Connection.QueryUnbufferedAsync<{{returnType}}>({{sqlVar}}, {{queryParams}}, transaction: this.{{transactionProperty}}){{dbDriver.Cancellation.EnumerationSuffix()}})
yield return row;
""";
}

private string GetDriverStreamNoTxBody(string sqlVar, string returnInterface, Query query)
{
var connectionCommands = dbDriver.EstablishConnection(query);
var dataclassInit = CommonGen.InstantiateDataclass([.. query.Columns], returnInterface, query);
var sqlCommands = dbDriver.CreateSqlCommand(sqlVar);
var commandBlock = sqlCommands.CommandCreation.WrapBlock(
$$"""
{{sqlCommands.SetCommandText.AppendSemicolonUnlessEmpty()}}
{{dbDriver.AddParametersToCommand(query)}}
{{sqlCommands.PrepareCommand.AppendSemicolonUnlessEmpty()}}
using ({{CommonGen.InitDataReader(dbDriver.Cancellation.Argument())}})
{
while ({{CommonGen.AwaitReaderRow(dbDriver.Cancellation.Argument())}})
yield return {{dataclassInit}};
}
"""
);
return connectionCommands.GetConnectionOrDataSource.WrapBlock(
$$"""
{{connectionCommands.ConnectionOpen.AppendSemicolonUnlessEmpty()}}
{{commandBlock}}
"""
);
}

private string GetDriverStreamWithTxBody(string sqlVar, string returnInterface, Query query)
{
var transactionProperty = Variable.Transaction.AsPropertyName();
var commandVar = Variable.Command.AsVarName();
return $$"""
{{dbDriver.TransactionConnectionNullExcetionThrow}}
using (var {{commandVar}} = this.{{transactionProperty}}.Connection.CreateCommand())
{
{{commandVar}}.CommandText = {{sqlVar}};
{{commandVar}}.Transaction = this.{{transactionProperty}};
{{dbDriver.AddParametersToCommand(query)}}
using ({{CommonGen.InitDataReader(dbDriver.Cancellation.Argument())}})
{
while ({{CommonGen.AwaitReaderRow(dbDriver.Cancellation.Argument())}})
yield return {{CommonGen.InstantiateDataclass([.. query.Columns], returnInterface, query)}};
}
}
""";
}

private string GetDapperNoTxBody(string sqlVar, string returnInterface, Query query)
{
var connectionCommands = dbDriver.EstablishConnection(query);
Expand Down
9 changes: 9 additions & 0 deletions PluginOptions/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,14 @@ public Options(GenerateRequest generateRequest)
WithCancellationToken = rawOptions.WithCancellationToken;
UseCentralPackageManagement = rawOptions.UseCentralPackageManagement;
UseProperSingularization = rawOptions.UseProperSingularization;
UseAsyncEnumerable = rawOptions.UseAsyncEnumerable;

if (rawOptions.DebugRequest && generateRequest.Settings.Codegen.Wasm is not null)
throw new ArgumentException("Debug request mode cannot be used with WASM plugin");
DebugRequest = rawOptions.DebugRequest;

if (UseAsyncEnumerable && DotnetFramework == DotnetFramework.DotnetStandard20)
throw new ArgumentException("useAsyncEnumerable is not supported with targetFramework netstandard2.0");
}

public DriverName DriverName { get; }
Expand Down Expand Up @@ -64,6 +68,11 @@ public Options(GenerateRequest generateRequest)
/// </summary>
public bool WithCancellationToken { get; }

/// <summary>
/// When true, :many queries return IAsyncEnumerable&lt;Row&gt; (streamed with yield return) instead of Task&lt;List&lt;Row&gt;&gt;.
/// </summary>
public bool UseAsyncEnumerable { get; }

/// <summary>
/// When true, model names are singularized with English grammar rules (policies -> Policy,
/// statuses -> Status). Defaults to the legacy behavior of trimming trailing 's' characters,
Expand Down
3 changes: 3 additions & 0 deletions PluginOptions/RawOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public record RawOptions
[JsonPropertyName("withCancellationToken")]
public bool WithCancellationToken { get; init; }

[JsonPropertyName("useAsyncEnumerable")]
public bool UseAsyncEnumerable { get; init; }

[JsonPropertyName("useProperSingularization")]
public bool UseProperSingularization { get; init; }
}
Expand Down
1 change: 1 addition & 0 deletions docs/03_Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
| useProperSingularization | default: `false`<br/>values: `false`,`true` | Yes | When true, model names are singularized using English grammar rules (`policies` -> `Policy`, `statuses` -> `Status`). Defaults to the legacy behavior of trimming trailing `s` characters, which mangles some names (`policies` -> `Policie`). Opt-in because enabling it renames existing generated classes. |
| withAsyncSuffix | default: `true`<br/>values: `false`,`true` | Yes | When true, async methods will have the "Async" suffix appended to their names (e.g., `GetAuthorAsync`). When false, async methods will not have the suffix (e.g., `GetAuthor`). |
| withCancellationToken | default: `false`<br/>values: `false`,`true` | Yes | When true, every generated method takes an optional trailing `CancellationToken cancellationToken = default`, threaded into every async DB call so in-flight queries can be cancelled. See [Cancellation](#cancellation). When false (default), generated output is unchanged. |
| useAsyncEnumerable | default: `false`<br/>values: `false`,`true` | Yes | When true, `:many` queries return `IAsyncEnumerable<Row>` streamed row by row (`yield return`; Dapper uses `QueryUnbufferedAsync`) instead of `Task<List<Row>>`. Not supported with `netstandard2.0`. With `withCancellationToken` the token is marked `[EnumeratorCancellation]`; with Dapper it only cancels the enumeration (Dapper's unbuffered query takes no cancellation token). When false (default), generated output is unchanged. |

### Target framework
`targetFramework` sets the framework of the generated `.csproj`, and is independent of the .NET version the plugin itself runs on.
Expand Down
34 changes: 34 additions & 0 deletions docs/09_Examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -555,3 +555,37 @@ overrides:
```

</details>

<details>
<summary>SqliteAsyncEnumerable</summary>

## Engine `sqlite`: [SqliteAsyncEnumerableExample](examples/SqliteAsyncEnumerableExample)
### [Schema](examples/config/sqlite/authors/schema.sql) | [Queries](examples/config/sqlite/authors/query.sql) | [End2End Test](end2end/EndToEndTests/SqliteAsyncEnumerableTester.cs)
### Config
```yaml
useDapper: false
useAsyncEnumerable: true
withCancellationToken: true
targetFramework: net10.0
generateCsproj: true
namespaceName: SqliteAsyncEnumerableExampleGen
```

</details>

<details>
<summary>NpgsqlDapperAsyncEnumerable</summary>

## Engine `postgresql`: [NpgsqlDapperAsyncEnumerableExample](examples/NpgsqlDapperAsyncEnumerableExample)
### [Schema](examples/config/postgresql/authors/schema.sql) | [Queries](examples/config/postgresql/authors/query.sql) | [End2End Test](end2end/EndToEndTests/NpgsqlDapperAsyncEnumerableTester.cs)
### Config
```yaml
useDapper: true
useAsyncEnumerable: true
withCancellationToken: true
targetFramework: net10.0
generateCsproj: true
namespaceName: NpgsqlDapperAsyncEnumerableExampleGen
```

</details>
2 changes: 2 additions & 0 deletions end2end/EndToEndTests/EndToEndTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@
<ProjectReference Include="..\EndToEndCommon\EndToEndCommon.csproj" />
<ProjectReference Include="..\..\examples\MySqlConnectorDapperExample\MySqlConnectorDapperExample.csproj" />
<ProjectReference Include="..\..\examples\MySqlConnectorExample\MySqlConnectorExample.csproj" />
<ProjectReference Include="..\..\examples\NpgsqlDapperAsyncEnumerableExample\NpgsqlDapperAsyncEnumerableExample.csproj" />
<ProjectReference Include="..\..\examples\NpgsqlDapperExample\NpgsqlDapperExample.csproj" />
<ProjectReference Include="..\..\examples\NpgsqlExample\NpgsqlExample.csproj" />
<ProjectReference Include="..\..\examples\SqliteDapperExample\SqliteDapperExample.csproj" />
<ProjectReference Include="..\..\examples\SqliteExample\SqliteExample.csproj" />
<ProjectReference Include="..\..\examples\SqliteAsyncEnumerableExample\SqliteAsyncEnumerableExample.csproj" />
</ItemGroup>
</Project>
87 changes: 87 additions & 0 deletions end2end/EndToEndTests/NpgsqlDapperAsyncEnumerableTester.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using Npgsql;
using NpgsqlDapperAsyncEnumerableExampleGen;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace EndToEndTests;

[TestFixture]
public class NpgsqlDapperAsyncEnumerableTester
{
private QuerySql QuerySql { get; } = new(
Environment.GetEnvironmentVariable(EndToEndCommon.PostgresConnectionStringEnv)!);

[TearDown]
public async Task EmptyTestsTable()
{
await QuerySql.TruncateAuthorsAsync();
}

private static async Task SeedAuthors(QuerySql querySql)
{
await querySql.CreateAuthorAsync(new QuerySql.CreateAuthorArgs { Id = 1, Name = "Bravo", Bio = "b" });
await querySql.CreateAuthorAsync(new QuerySql.CreateAuthorArgs { Id = 2, Name = "Alpha", Bio = null });
await querySql.CreateAuthorAsync(new QuerySql.CreateAuthorArgs { Id = 3, Name = "Charlie", Bio = "c" });
}

private static async Task<List<T>> ToListAsync<T>(IAsyncEnumerable<T> source)
{
var result = new List<T>();
await foreach (var item in source)
result.Add(item);
return result;
}

private static QuerySql.ListAuthorsArgs AllAuthors => new() { Offset = 0, Limit = 10 };

[Test]
public async Task TestListAuthorsStreamsRows()
{
await SeedAuthors(QuerySql);

var actual = await ToListAsync(QuerySql.ListAuthorsAsync(AllAuthors));

Assert.That(actual.ConvertAll(a => a.Name), Is.EqualTo(new[] { "Alpha", "Bravo", "Charlie" }));
Assert.That(actual[0].Bio, Is.Null);
Assert.That(actual[1].Bio, Is.EqualTo("b"));
}

[Test]
public async Task TestListAuthorsEmpty()
{
Assert.That(await ToListAsync(QuerySql.ListAuthorsAsync(AllAuthors)), Is.Empty);
}

[Test]
public async Task TestListAuthorsInTransaction()
{
using var connection = new NpgsqlConnection(
Environment.GetEnvironmentVariable(EndToEndCommon.PostgresConnectionStringEnv)!);
await connection.OpenAsync();
using var transaction = await connection.BeginTransactionAsync();
var transactionalQuerySql = QuerySql.WithTransaction(transaction);
await SeedAuthors(transactionalQuerySql);

var actual = await ToListAsync(transactionalQuerySql.ListAuthorsAsync(AllAuthors));

Assert.That(actual, Has.Count.EqualTo(3));
await transaction.RollbackAsync();
Assert.That(await ToListAsync(QuerySql.ListAuthorsAsync(AllAuthors)), Is.Empty);
}

[Test]
public async Task TestListAuthorsHonoursCancellation()
{
await SeedAuthors(QuerySql);
using var cts = new CancellationTokenSource();
cts.Cancel();

Assert.CatchAsync<OperationCanceledException>(async () =>
{
await foreach (var _ in QuerySql.ListAuthorsAsync(AllAuthors, cts.Token)) { }
});
}
}
Loading
Loading