Skip to content

Commit

Permalink
Add example that produces SQL without Entity Framework Core (#1361)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkoelman authored Oct 27, 2023
1 parent 9830302 commit 83f5a67
Show file tree
Hide file tree
Showing 131 changed files with 14,001 additions and 64 deletions.
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<CSharpGuidelinesAnalyzerVersion>3.8.*</CSharpGuidelinesAnalyzerVersion>
<CodeAnalysisVersion>4.7.*</CodeAnalysisVersion>
<CoverletVersion>6.0.*</CoverletVersion>
<DapperVersion>2.1.*</DapperVersion>
<DateOnlyTimeOnlyVersion>2.1.*</DateOnlyTimeOnlyVersion>
<EntityFrameworkCoreVersion>7.0.*</EntityFrameworkCoreVersion>
<FluentAssertionsVersion>6.12.*</FluentAssertionsVersion>
Expand Down
30 changes: 30 additions & 0 deletions JsonApiDotNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample",
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnnotationTests", "test\AnnotationTests\AnnotationTests.csproj", "{24B0C12F-38CD-4245-8785-87BEFAD55B00}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperExample", "src\Examples\DapperExample\DapperExample.csproj", "{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperTests", "test\DapperTests\DapperTests.csproj", "{80E322F5-5F5D-4670-A30F-02D33C2C7900}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -282,6 +286,30 @@ Global
{24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x64.Build.0 = Release|Any CPU
{24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.ActiveCfg = Release|Any CPU
{24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.Build.0 = Release|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.ActiveCfg = Debug|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.Build.0 = Debug|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.ActiveCfg = Debug|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.Build.0 = Debug|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.Build.0 = Release|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.ActiveCfg = Release|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.Build.0 = Release|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.ActiveCfg = Release|Any CPU
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.Build.0 = Release|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.ActiveCfg = Debug|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.Build.0 = Debug|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.ActiveCfg = Debug|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.Build.0 = Debug|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.Build.0 = Release|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.ActiveCfg = Release|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.Build.0 = Release|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.ActiveCfg = Release|Any CPU
{80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -305,6 +333,8 @@ Global
{83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF}
{60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD}
{24B0C12F-38CD-4245-8785-87BEFAD55B00} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F}
{C1774117-5073-4DF8-B5BE-BF7B538BD1C2} = {026FBC6C-AF76-4568-9B87-EC73457899FD}
{80E322F5-5F5D-4670-A30F-02D33C2C7900} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4}
Expand Down
4 changes: 4 additions & 0 deletions JsonApiDotNetCore.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -659,8 +659,12 @@ $left$ = $right$;</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=linebreaks/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Microservices/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=navigations/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Npgsql/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=parallelize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=parameterless/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=playlists/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pomelo/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Postgre/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Rewriter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Startups/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=subdirectory/@EntryIndexedValue">True</s:Boolean>
Expand Down
14 changes: 10 additions & 4 deletions docs/getting-started/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,18 @@ Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonA

You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options or analyze query strings.
And most resource definition callbacks are handled.
That's because the built-in resource service translates all JSON:API aspects of the request into a database-agnostic data structure called `QueryLayer`.
That's because the built-in resource service translates all JSON:API query aspects of the request into a database-agnostic data structure called `QueryLayer`.
Now the hard part for you becomes reading that data structure and producing data access calls from that.
If your data store provides a LINQ provider, you may reuse most of [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs),
If your data store provides a LINQ provider, you can probably reuse [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs),
which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/expression-trees/).
Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll likely need to prevent that from happening. There's an example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs).
We use a similar approach for accessing [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/674889e037334e3f376550178ce12d0842d7560c/src/JsonApiDotNetCore.MongoDb/Queries/Internal/QueryableBuilding/MongoQueryableBuilder.cs).
Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll need to
[prevent that from happening](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs).

The example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs) compiles and executes
the LINQ query against an in-memory list of resources.
For [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/master/src/JsonApiDotNetCore.MongoDb/Repositories/MongoRepository.cs), we use the MongoDB LINQ provider.
If there's no LINQ provider available, the example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/DapperExample/Repositories/DapperRepository.cs) may be of help,
which produces SQL and uses [Dapper](https://github.com/DapperLib/Dapper) for data access.

> [!TIP]
> [ExpressionTreeVisualizer](https://github.com/zspitz/ExpressionTreeVisualizer) is very helpful in trying to debug LINQ expression trees!
Expand Down
61 changes: 61 additions & 0 deletions src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System.Data.Common;
using JsonApiDotNetCore;
using JsonApiDotNetCore.AtomicOperations;

namespace DapperExample.AtomicOperations;

/// <summary>
/// Represents an ADO.NET transaction in a JSON:API atomic:operations request.
/// </summary>
internal sealed class AmbientTransaction : IOperationsTransaction
{
private readonly AmbientTransactionFactory _owner;

public DbTransaction Current { get; }

/// <inheritdoc />
public string TransactionId { get; }

public AmbientTransaction(AmbientTransactionFactory owner, DbTransaction current, Guid transactionId)
{
ArgumentGuard.NotNull(owner);
ArgumentGuard.NotNull(current);

_owner = owner;
Current = current;
TransactionId = transactionId.ToString();
}

/// <inheritdoc />
public Task BeforeProcessOperationAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}

/// <inheritdoc />
public Task AfterProcessOperationAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}

/// <inheritdoc />
public Task CommitAsync(CancellationToken cancellationToken)
{
return Current.CommitAsync(cancellationToken);
}

/// <inheritdoc />
public async ValueTask DisposeAsync()
{
DbConnection? connection = Current.Connection;

await Current.DisposeAsync();

if (connection != null)
{
await connection.DisposeAsync();
}

_owner.Detach(this);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Data.Common;
using DapperExample.TranslationToSql.DataModel;
using JsonApiDotNetCore;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;

namespace DapperExample.AtomicOperations;

/// <summary>
/// Provides transaction support for JSON:API atomic:operation requests using ADO.NET.
/// </summary>
public sealed class AmbientTransactionFactory : IOperationsTransactionFactory
{
private readonly IJsonApiOptions _options;
private readonly IDataModelService _dataModelService;

internal AmbientTransaction? AmbientTransaction { get; private set; }

public AmbientTransactionFactory(IJsonApiOptions options, IDataModelService dataModelService)
{
ArgumentGuard.NotNull(options);
ArgumentGuard.NotNull(dataModelService);

_options = options;
_dataModelService = dataModelService;
}

internal async Task<AmbientTransaction> BeginTransactionAsync(CancellationToken cancellationToken)
{
var instance = (IOperationsTransactionFactory)this;

IOperationsTransaction transaction = await instance.BeginTransactionAsync(cancellationToken);
return (AmbientTransaction)transaction;
}

async Task<IOperationsTransaction> IOperationsTransactionFactory.BeginTransactionAsync(CancellationToken cancellationToken)
{
if (AmbientTransaction != null)
{
throw new InvalidOperationException("Cannot start transaction because another transaction is already active.");
}

DbConnection dbConnection = _dataModelService.CreateConnection();

try
{
await dbConnection.OpenAsync(cancellationToken);

DbTransaction transaction = _options.TransactionIsolationLevel != null
? await dbConnection.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken)
: await dbConnection.BeginTransactionAsync(cancellationToken);

var transactionId = Guid.NewGuid();
AmbientTransaction = new AmbientTransaction(this, transaction, transactionId);

return AmbientTransaction;
}
catch (DbException)
{
await dbConnection.DisposeAsync();
throw;
}
}

internal void Detach(AmbientTransaction ambientTransaction)
{
ArgumentGuard.NotNull(ambientTransaction);

if (AmbientTransaction != null && AmbientTransaction == ambientTransaction)
{
AmbientTransaction = null;
}
else
{
throw new InvalidOperationException("Failed to detach ambient transaction.");
}
}
}
16 changes: 16 additions & 0 deletions src/Examples/DapperExample/Controllers/OperationsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;

namespace DapperExample.Controllers;

public sealed class OperationsController : JsonApiOperationsController
{
public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor,
IJsonApiRequest request, ITargetedFields targetedFields)
: base(options, resourceGraph, loggerFactory, processor, request, targetedFields)
{
}
}
19 changes: 19 additions & 0 deletions src/Examples/DapperExample/DapperExample.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>$(TargetFrameworkName)</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\JsonApiDotNetCore\JsonApiDotNetCore.csproj" />
<ProjectReference Include="..\..\JsonApiDotNetCore.SourceGenerators\JsonApiDotNetCore.SourceGenerators.csproj" OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Dapper" Version="$(DapperVersion)" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="$(EntityFrameworkCoreVersion)" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(EntityFrameworkCoreVersion)" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="$(NpgsqlVersion)" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="$(EntityFrameworkCoreVersion)" />
</ItemGroup>
</Project>
81 changes: 81 additions & 0 deletions src/Examples/DapperExample/Data/AppDbContext.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
using DapperExample.Models;
using JetBrains.Annotations;
using JsonApiDotNetCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;

// @formatter:wrap_chained_method_calls chop_always

namespace DapperExample.Data;

[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class AppDbContext : DbContext
{
private readonly IConfiguration _configuration;

public DbSet<TodoItem> TodoItems => Set<TodoItem>();
public DbSet<Person> People => Set<Person>();
public DbSet<LoginAccount> LoginAccounts => Set<LoginAccount>();
public DbSet<AccountRecovery> AccountRecoveries => Set<AccountRecovery>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<RgbColor> RgbColors => Set<RgbColor>();

public AppDbContext(DbContextOptions<AppDbContext> options, IConfiguration configuration)
: base(options)
{
ArgumentGuard.NotNull(configuration);

_configuration = configuration;
}

protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<Person>()
.HasMany(person => person.AssignedTodoItems)
.WithOne(todoItem => todoItem.Assignee);

builder.Entity<Person>()
.HasMany(person => person.OwnedTodoItems)
.WithOne(todoItem => todoItem.Owner);

builder.Entity<Person>()
.HasOne(person => person.Account)
.WithOne(loginAccount => loginAccount.Person)
.HasForeignKey<Person>("AccountId");

builder.Entity<LoginAccount>()
.HasOne(loginAccount => loginAccount.Recovery)
.WithOne(accountRecovery => accountRecovery.Account)
.HasForeignKey<LoginAccount>("RecoveryId");

builder.Entity<Tag>()
.HasOne(tag => tag.Color)
.WithOne(rgbColor => rgbColor.Tag)
.HasForeignKey<RgbColor>("TagId");

var databaseProvider = _configuration.GetValue<DatabaseProvider>("DatabaseProvider");

if (databaseProvider != DatabaseProvider.SqlServer)
{
// In this example project, all cascades happen in the database, but SQL Server doesn't support that very well.
AdjustDeleteBehaviorForJsonApi(builder);
}
}

private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder)
{
foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes()
.SelectMany(entityType => entityType.GetForeignKeys()))
{
if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull)
{
foreignKey.DeleteBehavior = DeleteBehavior.SetNull;
}

if (foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade)
{
foreignKey.DeleteBehavior = DeleteBehavior.Cascade;
}
}
}
}
35 changes: 35 additions & 0 deletions src/Examples/DapperExample/Data/RotatingList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace DapperExample.Data;

internal abstract class RotatingList
{
public static RotatingList<T> Create<T>(int count, Func<int, T> createElement)
{
List<T> elements = new();

for (int index = 0; index < count; index++)
{
T element = createElement(index);
elements.Add(element);
}

return new RotatingList<T>(elements);
}
}

internal sealed class RotatingList<T>
{
private int _index = -1;

public IList<T> Elements { get; }

public RotatingList(IList<T> elements)
{
Elements = elements;
}

public T GetNext()
{
_index++;
return Elements[_index % Elements.Count];
}
}
Loading

0 comments on commit 83f5a67

Please sign in to comment.