Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Support complex type parameters on data sources #509

Merged
merged 5 commits into from
Jan 7, 2025
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
- Added a `color` prop to `c-datetime-picker`.
- Added experimental client-side support for System.Text.Json's PreserveReferences reference handling option in server responses. This does not require changes to your JSON settings in Program.cs - instead, it is activated by setting `refResponse` on the `DataSourceParameters` for a request (i.e. the `$params` object on a ViewModel or ListViewModel). This option can significantly reduce response sizes in cases where the same object occurs many times in a response.
- `useBindToQueryString`/`bindToQueryString` supports primitive collections from metadata without needing to specify explicit parsing logic
- Data Sources now support complex type (object, and arrays of objects) parameters
- Object and array data source parameters can now be passed as JSON, allowing for significantly reduced URL size for parameters like collections of numbers.

## Fixes

- `c-select` `open-on-clear` prop once again functions as expected.
- `c-select` now closes when changing focus to other elements on the page
- Multi-line strings now emit correctly into generated metadata (e.g. a multi-line description for a property)
- Validation attributes on data source parameters are enforced correctly

# 5.2.1

Expand Down
4 changes: 2 additions & 2 deletions docs/modeling/model-components/data-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,12 @@ list.$load(1);

## Standard Parameters

All methods on `IDataSource<T>` take a parameter that contains all the client-specified parameters for things paging, searching, sorting, and filtering information. Almost all virtual methods on `StandardDataSource` are also passed the relevant set of parameters.
All methods on `IDataSource<T>` take a parameter that contains all the client-specified parameters for things paging, searching, sorting, and filtering information. Almost all virtual methods on `StandardDataSource` are also passed the relevant set of parameters. The parameters are contained in the `IDataSourceParameters` type or one of its derivatives, `IFilterParameters` (adds filtering and search parameters) or `IListParameters` (filters + pagination). These parameters can be set on the client through the `$params` member on [ViewModels](/stacks/vue/layers/viewmodels.md#viewmodels) and [ListViewModels](/stacks/vue/layers/viewmodels.md#listviewmodels), or less commonly by passing them directly when using the [API Clients](/stacks/vue/layers/api-clients.md) directly.


## Custom Parameters

On any data source that you create, you may add additional properties annotated with `[Coalesce]` that will then be exposed as parameters to the client. These property parameters can be primitives (numeric types, strings, enums), dates (DateTime, DateTimeOffset, DateOnly, TimeOnly), and collections of the preceding types.
On any data source that you create, you may add additional properties annotated with `[Coalesce]` that will then be exposed as parameters to the client. These property parameters can be any type supported by Coalesce, including primitives, dates, [Entity Models](/modeling/model-types/entities.md), [External Types](/modeling/model-types/external-types.md), or collections of these types.

``` c#
[Coalesce]
Expand Down
5 changes: 3 additions & 2 deletions playground/Coalesce.Domain/Person.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,10 @@ public static ListResult<Person> SearchPeople(AppDbContext db, PersonCriteria cr
}

[Coalesce, DefaultDataSource]
public class WithoutCases : StandardDataSource<Person, AppDbContext>
public class WithoutCases(CrudContext<AppDbContext> context) : StandardDataSource<Person, AppDbContext>(context)
{
public WithoutCases(CrudContext<AppDbContext> context) : base(context) { }
[Coalesce]
public PersonCriteria? PersonCriteria { get; set; }

public override IQueryable<Person> GetQuery(IDataSourceParameters parameters)
//=> Db.People.Include(p => p.Company);
Expand Down
7 changes: 7 additions & 0 deletions playground/Coalesce.Web.Vue3/src/metadata.g.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions playground/Coalesce.Web.Vue3/src/models.g.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 41 additions & 3 deletions src/IntelliTect.Coalesce.CodeGeneration.Tests/CodeGenTestBase.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using IntelliTect.Coalesce.CodeGeneration.Generation;
using IntelliTect.Coalesce.CodeGeneration.Api.Generators;
using IntelliTect.Coalesce.CodeGeneration.Generation;
using IntelliTect.Coalesce.Tests.Util;
using IntelliTect.Coalesce.TypeDefinition;
using IntelliTect.Coalesce.Validation;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using Microsoft.CodeAnalysis.Text;
using System;
using System.Collections.Generic;
Expand All @@ -12,15 +15,50 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Xunit;

namespace IntelliTect.Coalesce.CodeGeneration.Tests
{
public class CodeGenTestBase
{
public static Lazy<Assembly> WebAssembly { get; } = new(GetWebAssembly);

private static Assembly GetWebAssembly()
{
var suite = new GenerationExecutor(
new() { WebProject = new() { RootNamespace = "MyProject" } },
Microsoft.Extensions.Logging.LogLevel.Information
)
.CreateRootGenerator<ApiOnlySuite>()
.WithModel(ReflectionRepositoryFactory.Symbol)
.WithOutputPath(".");

var compilation = GetCSharpCompilation(suite).Result;

using var ms = new MemoryStream();
EmitResult emitResult = compilation.Emit(ms);
Assert.True(emitResult.Success);
var assembly = Assembly.Load(ms.ToArray());
ReflectionRepository.Global.AddAssembly(assembly);
return assembly;
}

public class ApiOnlySuite : CompositeGenerator<ReflectionRepository>, IRootGenerator
{
public ApiOnlySuite(CompositeGeneratorServices services) : base(services) { }

public override IEnumerable<IGenerator> GetGenerators()
{
yield return Generator<IntelliTect.Coalesce.CodeGeneration.Api.Generators.Models>()
.WithModel(Model)
.AppendOutputPath("Models");

yield return Generator<Controllers>()
.WithModel(Model);
}
}

protected GenerationExecutor BuildExecutor()
{
return new GenerationExecutor(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
using IntelliTect.Coalesce.Api.DataSources;
using IntelliTect.Coalesce.Tests.TargetClasses.TestDbContext;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationParts;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.Json.Serialization;
using System.Text.Json;
using System.Threading.Tasks;
using Xunit;
using IntelliTect.Coalesce.Utilities;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using IntelliTect.Coalesce.Api.Controllers;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;

namespace IntelliTect.Coalesce.CodeGeneration.Tests
{
[ServiceFilter(typeof(IApiActionFilter))]
public class TestController : Controller
{
[HttpGet]
public object Test(IDataSource<Person> dataSource)
{
// Return the parameters back so they can be inspected in our test
if (dataSource is ParameterTestsSource s) return new
{
s.PersonCriterion,
s.PersonCriteriaArray,
s.PersonCriteriaICollection,
s.PersonCriteriaList,
s.IntArray,
s.IntList,
s.IntICollection,
s.Bytes
};
return new object();
}
}

public class DataSourceModelBinderTests
{
[Fact]
public async Task Binding_ExternalTypeParameter_DeserializesFromJson()
{
HttpClient client = GetClient();
var res = await client.GetStringAsync("""/test/test?dataSource=ParameterTestsSource&dataSource.PersonCriterion={"name":"bob","Gender":1,"Date":"2024-01-03T08:20:00-07:00","PersonIds":[1,2,3,4,5],"subCriteria":[{"Name": "Grace"}]}""");

Assert.Equal(
"""{"personCriterion":{"personIds":[1,2,3,4,5],"name":"bob","subCriteria":[{"name":"Grace","gender":0,"date":"0001-01-01T00:00:00+00:00"}],"gender":1,"date":"2024-01-03T08:20:00-07:00"}}""",
res);
}

[Theory]
[InlineData(nameof(ParameterTestsSource.PersonCriteriaArray))]
[InlineData(nameof(ParameterTestsSource.PersonCriteriaICollection))]
[InlineData(nameof(ParameterTestsSource.PersonCriteriaList))]
public async Task Binding_ExternalTypeCollection_DeserializesFromJson(string collectionName)
{
HttpClient client = GetClient();
var res = await client.GetStringAsync($$"""/test/test?dataSource=ParameterTestsSource&dataSource.{{collectionName}}=[{"name":"bob","Gender":1,"Date":"2024-01-03T08:20:00-07:00","PersonIds":[1,2,3,4,5],"subCriteria":[{"Name": "Grace"}]}]""");

Assert.Equal(
$$"""{"{{collectionName.ToCamelCase()}}":[{"personIds":[1,2,3,4,5],"name":"bob","subCriteria":[{"name":"Grace","gender":0,"date":"0001-01-01T00:00:00+00:00"}],"gender":1,"date":"2024-01-03T08:20:00-07:00"}]}""",
res);
}

[Theory]
[InlineData(nameof(ParameterTestsSource.IntArray))]
[InlineData(nameof(ParameterTestsSource.IntICollection))]
[InlineData(nameof(ParameterTestsSource.IntList))]
public async Task Binding_PrimitiveCollection_DeserializesFromJson(string collectionName)
{
HttpClient client = GetClient();
var res = await client.GetStringAsync($$"""/test/test?dataSource=ParameterTestsSource&dataSource.{{collectionName}}=[1,2,3,4]""");

Assert.Equal(
$$"""{"{{collectionName.ToCamelCase()}}":[1,2,3,4]}""",
res);
}

[Theory]
[InlineData(nameof(ParameterTestsSource.IntArray))]
[InlineData(nameof(ParameterTestsSource.IntICollection))]
[InlineData(nameof(ParameterTestsSource.IntList))]
public async Task Binding_PrimitiveCollection_BindsConventionally(string collectionName)
{
HttpClient client = GetClient();
var res = await client.GetStringAsync($$"""/test/test?dataSource=ParameterTestsSource&dataSource.{{collectionName}}=1&dataSource.{{collectionName}}=2&dataSource.{{collectionName}}=3""");

Assert.Equal(
$$"""{"{{collectionName.ToCamelCase()}}":[1,2,3]}""",
res);
}

[Fact]
public async Task Binding_Bytes_BindsFromBase64()
{
HttpClient client = GetClient();
var res = await client.GetStringAsync($$"""/test/test?dataSource=ParameterTestsSource&dataSource.bytes=SGVsbG8gV29ybGQ%3D""");

Assert.Equal(
$$"""{"bytes":"SGVsbG8gV29ybGQ="}""",
res);
}

[Fact]
public async Task Validation_ExternalTypeParameter_ValidatesMembers()
{
HttpClient client = GetClient();
var res = await client.GetAsync("""/test/test?dataSource=ParameterTestsSource&dataSource.PersonCriterion={"PersonIds":[1,2,3,4,5,6]}""");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, res.StatusCode);
Assert.Equal(
"""{"wasSuccessful":false,"message":"dataSource.PersonCriterion.PersonIds: The field PersonIds must be a string or collection type with a minimum length of '2' and maximum length of '5'."}""",
await res.Content.ReadAsStringAsync());
}

[Fact]
public async Task Validation_TopLevelParameter_ValidatesDirectly()
{
HttpClient client = GetClient();
var res = await client.GetAsync("""/test/test?dataSource=ParameterTestsSource&dataSource.IntArray=[1,2,3,4,5,6]""");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, res.StatusCode);
Assert.Equal(
"""{"wasSuccessful":false,"message":"dataSource.IntArray: The field IntArray must be a string or collection type with a minimum length of '2' and maximum length of '5'."}""",
await res.Content.ReadAsStringAsync());
}

[Fact]
public async Task Security_UsesSecurityFromDtos()
{
HttpClient client = GetClient();
var res = await client.GetStringAsync("""/test/test?dataSource=ParameterTestsSource&dataSource.PersonCriterion={"adminOnly":"bob"}""");

// Not in admin role, so `adminOnly` isn't received
Assert.Equal("""{"personCriterion":{"gender":0,"date":"0001-01-01T00:00:00+00:00"}}""", res);
}

private static HttpClient GetClient() => GetClient<TestController>();

private static HttpClient GetClient<T>()
{
// Ensure the code gen output assembly is built and loaded.
// We need the generated DTOs for these tests.
CodeGenTestBase.WebAssembly.Value.ToString();

var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddDbContext<AppDbContext>(db => db.UseInMemoryDatabase(Guid.NewGuid().ToString()));
builder.Services.AddCoalesce<AppDbContext>();

builder.Services.AddMvc()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
})
.ConfigureApplicationPartManager(apm => apm.ApplicationParts.Add(new TypesPart(typeof(T))));

var app = builder.Build();
app.MapDefaultControllerRoute();
app.Start();
return app.GetTestClient();
}

private class TypesPart(params Type[] types) : ApplicationPart, IApplicationPartTypeProvider
{
public override string Name => string.Join(", ", Types.Select(t => t.FullName));

public IEnumerable<TypeInfo> Types { get; } = types.Select(t => t.GetTypeInfo());
}
}
}
30 changes: 1 addition & 29 deletions src/IntelliTect.Coalesce.Swashbuckle.Tests/OpenApiFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,7 @@ public class OpenApiFixture

public OpenApiFixture()
{
var suite = new GenerationExecutor(
new() { WebProject = new() { RootNamespace = "MyProject" } },
Microsoft.Extensions.Logging.LogLevel.Information
)
.CreateRootGenerator<ApiOnlySuite>()
.WithModel(ReflectionRepositoryFactory.Symbol)
.WithOutputPath(".");

var compilation = CodeGenTestBase.GetCSharpCompilation(suite).Result;

using var ms = new MemoryStream();
EmitResult emitResult = compilation.Emit(ms);
Assert.True(emitResult.Success);
Assembly assembly = Assembly.Load(ms.ToArray());
Assembly assembly = CodeGenTestBase.WebAssembly.Value;

var hostBuilder = new HostBuilder()
.ConfigureWebHost(webHost =>
Expand Down Expand Up @@ -90,20 +77,5 @@ public async Task<OpenApiDocument> GetDocumentAsync()
Assert.Empty(diagnostic.Warnings);
return openApiDocument;
}

public class ApiOnlySuite : CompositeGenerator<ReflectionRepository>, IRootGenerator
{
public ApiOnlySuite(CompositeGeneratorServices services) : base(services) { }

public override IEnumerable<IGenerator> GetGenerators()
{
yield return Generator<IntelliTect.Coalesce.CodeGeneration.Api.Generators.Models>()
.WithModel(Model)
.AppendOutputPath("Models");

yield return Generator<Controllers>()
.WithModel(Model);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@
<ItemGroup>
<ProjectReference Include="..\IntelliTect.Coalesce\IntelliTect.Coalesce.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>


<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />

<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
Expand Down
Loading
Loading