This commit is contained in:
Jens De Craecker 2024-10-10 17:13:39 +02:00
parent 5e54ffb9bf
commit dfb7faebb6
79 changed files with 2717 additions and 0 deletions

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf

512
.gitignore vendored Normal file
View File

@ -0,0 +1,512 @@
# Created by https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,dotnetcore,rider
# Edit at https://www.toptal.com/developers/gitignore?templates=visualstudio,visualstudiocode,dotnetcore,rider
### DotnetCore ###
# .NET Core build folders
bin/
obj/
# Common node modules locations
/node_modules
/wwwroot/node_modules
### Rider ###
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific
.idea/**/aws.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
# Support for Project snippet scope
.vscode/*.code-snippets
# Ignore code-workspaces
*.code-workspace
### VisualStudio ###
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
# Local History for Visual Studio Code
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
### VisualStudio Patch ###
# Additional files built by Visual Studio
# End of https://www.toptal.com/developers/gitignore/api/visualstudio,visualstudiocode,dotnetcore,rider

13
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/projectSettingsUpdater.xml
/.idea.querying.iml
/modules.xml
/contentModel.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

13
.idea/.idea.Baguette.Querying/.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,13 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.Baguette.Querying.iml
/projectSettingsUpdater.xml
/contentModel.xml
/modules.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" />
</project>

4
.idea/encodings.xml generated Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

8
.idea/indexLayout.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

45
Baguette.Querying.sln Normal file
View File

@ -0,0 +1,45 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baguette.Querying", "src\Baguette.Querying\Baguette.Querying.csproj", "{2908BE42-FE0E-44D0-9075-285818D639D2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baguette.Querying.Generator", "src\Baguette.Querying.Generator\Baguette.Querying.Generator.csproj", "{C9411E00-E0BB-4852-A09A-AB6AB792F25E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baguette.Querying.Generator.Abstractions", "src\Baguette.Querying.Generator.Abstractions\Baguette.Querying.Generator.Abstractions.csproj", "{C33795B5-7073-4F92-AE2C-71B06455EF78}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baguette.Querying.Console", "samples\Baguette.Querying.Console\Baguette.Querying.Console.csproj", "{EE47F375-D903-4DE6-8AB6-8A0BB58551AE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Baguette.Querying.EntityFrameworkCore", "src\Baguette.Querying.EntityFrameworkCore\Baguette.Querying.EntityFrameworkCore.csproj", "{5D207E64-77EE-43E7-ADED-793B8773A0C9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{AB466252-3569-4093-BE8A-E86BD46538C2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2908BE42-FE0E-44D0-9075-285818D639D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2908BE42-FE0E-44D0-9075-285818D639D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2908BE42-FE0E-44D0-9075-285818D639D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2908BE42-FE0E-44D0-9075-285818D639D2}.Release|Any CPU.Build.0 = Release|Any CPU
{C9411E00-E0BB-4852-A09A-AB6AB792F25E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9411E00-E0BB-4852-A09A-AB6AB792F25E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9411E00-E0BB-4852-A09A-AB6AB792F25E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9411E00-E0BB-4852-A09A-AB6AB792F25E}.Release|Any CPU.Build.0 = Release|Any CPU
{C33795B5-7073-4F92-AE2C-71B06455EF78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C33795B5-7073-4F92-AE2C-71B06455EF78}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C33795B5-7073-4F92-AE2C-71B06455EF78}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C33795B5-7073-4F92-AE2C-71B06455EF78}.Release|Any CPU.Build.0 = Release|Any CPU
{EE47F375-D903-4DE6-8AB6-8A0BB58551AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EE47F375-D903-4DE6-8AB6-8A0BB58551AE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EE47F375-D903-4DE6-8AB6-8A0BB58551AE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EE47F375-D903-4DE6-8AB6-8A0BB58551AE}.Release|Any CPU.Build.0 = Release|Any CPU
{5D207E64-77EE-43E7-ADED-793B8773A0C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5D207E64-77EE-43E7-ADED-793B8773A0C9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5D207E64-77EE-43E7-ADED-793B8773A0C9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5D207E64-77EE-43E7-ADED-793B8773A0C9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{EE47F375-D903-4DE6-8AB6-8A0BB58551AE} = {AB466252-3569-4093-BE8A-E86BD46538C2}
EndGlobalSection
EndGlobal

6
global.json Normal file
View File

@ -0,0 +1,6 @@
{
"sdk": {
"version": "8.0.100",
"rollForward": "latestFeature"
}
}

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Baguette.Querying.Generator.Abstractions\Baguette.Querying.Generator.Abstractions.csproj"/>
<ProjectReference Include="..\..\src\Baguette.Querying.Generator\Baguette.Querying.Generator.csproj" OutputItemType="Analyzer"/>
<ProjectReference Include="..\..\src\Baguette.Querying\Baguette.Querying.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1"/>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,148 @@
// See https://aka.ms/new-console-template for more information
using System.Runtime.InteropServices;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Baguette.Querying;
using Microsoft.Extensions.DependencyInjection;
using Test;
var services = new ServiceCollection();
services.AddQueryProfiles(typeof(Model));
services.AddAutoMapper(typeof(Model));
var provider = services.BuildServiceProvider();
var processor = provider.GetRequiredService<IQueryProcessor>();
var mapper = provider.GetRequiredService<IMapper>();
var list = new[]
{
new Model
{
FirstName = "Jens",
LastName = "De Craecker",
BirthDate = new DateOnly(1998, 6, 30),
Nested = new NestedModel
{
FirstName = "Jens",
LastName = "De Craecker",
BirthDate = new DateOnly(1998, 6, 30),
NestedInNested = new NestedNestedModel
{
Text = "Hello World!"
}
},
Nested2 = new NestedModel
{
FirstName = "Jens",
LastName = "De Craecker",
BirthDate = new DateOnly(1998, 6, 30),
NestedInNested = new NestedNestedModel
{
Text = "Hello World!"
}
}
}
};
var filters = new ModelFilters()
{
// FirstName = "Jens",
// Sorting = new[]
// {
// new SortingOptions()
// {
// SortBy = "LastName"
// }
// }
Search = "Jens"
};
var query = list.AsQueryable().ApplyFilters(processor, filters).ProjectTo<ResponseModel>(mapper.ConfigurationProvider);
var result = query.ToList();
//
// var json = JsonSerializer.Serialize(filters, typeof(ModelFilters));
//
// var filters2 = JsonSerializer.Deserialize<ModelFilters>(json);
//
// var result = list.AsQueryable().ApplyFilters(processor, filters).ToList();
//
// var pipelineProvider = provider.GetRequiredService<IQueryPipelineProvider>();
//
// var result2 = list.AsQueryable().Where(pipelineProvider.GetPredicate<ModelFilters, Model>(filters)).ToList();
return;
class MappingProfile : Profile
{
public MappingProfile()
{
CreateMap<Model, ResponseModel>();
}
}
public record ResponseModel(string FirstName, string LastName);
[GenerateFilters(IncludeSorting = true, IncludePaging = true)]
public record Model
{
[FilterMode(FilterMode.Contains)]
[Searchable]
public required string FirstName { get; init; }
[SortAlias("Surname")]
[SortAlias("")]
[Searchable]
public required string LastName { get; init; }
public required DateOnly BirthDate { get; init; }
[FilterMode(FilterMode.InList)]
public TestEnum TestEnum { get; init; }
[SortAlias("NestedAlias")]
[SortAlias("")]
[Searchable]
public required NestedModel Nested { get; init; }
public required NestedModel Nested2 { get; init; }
}
[StructLayout(LayoutKind.Auto)]
public readonly partial record struct ModelFilters { }
namespace Test
{
public enum TestEnum
{
Hello
}
[GenerateNestedFilters]
public record NestedModel
{
[FilterMode(FilterMode.Contains)]
[FilterName("HelloWorld")]
public required string FirstName { get; init; }
[Searchable]
public required string LastName { get; init; }
public required DateOnly BirthDate { get; init; }
[FilterMode(FilterMode.InList)]
public TestEnum TestEnum { get; init; }
[SortAlias("NestedNestedAlias")]
public required NestedNestedModel NestedInNested { get; init; }
}
[GenerateNestedFilters]
public record NestedNestedModel
{
[SortAlias("TextAlias")]
public required string Text { get; init; }
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Baguette.Querying\Baguette.Querying.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query;
namespace Baguette.Querying.EntityFrameworkCore;
public static class DbContextOptionsBuilderExtensions
{
public static DbContextOptionsBuilder AddQuerying(this DbContextOptionsBuilder builder, params Type[] assemblyMarkerTypes)
{
if (builder == null)
throw new ArgumentNullException(nameof(builder));
((IDbContextOptionsBuilderInfrastructure)builder).AddOrUpdateExtension(new QueryingDbContextOptionsExtension(assemblyMarkerTypes));
builder.ReplaceService<IAsyncQueryProvider, ServiceQueryProvider>();
// builder.AddInterceptors(new InjectQueryInterceptor());
return builder;
}
}

View File

@ -0,0 +1,37 @@
// using System.Linq.Expressions;
// using Baguette.Querying.Visitors;
// using Microsoft.EntityFrameworkCore.Query.Internal;
//
// namespace Baguette.Querying.EntityFrameworkCore;
//
// public class DefaultQueryInjector<TFilters, TModel> : IQueryInjector<TFilters, TModel> where TFilters : notnull where TModel : notnull
// {
// private readonly IQueryProcessor processor;
//
// public DefaultQueryInjector(IQueryProcessor processor)
// {
// this.processor = processor;
// }
//
// private static IQueryable<TModel> Empty { get; } = Enumerable.Empty<TModel>().AsQueryable();
//
// public Expression Inject(Expression expression, TFilters filters)
// {
// var query = processor.ProcessQuery(Empty, filters);
//
// var visitor = new ReplaceExpressionVisitor(Empty.Expression, expression);
//
// return visitor.Visit(query.Expression);
// }
//
// public Expression Inject(Expression expression, object filters)
// {
// if (filters is not TFilters typedFilters)
// throw new ArgumentException("Filters is not of the correct type.", nameof(filters));
//
// return Inject(expression, typedFilters);
// }
// }
//
// #pragma warning disable EF1001

View File

@ -0,0 +1,14 @@
// using System.Linq.Expressions;
//
// namespace Baguette.Querying.EntityFrameworkCore;
//
// public interface IQueryInjector
// {
// Expression Inject(Expression expression, object filters);
// }
//
// public interface IQueryInjector<in TFilters, TModel> : IQueryInjector where TFilters : notnull where TModel : notnull
// {
// Expression Inject(Expression expression, TFilters filters);
// }

View File

@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace Baguette.Querying.EntityFrameworkCore;
internal static class InfrastructureExtensions
{
public static IServiceProvider GetApplicationServiceProvider(this IInfrastructure<IServiceProvider> infrastructure)
{
var extension = infrastructure.Instance.GetRequiredService<IDbContextOptions>().FindExtension<CoreOptionsExtension>();
if (extension is null)
throw new InvalidOperationException("Core options not found.");
if (extension.ApplicationServiceProvider is null)
throw new InvalidOperationException("Application service provider not registered.");
return extension.ApplicationServiceProvider;
}
}

View File

@ -0,0 +1,43 @@
// using System.Linq.Expressions;
// using Microsoft.EntityFrameworkCore;
// using Microsoft.EntityFrameworkCore.Diagnostics;
// using Microsoft.EntityFrameworkCore.Infrastructure;
// using Microsoft.Extensions.DependencyInjection;
//
// namespace Baguette.Querying.EntityFrameworkCore;
//
// public class InjectQueryInterceptor : IQueryExpressionInterceptor
// {
// public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
// {
// if (eventData.Context is null)
// return queryExpression;
//
// var visitor = new InjectExpressionVisitor(eventData.Context);
//
// return visitor.Visit(queryExpression);
// }
//
// private class InjectExpressionVisitor : ExpressionVisitor
// {
// private readonly DbContext context;
//
// public InjectExpressionVisitor(DbContext context)
// {
// this.context = context;
// }
//
// protected override Expression VisitMethodCall(MethodCallExpression node)
// {
// if (!node.Method.IsGenericMethod || node.Method.GetGenericMethodDefinition() != QueryableProcessorExtensions.ApplyFiltersGenericMethodInfo)
// return base.VisitMethodCall(node);
//
// var injector = (IQueryInjector)context
// .GetInfrastructure()
// .GetRequiredService(typeof(IQueryInjector<,>).MakeGenericType(node.Method.GetGenericArguments()));
//
// return injector.Inject(node.Arguments[0], ((ConstantExpression)node.Arguments[1]).Value!);
// }
// }
// }

View File

@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Baguette.Querying.EntityFrameworkCore;
public static class QueryableProcessorExtensions
{
// internal static MethodInfo ApplyFiltersGenericMethodInfo { get; } = typeof(QueryableProcessorExtensions).GetMethod(nameof(ApplyFilters))!;
//
// public static async Task<PaginatedResult<TModel>> PaginateAsync<TFilters, TModel>(
// this IQueryable<TModel> baseQuery,
// TFilters filters,
// CancellationToken cancellationToken = default) where TFilters : IPageable where TModel : notnull
// {
// if (baseQuery == null)
// throw new ArgumentNullException(nameof(baseQuery));
//
// var query = baseQuery.ApplyFilters(filters);
//
// var count = !filters.Paging.ExcludeTotal ? (int?)await query.CountAsync(cancellationToken) : null;
// var results = await query.Skip(filters.Paging.Offset).Take(filters.Paging.Length).ToListAsync(cancellationToken);
//
// return new PaginatedResult<TModel>(results, count, !filters.Paging.ExcludeTotal);
// }
//
// public static IQueryable<TModel> ApplyFilters<TFilters, TModel>(this IQueryable<TModel> baseQuery, [NotParameterized] TFilters filters) where TFilters : notnull where TModel : notnull
// {
// if (baseQuery == null)
// throw new ArgumentNullException(nameof(baseQuery));
//
// if (filters == null)
// throw new ArgumentNullException(nameof(filters));
//
// return baseQuery.Provider.CreateQuery<TModel>(Expression.Call(null, ApplyFiltersMethodInfoCache<TFilters, TModel>.MethodInfo, baseQuery.Expression, Expression.Constant(filters)));
// }
//
// private static class ApplyFiltersMethodInfoCache<TFilters, TModel> where TFilters : notnull where TModel : notnull
// {
// public static MethodInfo MethodInfo { get; } = ApplyFiltersGenericMethodInfo.MakeGenericMethod(typeof(TFilters), typeof(TModel));
// }
public static async Task<PaginatedResult<TModel>> PaginateAsync<TFilters, TModel>(
this IQueryable<TModel> baseQuery,
TFilters filters,
CancellationToken cancellationToken = default) where TFilters : IPageable where TModel : notnull
{
if (baseQuery == null)
throw new ArgumentNullException(nameof(baseQuery));
if (filters == null)
throw new ArgumentNullException(nameof(filters));
var query = baseQuery.ApplyFilters(filters);
var count = !filters.Paging.ExcludeTotal ? (int?)await query.CountAsync(cancellationToken) : null;
var results = await query.Skip(filters.Paging.Offset).Take(filters.Paging.Length).ToListAsync(cancellationToken);
return new PaginatedResult<TModel>(results, count, !filters.Paging.ExcludeTotal);
}
public static IQueryable<TModel> ApplyFilters<TFilters, TModel>(this IQueryable<TModel> baseQuery, TFilters filters) where TFilters : notnull where TModel : notnull
{
if (baseQuery == null)
throw new ArgumentNullException(nameof(baseQuery));
if (filters == null)
throw new ArgumentNullException(nameof(filters));
if (baseQuery.Provider is not IInfrastructure<IServiceProvider> infrastructure)
throw new NotSupportedException("QueryProvider doesn't provide the service provider.");
var processor = infrastructure.GetService<IQueryProcessor>();
return processor.ProcessQuery(baseQuery, filters);
}
}

View File

@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
namespace Baguette.Querying.EntityFrameworkCore;
public class QueryingDbContextOptionsExtension : IDbContextOptionsExtension
{
private readonly Type[] assemblyMarkerTypes;
public QueryingDbContextOptionsExtension(Type[] assemblyMarkerTypes)
{
this.assemblyMarkerTypes = assemblyMarkerTypes;
}
public DbContextOptionsExtensionInfo Info => new QueryingDbContextOptionsExtensionInfo(this);
public void ApplyServices(IServiceCollection services)
{
services.AddQueryProfiles(assemblyMarkerTypes);
// services.AddTransient(typeof(IQueryInjector<,>), typeof(DefaultQueryInjector<,>));
}
public void Validate(IDbContextOptions options) { }
}

View File

@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Baguette.Querying.EntityFrameworkCore;
public class QueryingDbContextOptionsExtensionInfo : DbContextOptionsExtensionInfo
{
public QueryingDbContextOptionsExtensionInfo(IDbContextOptionsExtension extension) : base(extension) { }
public override bool IsDatabaseProvider { get; } = false;
public override string LogFragment { get; } = "";
public override int GetServiceProviderHashCode() => 0;
public override bool ShouldUseSameServiceProvider(DbContextOptionsExtensionInfo other) => true;
public override void PopulateDebugInfo(IDictionary<string, string> debugInfo) { }
}

View File

@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Query.Internal;
namespace Baguette.Querying.EntityFrameworkCore;
// public class ServiceQueryProvider : IAsyncQueryProvider, IInfrastructure<IServiceProvider>
// #pragma warning restore EF1001
// {
// private readonly IAsyncQueryProvider inner;
// private readonly IServiceProvider provider;
//
// #pragma warning disable EF1001
// public ServiceQueryProvider(IAsyncQueryProvider inner, IServiceProvider provider) => (this.inner, this.provider) = (inner, provider);
// #pragma warning restore EF1001
//
// IServiceProvider IInfrastructure<IServiceProvider>.Instance => provider;
//
// public IQueryable CreateQuery(Expression expression) => inner.CreateQuery(expression);
//
// public IQueryable<TElement> CreateQuery<TElement>(Expression expression) => inner.CreateQuery<TElement>(expression);
//
// public object? Execute(Expression expression) => inner.Execute(expression);
//
// public TResult Execute<TResult>(Expression expression) => inner.Execute<TResult>(expression);
//
// public TResult ExecuteAsync<TResult>(Expression expression, CancellationToken cancellationToken = default) => inner.ExecuteAsync<TResult>(expression, cancellationToken);
// }
#pragma warning disable EF1001
public class ServiceQueryProvider : EntityQueryProvider, IInfrastructure<IServiceProvider>
#pragma warning restore EF1001
{
private readonly IServiceProvider provider;
#pragma warning disable EF1001
public ServiceQueryProvider(IQueryCompiler queryCompiler, IServiceProvider provider) : base(queryCompiler)
{
this.provider = provider;
}
#pragma warning restore EF1001
IServiceProvider IInfrastructure<IServiceProvider>.Instance => provider;
}

View File

@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Baguette.Querying</RootNamespace>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,4 @@
namespace Baguette.Querying;
[AttributeUsage(AttributeTargets.Property)]
public class DisableSortingAttribute : Attribute;

View File

@ -0,0 +1,12 @@
namespace Baguette.Querying;
public enum FilterMode
{
ByConvention,
Ignore,
Equal,
InRange,
Contains,
InList,
InDateRange
}

View File

@ -0,0 +1,7 @@
namespace Baguette.Querying;
[AttributeUsage(AttributeTargets.Property)]
public class FilterModeAttribute(FilterMode mode) : Attribute
{
public FilterMode Mode { get; } = mode;
}

View File

@ -0,0 +1,7 @@
namespace Baguette.Querying;
[AttributeUsage(AttributeTargets.Property)]
public class FilterNameAttribute(string name) : Attribute
{
public string Name { get; } = name;
}

View File

@ -0,0 +1,19 @@
namespace Baguette.Querying;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class GenerateFiltersAttribute : Attribute // TODO: Add exclude profile option
{
public string? FilterName { get; init; }
public string? FilterNamespace { get; init; }
public string? ProfileName { get; init; }
public string? ProfileNamespace { get; init; }
public bool IncludeSorting { get; init; }
public bool IncludePaging { get; init; }
public string? SearchName { get; init; }
}

View File

@ -0,0 +1,9 @@
namespace Baguette.Querying;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public class GenerateNestedFiltersAttribute : Attribute
{
public string? FilterName { get; init; }
public string? FilterNamespace { get; init; }
}

View File

@ -0,0 +1,4 @@
namespace Baguette.Querying;
[AttributeUsage(AttributeTargets.Property)]
public class SearchableAttribute : Attribute;

View File

@ -0,0 +1,7 @@
namespace Baguette.Querying;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class SortAliasAttribute(string name) : Attribute
{
public string Name { get; } = name;
}

View File

@ -0,0 +1,23 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
namespace Baguette.Querying;
public static class ArgumentUtilities
{
public static T GetValue<T>(IEnumerable<KeyValuePair<string, TypedConstant>> collection, string key, T defaultValue)
{
var t = collection.Where(p => p.Key == key).Select(p => p.Value).FirstOrDefault();
return t.Value is not null ? (T)t.Value : defaultValue;
}
public static ImmutableArray<string?> GetValues(IEnumerable<KeyValuePair<string, TypedConstant>> collection, string key)
{
var t = collection.Where(p => p.Key == key).Select(p => p.Value).FirstOrDefault();
return t.IsNull
? ImmutableArray<string?>.Empty
: t.Values.Select(c => (string?)c.Value).ToImmutableArray();
}
}

View File

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>true</ImplicitUsings>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<RootNamespace>Baguette.Querying</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Bcl.HashCode" Version="1.1.1" PrivateAssets="all"/>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all"/>
<PackageReference Include="PolySharp" Version="1.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,18 @@
namespace Baguette.Querying.Configuration;
public static class AttributeArgumentConstants
{
public const string FilterName = nameof(FilterName);
public const string FilterNamespace = nameof(FilterNamespace);
public const string ProfileName = nameof(ProfileName);
public const string ProfileNamespace = nameof(ProfileNamespace);
public const string IncludeSorting = nameof(IncludeSorting);
public const string IncludePaging = nameof(IncludePaging);
public const string SearchName = nameof(SearchName);
}

View File

@ -0,0 +1,18 @@
namespace Baguette.Querying.Configuration;
public static class AttributeConstants
{
public const string GenerateFiltersAttribute = $"{NamespaceConstants.RootNamespace}.{nameof(GenerateFiltersAttribute)}";
public const string GenerateNestedFiltersAttribute = $"{NamespaceConstants.RootNamespace}.{nameof(GenerateNestedFiltersAttribute)}";
public const string FilterModeAttribute = $"{NamespaceConstants.RootNamespace}.{nameof(FilterModeAttribute)}";
public const string ExcludeSortingAttribute = $"{NamespaceConstants.RootNamespace}.{nameof(ExcludeSortingAttribute)}";
public const string SortAliasAttribute = $"{NamespaceConstants.RootNamespace}.{nameof(SortAliasAttribute)}";
public const string FilterNameAttribute = $"{NamespaceConstants.RootNamespace}.{nameof(FilterNameAttribute)}";
public const string SearchableAttribute = $"{NamespaceConstants.RootNamespace}.{nameof(SearchableAttribute)}";
}

View File

@ -0,0 +1,6 @@
namespace Baguette.Querying.Configuration;
public static class GenerationConstants
{
public const string Indentation = " ";
}

View File

@ -0,0 +1,8 @@
namespace Baguette.Querying.Configuration;
public static class NamespaceConstants
{
public const string RootNamespace = "Baguette.Querying";
public const string RangeNamespace = RootNamespace;
}

View File

@ -0,0 +1,83 @@
using System.Diagnostics.CodeAnalysis;
using Baguette.Querying.Configuration;
using Microsoft.CodeAnalysis;
namespace Baguette.Querying;
public record FilterGenerationContext : FilterGenerationContextBase
{
private readonly string? profileName;
private readonly string? profileNamespace;
private readonly string? searchName;
private FilterGenerationContext(INamespaceOrTypeSymbol symbol) : base(symbol) { }
public required bool IncludeSorting { get; init; }
public required bool IncludePaging { get; init; }
protected override string FilterSuffix => "Filters";
[NotNull]
public required string? ProfileName
{
get => profileName ?? FilterName + ProfileSuffix;
init => profileName = value;
}
[NotNull]
public required string? ProfileNamespace
{
get => profileNamespace ?? FilterNamespace;
init => profileNamespace = value;
}
public string ProfileNameFull => CreateFullName(ProfileNamespace, ProfileName);
protected virtual string ProfileSuffix => "Profile";
[NotNull]
public required string? SearchName
{
get => searchName ?? "Search";
init => searchName = value;
}
private static string CreateFullName(string targetNamespace, string targetName)
{
return !string.IsNullOrWhiteSpace(targetNamespace) ? $"global::{targetNamespace}.{targetName}" : targetName;
}
public static FilterGenerationContext Create(ITypeSymbol symbol, AttributeData attributeData)
{
if (attributeData.AttributeClass?.ToString() is not AttributeConstants.GenerateFiltersAttribute)
throw new ArgumentException($"Attribute is not a {AttributeConstants.GenerateFiltersAttribute}", nameof(attributeData));
return new FilterGenerationContext(symbol)
{
FilterName = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.FilterName, (string?)null),
FilterNamespace = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.FilterNamespace, (string?)null),
ProfileName = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.ProfileName, (string?)null),
ProfileNamespace = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.ProfileNamespace, (string?)null),
IncludeSorting = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.IncludeSorting, false),
IncludePaging = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.IncludePaging, false),
SearchName = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.SearchName, (string?)null)
};
}
public static bool TryCreate(ITypeSymbol symbol, [NotNullWhen(true)] out FilterGenerationContext? context)
{
var attributeData = symbol.GetAttributes().FirstOrDefault(x => x.AttributeClass?.ToString() == AttributeConstants.GenerateFiltersAttribute);
if (attributeData is null)
{
context = null;
return false;
}
context = Create(symbol, attributeData);
return true;
}
}

View File

@ -0,0 +1,63 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Microsoft.CodeAnalysis;
namespace Baguette.Querying;
public abstract record FilterGenerationContextBase
{
private readonly string? filterName;
private readonly string? filterNamespace;
protected FilterGenerationContextBase(INamespaceOrTypeSymbol symbol)
{
ModelName = symbol.Name;
ModelNamespace = !symbol.ContainingNamespace.IsGlobalNamespace
? symbol.ContainingNamespace.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)[8..] // Strip global:: from the namespace
: "";
Properties = GetProperties(symbol);
}
public string ModelName { get; }
public string ModelNamespace { get; }
public string ModelNameFull => CreateFullName(ModelNamespace, ModelName);
[NotNull]
public required string? FilterName
{
get => filterName ?? ModelName + FilterSuffix;
init => filterName = value;
}
[NotNull]
public required string? FilterNamespace
{
get => filterNamespace ?? ModelNamespace;
init => filterNamespace = value;
}
public string FilterNameFull => CreateFullName(FilterNamespace, FilterName);
protected abstract string FilterSuffix { get; }
public ImmutableArray<FilterProperty> Properties { get; }
private static string CreateFullName(string targetNamespace, string targetName)
{
return !string.IsNullOrWhiteSpace(targetNamespace) ? $"global::{targetNamespace}.{targetName}" : targetName;
}
private static ImmutableArray<FilterProperty> GetProperties(INamespaceOrTypeSymbol symbol)
{
return symbol
.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.GetMethod is not null && p.DeclaredAccessibility == Accessibility.Public)
.Select(x => new FilterProperty(x))
.ToImmutableArray();
}
}

View File

@ -0,0 +1,97 @@
namespace Baguette.Querying;
public static class FilterGenerationContextExtensions
{
public static IEnumerable<(string FilterType, string Name)> GetFilterProperties(this FilterGenerationContextBase context)
{
foreach (var property in context.Properties)
{
if (!property.Include)
continue;
yield return (property.FilterType, property.FilterName);
}
}
public static IEnumerable<(TypeMapping TypeMapping, string FilterName, string ModelName)> GetTypeMappings(this FilterGenerationContextBase context)
{
foreach (var property in context.Properties)
{
if (!property.Include)
continue;
var (filterName, modelName) = (property.FilterName, property.ModelName);
if (!property.IsNested)
{
yield return (property.TypeMapping, filterName, modelName);
continue;
}
foreach (var (nestedTypeMapping, nestedFilterName, nestedModelName) in property.NestedContext.GetTypeMappings())
{
yield return (nestedTypeMapping, $"{filterName}.{nestedFilterName}", $"{modelName}.{nestedModelName}");
}
}
}
public static IEnumerable<(string SortingKey, string ModelName)> GetSortProperties(this FilterGenerationContextBase context)
{
foreach (var property in context.Properties)
{
if (!property.IncludeSorting)
continue;
var (sortKey, modelName) = (property.FilterName, property.ModelName);
if (!property.IsNested)
{
yield return (sortKey, modelName);
foreach (var alias in property.Aliases)
{
if (string.IsNullOrWhiteSpace(alias))
continue;
yield return (alias, modelName);
}
continue;
}
foreach (var (nestedSortKey, nestedModelName) in property.NestedContext.GetSortProperties())
{
yield return ($"{sortKey}.{nestedSortKey}", $"{modelName}.{nestedModelName}");
foreach (var alias in property.Aliases)
{
yield return (!string.IsNullOrWhiteSpace(alias) ? $"{alias}.{nestedSortKey}" : nestedSortKey, $"{modelName}.{nestedModelName}");
}
}
}
}
public static IEnumerable<string> GetSearchableProperties(this FilterGenerationContextBase context)
{
foreach (var property in context.Properties)
{
if (!property.IsSearchable)
continue;
var modelName = property.ModelName;
if (!property.IsNested)
{
yield return modelName;
continue;
}
foreach (var nestedProperty in property.NestedContext.GetSearchableProperties())
{
yield return $"{modelName}.{nestedProperty}";
}
}
}
}

View File

@ -0,0 +1,41 @@
using Baguette.Querying.Configuration;
namespace Baguette.Querying;
public class FilterGenerator(FilterGenerationContext context) : FilterGeneratorBase(context)
{
protected override IEnumerable<string> ImplementingInterfaces
{
get
{
if (context.IncludeSorting)
yield return $"global::{NamespaceConstants.RootNamespace}.IMultiSortable";
if (context.IncludePaging)
yield return $"global::{NamespaceConstants.RootNamespace}.IPageable";
}
}
protected override void WriteBody()
{
if (context.IncludeSorting)
{
StringBuilder.AppendLine($"{CurrentIndentation}public global::System.Collections.Generic.IEnumerable<global::{NamespaceConstants.RootNamespace}.SortingOptions>? Sorting {{ get; init; }}");
StringBuilder.AppendLine();
}
if (context.IncludePaging)
{
StringBuilder.AppendLine($"{CurrentIndentation}public global::{NamespaceConstants.RootNamespace}.PagingOptions Paging {{ get; init; }}");
StringBuilder.AppendLine();
}
if (context.GetSearchableProperties().Any())
{
StringBuilder.AppendLine($"{CurrentIndentation}public string? {context.SearchName} {{ get; init; }}");
StringBuilder.AppendLine();
}
base.WriteBody();
}
}

View File

@ -0,0 +1,47 @@
namespace Baguette.Querying;
public class FilterGeneratorBase(FilterGenerationContextBase context) : GeneratorBase
{
protected override string GeneratedFileName => $"{context.FilterName}.g.cs";
protected override string Namespace => context.FilterNamespace;
protected virtual IEnumerable<string> ImplementingInterfaces
{
get { yield break; }
}
protected override void WriteStartBody()
{
StringBuilder.AppendLine($"{CurrentIndentation}public readonly partial record struct {context.FilterName}{GetImplementingInterfacesText()}");
StringBuilder.AppendLine($"{CurrentIndentation}{{");
Indent();
return;
string GetImplementingInterfacesText()
{
if (ImplementingInterfaces.ToList() is not { } list || list is { Count: 0 })
return string.Empty;
return " : " + string.Join(", ", list);
}
}
protected override void WriteBody()
{
foreach (var (filterType, name) in context.GetFilterProperties())
{
StringBuilder.AppendLine($"{CurrentIndentation}public {filterType} {name} {{ get; init; }}");
StringBuilder.AppendLine();
}
}
protected override void WriteEndBody()
{
Unindent();
StringBuilder.AppendLine($"{CurrentIndentation}}}");
}
}

View File

@ -0,0 +1,108 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Baguette.Querying.Configuration;
using Microsoft.CodeAnalysis;
namespace Baguette.Querying;
public record FilterProperty
{
public FilterProperty(IPropertySymbol symbol)
{
ModelName = symbol.Name;
ModelType = symbol.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
FilterMode = GetFilterMode(symbol);
NestedContext = GetContext(symbol.Type);
FilterName = GetFilterName(symbol);
Aliases = GetAliases(symbol);
HasExludeSortingAttribute = symbol.GetAttributes().Any(x => x.AttributeClass?.ToString() == AttributeConstants.ExcludeSortingAttribute);
HasSearchableAttribute = symbol.GetAttributes().Any(x => x.AttributeClass?.ToString() == AttributeConstants.SearchableAttribute);
}
public string ModelName { get; }
public string ModelType { get; }
public int FilterMode { get; }
public TypeMapping? TypeMapping => FindTypeMapping(ModelType, FilterMode);
public NestedFilterGenerationContext? NestedContext { get; }
[MemberNotNullWhen(true, nameof(FilterType), nameof(TypeMapping))]
public bool Include => FilterType is not null;
[MemberNotNullWhen(true, nameof(FilterType), nameof(TypeMapping))]
public bool IncludeSorting => Include && !HasExludeSortingAttribute;
[MemberNotNullWhen(true, nameof(FilterType), nameof(TypeMapping), nameof(NestedContext))]
public bool IsNested => Include && NestedContext is not null;
public string? FilterType => GetFilterType(TypeMapping, NestedContext);
public string FilterName { get; }
[MemberNotNullWhen(true, nameof(FilterType), nameof(TypeMapping))]
public bool IsSearchable => Include && HasSearchableAttribute;
public ImmutableArray<string> Aliases { get; }
private bool HasExludeSortingAttribute { get; }
private bool HasSearchableAttribute { get; }
private static string GetFilterName(ISymbol symbol)
{
var attribute = symbol.GetAttributes().FirstOrDefault(x => x.AttributeClass?.ToString() == AttributeConstants.FilterNameAttribute);
if (attribute is null)
return symbol.Name;
var filterName = (string)attribute.ConstructorArguments[0].Value!;
return !string.IsNullOrWhiteSpace(filterName)
? filterName
: symbol.Name;
}
private static string? GetFilterType(TypeMapping? typeMapping, NestedFilterGenerationContext? generationContext)
{
return typeMapping is null
? null
: generationContext is null
? typeMapping.FilterType
: typeMapping.Kind is TypeMapping.FilterKind.Equal
? generationContext.FilterNameFull
: null;
}
private static TypeMapping? FindTypeMapping(string modelType, int filterMode)
{
return TypeMapping.FindTypeMapping(modelType, filterMode);
}
private static int GetFilterMode(ISymbol symbol)
{
var filterModeAttribute = symbol.GetAttributes().FirstOrDefault(x => x.AttributeClass?.ToString() == AttributeConstants.FilterModeAttribute);
return filterModeAttribute is not null
? (int)filterModeAttribute.ConstructorArguments[0].Value!
: 0;
}
private static NestedFilterGenerationContext? GetContext(ITypeSymbol symbol)
{
return NestedFilterGenerationContext.TryCreate(symbol, out var context) ? context : null;
}
private static ImmutableArray<string> GetAliases(IPropertySymbol symbol)
{
return symbol
.GetAttributes()
.Where(x => x.AttributeClass?.ToString() == AttributeConstants.SortAliasAttribute)
.Select(x => (string)(x.ConstructorArguments[0].Value ?? string.Empty))
.Select(x => x.Trim())
.Distinct()
.ToImmutableArray();
}
}

View File

@ -0,0 +1,71 @@
using System.Text;
using Baguette.Querying.Configuration;
using Microsoft.CodeAnalysis;
namespace Baguette.Querying;
public abstract class GeneratorBase
{
protected StringBuilder StringBuilder { get; } = new();
protected string CurrentIndentation { get; private set; } = string.Empty;
protected abstract string Namespace { get; }
protected abstract string GeneratedFileName { get; }
private void WriteStartNamespace()
{
if (string.IsNullOrWhiteSpace(Namespace))
return;
StringBuilder.AppendLine($"{CurrentIndentation}namespace {Namespace}");
StringBuilder.AppendLine($"{CurrentIndentation}{{");
Indent();
}
protected abstract void WriteStartBody();
protected abstract void WriteBody();
protected abstract void WriteEndBody();
private void WriteEndNamespace()
{
if (string.IsNullOrWhiteSpace(Namespace))
return;
Unindent();
StringBuilder.AppendLine($"{CurrentIndentation}}}");
}
protected void Indent()
{
CurrentIndentation += GenerationConstants.Indentation;
}
protected void Unindent()
{
if (CurrentIndentation.Length == 0)
return;
CurrentIndentation = CurrentIndentation[..^GenerationConstants.Indentation.Length];
}
public void Generate(SourceProductionContext productionContext)
{
StringBuilder.Clear();
StringBuilder.AppendLine("// <auto-generated/>");
StringBuilder.AppendLine("#nullable enable");
WriteStartNamespace();
WriteStartBody();
WriteBody();
WriteEndBody();
WriteEndNamespace();
productionContext.AddSource(GeneratedFileName, StringBuilder.ToString());
}
}

View File

@ -0,0 +1,43 @@
using Baguette.Querying.Configuration;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Baguette.Querying;
[Generator]
public class IncrementalFilterGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var filterProvider = context.SyntaxProvider
.ForAttributeWithMetadataName(
AttributeConstants.GenerateFiltersAttribute,
static (node, _) => node is TypeDeclarationSyntax,
static (ctx, _) => (ModelSymbol: (ITypeSymbol)ctx.TargetSymbol, ctx.Attributes))
.SelectMany((x, _) => x.Attributes.Select(y => (TargetSymbol: x.ModelSymbol, Attribute: y)))
.Select((x, _) => FilterGenerationContext.Create(x.TargetSymbol, x.Attribute));
context.RegisterSourceOutput(filterProvider, Generate);
var nestedFilterProvider = context.SyntaxProvider
.ForAttributeWithMetadataName(
AttributeConstants.GenerateNestedFiltersAttribute,
static (node, _) => node is TypeDeclarationSyntax,
static (ctx, _) => (ModelSymbol: (ITypeSymbol)ctx.TargetSymbol, ctx.Attributes))
.SelectMany((x, _) => x.Attributes.Select(y => (TargetSymbol: x.ModelSymbol, Attribute: y)))
.Select((x, _) => NestedFilterGenerationContext.Create(x.TargetSymbol, x.Attribute));
context.RegisterSourceOutput(nestedFilterProvider, Generate);
}
private static void Generate(SourceProductionContext productionContext, FilterGenerationContext generationContext)
{
new FilterGenerator(generationContext).Generate(productionContext);
new QueryProfileGenerator(generationContext).Generate(productionContext);
}
private static void Generate(SourceProductionContext productionContext, NestedFilterGenerationContext generationContext)
{
new FilterGeneratorBase(generationContext).Generate(productionContext);
}
}

View File

@ -0,0 +1,40 @@
using System.Diagnostics.CodeAnalysis;
using Baguette.Querying.Configuration;
using Microsoft.CodeAnalysis;
namespace Baguette.Querying;
public record NestedFilterGenerationContext : FilterGenerationContextBase
{
private NestedFilterGenerationContext(INamespaceOrTypeSymbol symbol) : base(symbol) { }
protected override string FilterSuffix => "NestedFilters";
public static NestedFilterGenerationContext Create(ITypeSymbol symbol, AttributeData attributeData)
{
if (attributeData.AttributeClass?.ToString() is not AttributeConstants.GenerateNestedFiltersAttribute)
throw new ArgumentException($"Attribute is not a {AttributeConstants.GenerateNestedFiltersAttribute}", nameof(attributeData));
return new NestedFilterGenerationContext(symbol)
{
FilterName = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.FilterName, (string?)null),
FilterNamespace = ArgumentUtilities.GetValue(attributeData.NamedArguments, AttributeArgumentConstants.FilterNamespace, (string?)null)
};
}
public static bool TryCreate(ITypeSymbol symbol, [NotNullWhen(true)] out NestedFilterGenerationContext? context)
{
var attributeData = symbol.GetAttributes().FirstOrDefault(x => x.AttributeClass?.ToString() == AttributeConstants.GenerateNestedFiltersAttribute);
if (attributeData is null)
{
context = null;
return false;
}
context = Create(symbol, attributeData);
return true;
}
}

View File

@ -0,0 +1,8 @@
{
"profiles": {
"Debug Console": {
"commandName": "DebugRoslynComponent",
"targetProject": "../../samples/Baguette.Querying.Console/Baguette.Querying.Console.csproj"
}
}
}

View File

@ -0,0 +1,84 @@
namespace Baguette.Querying;
public class QueryProfileGenerator(FilterGenerationContext context) : GeneratorBase
{
private const string BuilderParameter = "builder";
protected override string Namespace => context.ProfileNamespace;
protected override string GeneratedFileName => $"{context.ProfileName}.g.cs";
protected override void WriteStartBody()
{
StringBuilder.AppendLine($"{CurrentIndentation}public sealed class {context.ProfileName} : global::Baguette.Querying.IQueryProfile<{context.FilterNameFull}, {context.ModelNameFull}>");
StringBuilder.AppendLine($"{CurrentIndentation}{{");
Indent();
StringBuilder.AppendLine($"{CurrentIndentation}public void Configure(global::Baguette.Querying.IQueryBuilder<{context.FilterNameFull}, {context.ModelNameFull}> {BuilderParameter})");
StringBuilder.AppendLine($"{CurrentIndentation}{{");
Indent();
}
protected override void WriteBody()
{
foreach (var (typeMapping, filterName, modelName) in context.GetTypeMappings())
{
StringBuilder.AppendLine($"{CurrentIndentation}{typeMapping.GetFilterCode(BuilderParameter, filterName, modelName)}");
}
WriteSearch();
WriteSorting();
return;
void WriteSearch()
{
var searchProperties = context.GetSearchableProperties().ToList();
if (searchProperties.Count == 0)
return;
StringBuilder.AppendLine(
$"{CurrentIndentation}global::Baguette.Querying.QueryBuilderFilterExtensions.AddMultiSearch({BuilderParameter}, filters => filters.{context.SearchName}, {string.Join(", ", searchProperties.Select(x => $"model => model.{x}"))});");
}
void WriteSorting()
{
if (!context.IncludeSorting)
return;
var sortProperties = context.GetSortProperties().ToList();
if (sortProperties.Count == 0)
return;
StringBuilder.AppendLine($"{CurrentIndentation}global::Baguette.Querying.QueryBuilderSortingExtensions.AddMultiSorting({BuilderParameter}, sortingBuilder =>");
StringBuilder.AppendLine($"{CurrentIndentation}{{");
Indent();
foreach (var (sortingKey, modelName) in sortProperties)
{
StringBuilder.AppendLine($"{CurrentIndentation}global::Baguette.Querying.QueryBuilderSortingExtensions.AddSorting(sortingBuilder, \"{sortingKey}\", model => model.{modelName});");
}
Unindent();
StringBuilder.AppendLine($"{CurrentIndentation}}});");
}
}
protected override void WriteEndBody()
{
Unindent();
StringBuilder.AppendLine($"{CurrentIndentation}}}");
Unindent();
StringBuilder.AppendLine($"{CurrentIndentation}}}");
}
}

View File

@ -0,0 +1,93 @@
using Baguette.Querying.Configuration;
namespace Baguette.Querying;
public record TypeMapping(string ModelType, string FilterType, TypeMapping.FilterKind Kind, int Mode)
{
public enum FilterKind
{
Equal,
Contains,
InList,
InRange,
InDateRange,
InDateOnlyRange
}
private static IEnumerable<TypeMapping> Mappings { get; } = CreateTypeMappings().ToList();
public string GetFilterCode(string builderName, string filterPropertyName, string modelPropertyName)
{
var format = Kind switch
{
FilterKind.Equal => "global::Baguette.Querying.QueryBuilderFilterExtensions.AddEqualsFilter({0}, filters => filters.{1}, model => model.{2});",
FilterKind.Contains => "global::Baguette.Querying.QueryBuilderFilterExtensions.AddTextFilter({0}, filters => filters.{1}, model => model.{2});",
FilterKind.InList => "global::Baguette.Querying.QueryBuilderFilterExtensions.AddInListFilter({0}, filters => filters.{1}, model => model.{2});",
FilterKind.InRange => "global::Baguette.Querying.QueryBuilderFilterExtensions.AddInRangeFilter({0}, filters => filters.{1}, model => model.{2});",
FilterKind.InDateRange => "global::Baguette.Querying.QueryBuilderFilterExtensions.AddInDateRangeFilter({0}, filters => filters.{1}, model => model.{2});",
FilterKind.InDateOnlyRange => "global::Baguette.Querying.QueryBuilderFilterExtensions.AddInDateOnlyRangeFilter({0}, filters => filters.{1}, model => model.{2});",
_ => throw new ArgumentOutOfRangeException()
};
return string.Format(format, builderName, filterPropertyName, modelPropertyName);
}
private static IEnumerable<TypeMapping> CreateTypeMappings()
{
var numericTypes = new[] { "int", "long", "double", "decimal" };
foreach (var numericType in numericTypes)
{
yield return new TypeMapping(numericType, $"global::{NamespaceConstants.RangeNamespace}.FilterRange<{numericType}>", FilterKind.InRange, 0);
yield return new TypeMapping(numericType, $"global::{NamespaceConstants.RangeNamespace}.FilterRange<{numericType}>", FilterKind.InRange, 3);
yield return new TypeMapping(numericType + "?", $"global::{NamespaceConstants.RangeNamespace}.FilterRange<{numericType}>", FilterKind.InRange, 0);
yield return new TypeMapping(numericType + "?", $"global::{NamespaceConstants.RangeNamespace}.FilterRange<{numericType}>", FilterKind.InRange, 3);
}
yield return new TypeMapping("bool", "bool?", FilterKind.Equal, 0);
yield return new TypeMapping("bool?", "bool?", FilterKind.Equal, 0);
yield return new TypeMapping("bool", "bool?", FilterKind.Equal, 2);
yield return new TypeMapping("bool?", "bool?", FilterKind.Equal, 2);
yield return new TypeMapping("string", "string?", FilterKind.Contains, 0);
yield return new TypeMapping("string?", "string?", FilterKind.Contains, 0);
yield return new TypeMapping("string", "string?", FilterKind.Contains, 4);
yield return new TypeMapping("string?", "string?", FilterKind.Contains, 4);
yield return new TypeMapping("string", "string?", FilterKind.Equal, 2);
yield return new TypeMapping("string?", "string?", FilterKind.Equal, 2);
yield return new TypeMapping("string", "global::System.Collections.Generic.IReadOnlyCollection<string>?", FilterKind.InList, 5);
yield return new TypeMapping("string?", "global::System.Collections.Generic.IReadOnlyCollection<string>?", FilterKind.InList, 5);
yield return new TypeMapping("global::System.DateTimeOffset", $"global::{NamespaceConstants.RangeNamespace}.DateTimeOffsetFilterRange", FilterKind.InDateRange, 0);
yield return new TypeMapping("global::System.DateTimeOffset?", $"global::{NamespaceConstants.RangeNamespace}.DateTimeOffsetFilterRange", FilterKind.InDateRange, 0);
yield return new TypeMapping("global::System.DateTimeOffset", $"global::{NamespaceConstants.RangeNamespace}.DateTimeOffsetFilterRange", FilterKind.InDateRange, 6);
yield return new TypeMapping("global::System.DateTimeOffset?", $"global::{NamespaceConstants.RangeNamespace}.DateTimeOffsetFilterRange", FilterKind.InDateRange, 6);
yield return new TypeMapping("global::System.DateTimeOffset", "global::System.DateTimeOffset?", FilterKind.Equal, 2);
yield return new TypeMapping("global::System.DateTimeOffset?", "global::System.DateTimeOffset?", FilterKind.Equal, 2);
yield return new TypeMapping("global::System.DateTime", $"global::{NamespaceConstants.RangeNamespace}.DateTimeFilterRange", FilterKind.InDateRange, 0);
yield return new TypeMapping("global::System.DateTime?", $"global::{NamespaceConstants.RangeNamespace}.DateTimeFilterRange", FilterKind.InDateRange, 0);
yield return new TypeMapping("global::System.DateTime", $"global::{NamespaceConstants.RangeNamespace}.DateTimeFilterRange", FilterKind.InDateRange, 6);
yield return new TypeMapping("global::System.DateTime?", $"global::{NamespaceConstants.RangeNamespace}.DateTimeFilterRange", FilterKind.InDateRange, 6);
yield return new TypeMapping("global::System.DateTime", "global::System.DateTime?", FilterKind.Equal, 2);
yield return new TypeMapping("global::System.DateTime?", "global::System.DateTime?", FilterKind.Equal, 2);
yield return new TypeMapping("global::System.DateOnly", $"global::{NamespaceConstants.RangeNamespace}.DateOnlyFilterRange", FilterKind.InDateOnlyRange, 0);
yield return new TypeMapping("global::System.DateOnly?", $"global::{NamespaceConstants.RangeNamespace}.DateOnlyFilterRange", FilterKind.InDateOnlyRange, 0);
yield return new TypeMapping("global::System.DateOnly", $"global::{NamespaceConstants.RangeNamespace}.DateOnlyFilterRange", FilterKind.InDateOnlyRange, 6);
yield return new TypeMapping("global::System.DateOnly?", $"global::{NamespaceConstants.RangeNamespace}.DateOnlyFilterRange", FilterKind.InDateOnlyRange, 6);
yield return new TypeMapping("global::System.DateOnly", "global::System.DateOnly?", FilterKind.Equal, 2);
yield return new TypeMapping("global::System.DateOnly?", "global::System.DateOnly?", FilterKind.Equal, 2);
}
public static TypeMapping? FindTypeMapping(string modelType, int mode)
{
var defaultMapping = Mappings.FirstOrDefault(m => m.ModelType == modelType && m.Mode == mode);
if (defaultMapping is not null)
return defaultMapping;
return mode switch
{
2 or 0 => new TypeMapping(modelType, modelType.EndsWith("?") ? modelType : modelType + "?", FilterKind.Equal, mode),
5 => new TypeMapping(modelType, $"global::System.Collections.Generic.IReadOnlyCollection<{modelType}>?", FilterKind.InList, mode),
_ => null
};
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<PackageId>Baguette.Querying</PackageId>
<Description>Provides querying based on a pre-defined model.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0"/>
<PackageReference Include="Scrutor" Version="4.2.2" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,8 @@
namespace Baguette.Querying;
public readonly record struct DateOnlyFilterRange
{
public DateOnly? Min { get; init; }
public DateOnly? Max { get; init; }
}

View File

@ -0,0 +1,8 @@
namespace Baguette.Querying;
public readonly record struct DateTimeFilterRange
{
public DateTime? Min { get; init; }
public DateTime? Max { get; init; }
}

View File

@ -0,0 +1,8 @@
namespace Baguette.Querying;
public readonly record struct DateTimeOffsetFilterRange
{
public DateTimeOffset? Min { get; init; }
public DateTimeOffset? Max { get; init; }
}

View File

@ -0,0 +1,32 @@
namespace Baguette.Querying;
public class DefaultQueryBuilder<TFilters, TModel> : IQueryBuilder<TFilters, TModel> where TModel : notnull
{
private readonly List<Func<QueryPipeline<TFilters, TModel>, QueryPipeline<TFilters, TModel>>> queryBuilders = [];
public void Add(Func<IQueryable<TModel>, TFilters, IQueryable<TModel>> queryBuilder)
{
ArgumentNullException.ThrowIfNull(queryBuilder);
queryBuilders.Add(next => (query, filters) => next(queryBuilder(query, filters), filters));
}
public QueryPipeline<TFilters, TModel> Build()
{
var actualPipeline = BuildPipeline();
return InvokePipeline;
IQueryable<TModel> InvokePipeline(IQueryable<TModel> query, TFilters filters)
{
ArgumentNullException.ThrowIfNull(query);
return actualPipeline(query, filters);
}
}
private QueryPipeline<TFilters, TModel> BuildPipeline()
{
return queryBuilders.AsEnumerable().Reverse().Aggregate((QueryPipeline<TFilters, TModel>)((query, _) => query), (next, builder) => builder(next));
}
}

View File

@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
namespace Baguette.Querying;
public class DefaultQueryPipelineProvider(IServiceProvider provider) : IQueryPipelineProvider
{
public QueryPipeline<TFilters, TModel> GetPipeline<TFilters, TModel>() where TModel : notnull
{
return provider.GetRequiredService<IQueryPipelineProvider<TFilters, TModel>>().Pipeline;
}
}
public class DefaultQueryPipelineProvider<TFilters, TModel>(IEnumerable<IQueryProfile<TFilters, TModel>> profiles) : IQueryPipelineProvider<TFilters, TModel>
where TModel : notnull
{
private QueryPipeline<TFilters, TModel>? pipeline;
public QueryPipeline<TFilters, TModel> Pipeline => pipeline ??= BuildPipeline();
private QueryPipeline<TFilters, TModel> BuildPipeline()
{
var builder = new DefaultQueryBuilder<TFilters, TModel>();
foreach (var profile in profiles)
{
profile.Configure(builder);
}
return builder.Build();
}
}

View File

@ -0,0 +1,22 @@
namespace Baguette.Querying;
public class DefaultQueryProcessor : IQueryProcessor
{
private readonly IQueryPipelineProvider pipelineProvider;
public DefaultQueryProcessor(IQueryPipelineProvider pipelineProvider)
{
this.pipelineProvider = pipelineProvider;
}
public IQueryable<TModel> ProcessQuery<TFilters, TModel>(IQueryable<TModel> baseQuery, TFilters filters) where TModel : notnull
{
if (baseQuery == null)
throw new ArgumentNullException(nameof(baseQuery));
if (filters == null)
throw new ArgumentNullException(nameof(filters));
return pipelineProvider.GetPipeline<TFilters, TModel>()(baseQuery, filters);
}
}

View File

@ -0,0 +1,10 @@
using System.Numerics;
namespace Baguette.Querying;
public readonly record struct FilterRange<T> where T : struct, INumber<T>
{
public T? Min { get; init; }
public T? Max { get; init; }
}

View File

@ -0,0 +1,6 @@
namespace Baguette.Querying;
public interface IMultiSortable
{
IEnumerable<SortingOptions>? Sorting { get; }
}

View File

@ -0,0 +1,6 @@
namespace Baguette.Querying;
public interface IPageable
{
PagingOptions Paging { get; }
}

View File

@ -0,0 +1,8 @@
namespace Baguette.Querying;
public interface IQueryBuilder<TFilters, TModel> where TModel : notnull
{
void Add(Func<IQueryable<TModel>, TFilters, IQueryable<TModel>> queryBuilder);
QueryPipeline<TFilters, TModel> Build();
}

View File

@ -0,0 +1,11 @@
namespace Baguette.Querying;
public interface IQueryPipelineProvider
{
QueryPipeline<TFilters, TModel> GetPipeline<TFilters, TModel>() where TModel : notnull;
}
public interface IQueryPipelineProvider<in TFilters, TModel> where TModel : notnull
{
QueryPipeline<TFilters, TModel> Pipeline { get; }
}

View File

@ -0,0 +1,7 @@
namespace Baguette.Querying;
[Obsolete]
public interface IQueryProcessor
{
IQueryable<TModel> ProcessQuery<TFilters, TModel>(IQueryable<TModel> baseQuery, TFilters filters) where TModel : notnull;
}

View File

@ -0,0 +1,6 @@
namespace Baguette.Querying;
public interface IQueryProfile<TFilters, TModel> where TModel : notnull
{
void Configure(IQueryBuilder<TFilters, TModel> builder);
}

View File

@ -0,0 +1,6 @@
namespace Baguette.Querying;
public interface ISortable
{
SortingOptions Sorting { get; }
}

View File

@ -0,0 +1,9 @@
namespace Baguette.Querying;
public class LambdaQueryProfile<TFilters, TModel>(Action<IQueryBuilder<TFilters, TModel>> configure) : IQueryProfile<TFilters, TModel>
where TModel : notnull
{
private readonly Action<IQueryBuilder<TFilters, TModel>> configure = configure ?? throw new ArgumentNullException(nameof(configure));
public void Configure(IQueryBuilder<TFilters, TModel> builder) => configure(builder);
}

View File

@ -0,0 +1,13 @@
using System.Diagnostics.CodeAnalysis;
namespace Baguette.Querying;
public class PaginatedResult<T>(IEnumerable<T> items, int? total, bool hasTotal)
{
public IEnumerable<T> Items { get; } = items;
public int? Total { get; } = total;
[MemberNotNullWhen(true, nameof(Total))]
public bool HasTotal { get; } = hasTotal;
}

View File

@ -0,0 +1,10 @@
namespace Baguette.Querying;
public readonly record struct PagingOptions
{
public int Length { get; init; }
public int Offset { get; init; }
public bool ExcludeTotal { get; init; }
}

View File

@ -0,0 +1,73 @@
using System.Linq.Expressions;
using Baguette.Querying.Visitors;
namespace Baguette.Querying;
public static class QueryBuilderExtensions
{
public static void AddTransform<TFilters, TTransform, TModel>(
this IQueryBuilder<TFilters, TModel> builder, Func<TFilters, TTransform> selector,
Action<IQueryBuilder<TTransform, TModel>> configureFilter)
where TModel : notnull
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(selector);
ArgumentNullException.ThrowIfNull(configureFilter);
var filterBuilder = new DefaultQueryBuilder<TTransform, TModel>();
configureFilter(filterBuilder);
var transformPipeline = filterBuilder.Build();
builder.Add((query, filters) => transformPipeline(query, selector(filters)));
}
public static void AddFilter<TFilters, TModel>(
this IQueryBuilder<TFilters, TModel> builder,
Expression<Func<TFilters, TModel, bool>> predicate,
Func<TFilters, bool>? guard = null)
where TModel : notnull // Shorthand for Add(query => query.Where(predicate))
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(predicate);
builder.Add((query, filters) =>
{
if (guard is not null && !guard(filters))
return query;
var replacedPredicate = predicate.Body.Replace(predicate.Parameters[0], Expression.Constant(filters));
return query.Where(Expression.Lambda<Func<TModel, bool>>(replacedPredicate, predicate.Parameters[1]));
});
}
public static void AddPropertyFilter<TFilters, TModel, TFilterProperty, TModelProperty>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, TFilterProperty> filterSelector,
Expression<Func<TModel, TModelProperty>> modelSelector,
Expression<Func<TFilterProperty, TModelProperty, bool>> predicate,
Func<TFilterProperty, bool>? guard = null)
where TModel : notnull
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(filterSelector);
ArgumentNullException.ThrowIfNull(modelSelector);
ArgumentNullException.ThrowIfNull(predicate);
builder.Add((query, filters) =>
{
var filter = filterSelector(filters);
if (guard is not null && !guard(filter))
return query;
var replacedExpression = predicate.Body
.Replace(predicate.Parameters[0], Expression.Constant(filter))
.Replace(predicate.Parameters[1], modelSelector.Body);
return query.Where(Expression.Lambda<Func<TModel, bool>>(replacedExpression, modelSelector.Parameters));
});
}
}

View File

@ -0,0 +1,131 @@
using System.Linq.Expressions;
using System.Numerics;
using Baguette.Querying.Visitors;
namespace Baguette.Querying;
public static class QueryBuilderFilterExtensions
{
public static void AddMultiSearch<TFilters, TModel>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, string?> filterSelector,
params Expression<Func<TModel, string?>>[] modelSelectors)
where TModel : notnull
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(filterSelector);
ArgumentNullException.ThrowIfNull(modelSelectors);
if (modelSelectors.Length == 0)
return;
var filterParameter = Expression.Parameter(typeof(string), "search");
var modelParameter = Expression.Parameter(typeof(TModel), "model");
var expression = modelSelectors
.Select(m => m.Body.Replace(m.Parameters[0], modelParameter))
.Select(m => Expression.AndAlso(Expression.NotEqual(m, Expression.Constant(null, m.Type)),
Expression.Call(Expression.Call(m, nameof(string.ToLower), null), "Contains", null, filterParameter)))
.Aggregate(Expression.OrElse);
var predicate = Expression.Lambda<Func<string?, TModel, bool>>(expression, filterParameter, modelParameter);
builder.AddTransform(x => filterSelector(x)?.ToLower(), nestedBuilder => nestedBuilder.AddFilter(predicate, filter => !string.IsNullOrEmpty(filter)));
}
public static void AddTextFilter<TFilters, TModel>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, string?> filterSelector,
Expression<Func<TModel, string?>> modelSelector)
where TFilters : notnull where TModel : notnull
{
ArgumentNullException.ThrowIfNull(filterSelector);
builder.AddPropertyFilter(
filters => filterSelector(filters)?.ToLower(),
modelSelector,
(filter, model) => model != null && model.ToLower().Contains(filter!),
filter => !string.IsNullOrEmpty(filter));
}
public static void AddInListFilter<TFilters, TModel, TItem>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, IReadOnlyCollection<TItem>?> filterSelector,
Expression<Func<TModel, TItem>> modelSelector)
where TFilters : notnull where TModel : notnull
{
builder.AddPropertyFilter(filterSelector, modelSelector, (filter, model) => filter!.Contains(model), filter => filter is not null && filter.Count != 0);
}
public static void AddEqualsFilter<TFilters, TModel, TItem>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, TItem?> filterSelector,
Expression<Func<TModel, TItem>> modelSelector)
where TFilters : notnull where TModel : notnull
{
builder.AddPropertyFilter(filterSelector, modelSelector, (filter, model) => Equals(filter, model), filter => filter is string text ? !string.IsNullOrEmpty(text) : filter is not null);
}
public static void AddInRangeFilter<TFilters, TModel, T>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, FilterRange<T>> filterSelector,
Expression<Func<TModel, T>> modelSelector)
where TFilters : notnull where TModel : notnull where T : struct, INumber<T>
{
var rangeParameter = Expression.Parameter(typeof(FilterRange<T>), "range");
var modelParameter = Expression.Parameter(typeof(T), "model");
var minProperty = Expression.Property(rangeParameter, typeof(FilterRange<T>), "Min");
var maxProperty = Expression.Property(rangeParameter, typeof(FilterRange<T>), "Max");
var minExpression = Expression.OrElse(Expression.Equal(minProperty, Expression.Constant(null, typeof(T?))),
Expression.GreaterThanOrEqual(modelParameter, Expression.Property(minProperty, typeof(T?), "Value")));
var maxExpression = Expression.OrElse(Expression.Equal(maxProperty, Expression.Constant(null, typeof(T?))),
Expression.LessThanOrEqual(modelParameter, Expression.Property(maxProperty, typeof(T?), "Value")));
builder.AddPropertyFilter(
filterSelector,
modelSelector,
Expression.Lambda<Func<FilterRange<T>, T, bool>>(Expression.AndAlso(minExpression, maxExpression), rangeParameter, modelParameter),
range => range.Min is not null || range.Max is not null);
}
public static void AddInDateRangeFilter<TFilters, TModel>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, DateTimeOffsetFilterRange> filterSelector,
Expression<Func<TModel, DateTimeOffset?>> modelSelector)
where TFilters : notnull where TModel : notnull
{
builder.AddPropertyFilter(
filterSelector,
modelSelector,
(range, model) => (range.Min == null || model >= range.Min.Value) && (range.Max == null || model <= range.Max),
range => range.Min is not null || range.Max is not null);
}
public static void AddInDateRangeFilter<TFilters, TModel>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, DateTimeFilterRange> filterSelector,
Expression<Func<TModel, DateTime?>> modelSelector) where TFilters : notnull where TModel : notnull
{
builder.AddPropertyFilter(
filterSelector,
modelSelector,
(range, model) => (range.Min == null || model >= range.Min.Value) && (range.Max == null || model <= range.Max),
range => range.Min is not null || range.Max is not null);
}
public static void AddInDateOnlyRangeFilter<TFilters, TModel>(
this IQueryBuilder<TFilters, TModel> builder,
Func<TFilters, DateOnlyFilterRange> filterSelector,
Expression<Func<TModel, DateOnly?>> modelSelector)
where TFilters : notnull where TModel : notnull
{
builder.AddPropertyFilter(
filterSelector,
modelSelector,
(range, model) => (range.Min == null || model >= range.Min.Value) && (range.Max == null || model <= range.Max),
range => range.Min is not null || range.Max is not null);
}
}

View File

@ -0,0 +1,50 @@
using System.Linq.Expressions;
namespace Baguette.Querying;
public static class QueryBuilderSortingExtensions
{
public static void AddSorting<TFilters, TModel, TProperty>(
this IQueryBuilder<TFilters, TModel> builder,
string key,
Expression<Func<TModel, TProperty>> selector)
where TFilters : ISortable where TModel : notnull
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(selector);
ArgumentException.ThrowIfNullOrWhiteSpace(key);
builder.Add((query, filters) =>
{
if (filters.Sorting.SortBy != key)
return query;
return query.Expression.Type == typeof(IOrderedQueryable<TModel>)
? ((IOrderedQueryable<TModel>)query).ThenBy(selector, filters.Sorting.SortDescending)
: query.OrderBy(selector, filters.Sorting.SortDescending);
});
}
public static void AddMultiSorting<TFilters, TModel>(
this IQueryBuilder<TFilters, TModel> builder,
Action<IQueryBuilder<SortingFilter, TModel>> configureSorting)
where TFilters : IMultiSortable where TModel : notnull
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configureSorting);
var sortingBuilder = new DefaultQueryBuilder<SortingFilter, TModel>();
configureSorting(sortingBuilder);
var sortingPipeline = sortingBuilder.Build();
builder.Add((query, filters) =>
{
if (filters.Sorting is null || !filters.Sorting.Any())
return query;
return filters.Sorting.Aggregate(query, (current, sorting) => sortingPipeline(current, new SortingFilter(sorting)));
});
}
}

View File

@ -0,0 +1,3 @@
namespace Baguette.Querying;
public delegate IQueryable<TModel> QueryPipeline<in TFilters, TModel>(IQueryable<TModel> query, TFilters filters) where TModel : notnull;

View File

@ -0,0 +1,58 @@
using System.Diagnostics.Contracts;
using System.Linq.Expressions;
using System.Reflection;
using Baguette.Querying.Visitors;
namespace Baguette.Querying;
public static class QueryPipelineProviderExtensions
{
[Pure]
public static Expression<Func<TModel, bool>> GetPredicate<TFilters, TModel>(this IQueryPipelineProvider provider, TFilters filters) where TFilters : notnull where TModel : notnull
{
ArgumentNullException.ThrowIfNull(provider);
var pipeline = provider.GetPipeline<TFilters, TModel>();
var query = pipeline(Enumerable.Empty<TModel>().AsQueryable(), filters);
var visitor = new WhereExpressionVisitor<TModel>();
visitor.Visit(query.Expression);
return visitor.Predicate;
}
private class WhereExpressionVisitor<TModel> : ExpressionVisitor
{
private readonly MethodInfo method = new Func<IQueryable<TModel>, Expression<Func<TModel, bool>>, IQueryable<TModel>>(Queryable.Where).Method;
private readonly List<Expression<Func<TModel, bool>>> predicates = [];
public Expression<Func<TModel, bool>> Predicate
{
get
{
if (predicates.Count == 0)
return _ => true;
return predicates.Aggregate((res, curr) =>
{
var parameter = Expression.Parameter(typeof(TModel), "model");
return Expression.Lambda<Func<TModel, bool>>(Expression.And(res.Body.Replace(res.Parameters[0], parameter), curr.Body.Replace(curr.Parameters[0], parameter)), parameter);
});
}
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
if (node.Method != method)
return base.VisitMethodCall(node);
var unary = (UnaryExpression)node.Arguments[1];
predicates.Add((Expression<Func<TModel, bool>>)unary.Operand);
return base.VisitMethodCall(node);
}
}
}

View File

@ -0,0 +1,46 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Baguette.Querying;
public static class QueryServiceCollectionExtensions
{
public static IServiceCollection ConfigureQuery<TFilters, TModel>(
this IServiceCollection services,
Action<IQueryBuilder<TFilters, TModel>> configure)
where TModel : notnull
{
ArgumentNullException.ThrowIfNull(services);
services.AddQuerying();
services.AddSingleton<IQueryProfile<TFilters, TModel>>(new LambdaQueryProfile<TFilters, TModel>(configure));
return services;
}
public static IServiceCollection AddQuerying(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddTransient<IQueryProcessor, DefaultQueryProcessor>();
services.TryAddSingleton(typeof(IQueryPipelineProvider<,>), typeof(DefaultQueryPipelineProvider<,>));
services.TryAddSingleton<IQueryPipelineProvider, DefaultQueryPipelineProvider>();
return services;
}
public static IServiceCollection AddQueryProfiles(this IServiceCollection services, params Type[] assemblyMarkerTypes)
{
ArgumentNullException.ThrowIfNull(services);
services.AddQuerying();
services.Scan(scanner => scanner
.FromAssembliesOf(assemblyMarkerTypes)
.AddClasses(classes => classes.AssignableTo(typeof(IQueryProfile<,>)))
.AsImplementedInterfaces()
.WithSingletonLifetime());
return services;
}
}

View File

@ -0,0 +1,16 @@
using System.Linq.Expressions;
namespace Baguette.Querying;
public static class QueryableExtensions
{
public static IOrderedQueryable<T> OrderBy<T, TProperty>(this IQueryable<T> query, Expression<Func<T, TProperty>> selector, bool desc)
{
return desc ? query.OrderByDescending(selector) : query.OrderBy(selector);
}
public static IOrderedQueryable<T> ThenBy<T, TProperty>(this IOrderedQueryable<T> query, Expression<Func<T, TProperty>> selector, bool desc)
{
return desc ? query.ThenByDescending(selector) : query.ThenBy(selector);
}
}

View File

@ -0,0 +1,26 @@
namespace Baguette.Querying;
public static class QueryableProcessorExtensions
{
public static IQueryable<TModel> ApplyFilters<TFilters, TModel>(this IQueryable<TModel> baseQuery, IQueryProcessor processor, TFilters filters)
where TFilters : notnull where TModel : notnull
{
ArgumentNullException.ThrowIfNull(baseQuery);
ArgumentNullException.ThrowIfNull(processor);
return processor.ProcessQuery(baseQuery, filters);
}
public static PaginatedResult<TModel> Paginate<TFilters, TModel>(this IQueryable<TModel> baseQuery, IQueryProcessor processor, TFilters filters)
where TFilters : notnull, IPageable where TModel : notnull
{
ArgumentNullException.ThrowIfNull(baseQuery);
ArgumentNullException.ThrowIfNull(processor);
var query = processor.ProcessQuery(baseQuery, filters);
var count = !filters.Paging.ExcludeTotal ? (int?)query.Count() : null;
var results = query.Skip(filters.Paging.Offset).Take(filters.Paging.Length).ToList();
return new PaginatedResult<TModel>(results, count, !filters.Paging.ExcludeTotal);
}
}

View File

@ -0,0 +1,3 @@
namespace Baguette.Querying;
public record SortingFilter(SortingOptions Sorting) : ISortable;

View File

@ -0,0 +1,3 @@
namespace Baguette.Querying;
public readonly record struct SortingOptions(string? SortBy, bool SortDescending);

View File

@ -0,0 +1,30 @@
using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts;
using System.Linq.Expressions;
namespace Baguette.Querying.Visitors;
public static class ExpressionExtensions
{
[Pure]
public static Expression Replace(this Expression expression, Expression source, Expression target)
{
ArgumentNullException.ThrowIfNull(expression);
ArgumentNullException.ThrowIfNull(source);
ArgumentNullException.ThrowIfNull(target);
return new ReplaceExpressionVisitor(source, target).Visit(expression);
}
private class ReplaceExpressionVisitor(Expression source, Expression target) : ExpressionVisitor
{
[return: NotNullIfNotNull(nameof(node))]
public override Expression? Visit(Expression? node)
{
if (node == source)
return target;
return base.Visit(node);
}
}
}