diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..5dc46e6
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+* text=auto eol=lf
+*.{cmd,[cC][mM][dD]} text eol=crlf
+*.{bat,[bB][aA][tT]} text eol=crlf
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..69fa7f9
--- /dev/null
+++ b/.gitignore
@@ -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
+
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..600b92f
--- /dev/null
+++ b/.idea/.gitignore
@@ -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
diff --git a/.idea/.idea.Baguette.Querying/.idea/.gitignore b/.idea/.idea.Baguette.Querying/.idea/.gitignore
new file mode 100644
index 0000000..e37b0b4
--- /dev/null
+++ b/.idea/.idea.Baguette.Querying/.idea/.gitignore
@@ -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
diff --git a/.idea/.idea.Baguette.Querying/.idea/encodings.xml b/.idea/.idea.Baguette.Querying/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/.idea.Baguette.Querying/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Baguette.Querying/.idea/vcs.xml b/.idea/.idea.Baguette.Querying/.idea/vcs.xml
new file mode 100644
index 0000000..d843f34
--- /dev/null
+++ b/.idea/.idea.Baguette.Querying/.idea/vcs.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/indexLayout.xml b/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Baguette.Querying.sln b/Baguette.Querying.sln
new file mode 100644
index 0000000..1f43297
--- /dev/null
+++ b/Baguette.Querying.sln
@@ -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
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..7d3b56c
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "8.0.100",
+ "rollForward": "latestFeature"
+ }
+}
\ No newline at end of file
diff --git a/samples/Baguette.Querying.Console/Baguette.Querying.Console.csproj b/samples/Baguette.Querying.Console/Baguette.Querying.Console.csproj
new file mode 100644
index 0000000..3b8b621
--- /dev/null
+++ b/samples/Baguette.Querying.Console/Baguette.Querying.Console.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/Baguette.Querying.Console/Program.cs b/samples/Baguette.Querying.Console/Program.cs
new file mode 100644
index 0000000..b7368b2
--- /dev/null
+++ b/samples/Baguette.Querying.Console/Program.cs
@@ -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();
+var mapper = provider.GetRequiredService();
+
+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(mapper.ConfigurationProvider);
+var result = query.ToList();
+
+//
+// var json = JsonSerializer.Serialize(filters, typeof(ModelFilters));
+//
+// var filters2 = JsonSerializer.Deserialize(json);
+//
+// var result = list.AsQueryable().ApplyFilters(processor, filters).ToList();
+//
+// var pipelineProvider = provider.GetRequiredService();
+//
+// var result2 = list.AsQueryable().Where(pipelineProvider.GetPredicate(filters)).ToList();
+
+return;
+
+class MappingProfile : Profile
+{
+ public MappingProfile()
+ {
+ CreateMap();
+ }
+}
+
+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; }
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.EntityFrameworkCore/Baguette.Querying.EntityFrameworkCore.csproj b/src/Baguette.Querying.EntityFrameworkCore/Baguette.Querying.EntityFrameworkCore.csproj
new file mode 100644
index 0000000..ea3ab86
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/Baguette.Querying.EntityFrameworkCore.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Baguette.Querying.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs b/src/Baguette.Querying.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs
new file mode 100644
index 0000000..d9c7525
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/DbContextOptionsBuilderExtensions.cs
@@ -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();
+
+ // builder.AddInterceptors(new InjectQueryInterceptor());
+
+ return builder;
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.EntityFrameworkCore/DefaultQueryInjector.cs b/src/Baguette.Querying.EntityFrameworkCore/DefaultQueryInjector.cs
new file mode 100644
index 0000000..89a9e9c
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/DefaultQueryInjector.cs
@@ -0,0 +1,37 @@
+// using System.Linq.Expressions;
+// using Baguette.Querying.Visitors;
+// using Microsoft.EntityFrameworkCore.Query.Internal;
+//
+// namespace Baguette.Querying.EntityFrameworkCore;
+//
+// public class DefaultQueryInjector : IQueryInjector where TFilters : notnull where TModel : notnull
+// {
+// private readonly IQueryProcessor processor;
+//
+// public DefaultQueryInjector(IQueryProcessor processor)
+// {
+// this.processor = processor;
+// }
+//
+// private static IQueryable Empty { get; } = Enumerable.Empty().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
+
diff --git a/src/Baguette.Querying.EntityFrameworkCore/IQueryInjector.cs b/src/Baguette.Querying.EntityFrameworkCore/IQueryInjector.cs
new file mode 100644
index 0000000..039d174
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/IQueryInjector.cs
@@ -0,0 +1,14 @@
+// using System.Linq.Expressions;
+//
+// namespace Baguette.Querying.EntityFrameworkCore;
+//
+// public interface IQueryInjector
+// {
+// Expression Inject(Expression expression, object filters);
+// }
+//
+// public interface IQueryInjector : IQueryInjector where TFilters : notnull where TModel : notnull
+// {
+// Expression Inject(Expression expression, TFilters filters);
+// }
+
diff --git a/src/Baguette.Querying.EntityFrameworkCore/InfrastructureExtensions.cs b/src/Baguette.Querying.EntityFrameworkCore/InfrastructureExtensions.cs
new file mode 100644
index 0000000..cc26e79
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/InfrastructureExtensions.cs
@@ -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 infrastructure)
+ {
+ var extension = infrastructure.Instance.GetRequiredService().FindExtension();
+
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.EntityFrameworkCore/InjectQueryInterceptor.cs b/src/Baguette.Querying.EntityFrameworkCore/InjectQueryInterceptor.cs
new file mode 100644
index 0000000..00a2159
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/InjectQueryInterceptor.cs
@@ -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!);
+// }
+// }
+// }
+
diff --git a/src/Baguette.Querying.EntityFrameworkCore/QueryableProcessorExtensions.cs b/src/Baguette.Querying.EntityFrameworkCore/QueryableProcessorExtensions.cs
new file mode 100644
index 0000000..5f939e7
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/QueryableProcessorExtensions.cs
@@ -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> PaginateAsync(
+ // this IQueryable 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(results, count, !filters.Paging.ExcludeTotal);
+ // }
+ //
+ // public static IQueryable ApplyFilters(this IQueryable 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(Expression.Call(null, ApplyFiltersMethodInfoCache.MethodInfo, baseQuery.Expression, Expression.Constant(filters)));
+ // }
+ //
+ // private static class ApplyFiltersMethodInfoCache where TFilters : notnull where TModel : notnull
+ // {
+ // public static MethodInfo MethodInfo { get; } = ApplyFiltersGenericMethodInfo.MakeGenericMethod(typeof(TFilters), typeof(TModel));
+ // }
+
+ public static async Task> PaginateAsync(
+ this IQueryable 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(results, count, !filters.Paging.ExcludeTotal);
+ }
+
+ public static IQueryable ApplyFilters(this IQueryable 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 infrastructure)
+ throw new NotSupportedException("QueryProvider doesn't provide the service provider.");
+
+ var processor = infrastructure.GetService();
+
+ return processor.ProcessQuery(baseQuery, filters);
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.EntityFrameworkCore/QueryingDbContextOptionsExtension.cs b/src/Baguette.Querying.EntityFrameworkCore/QueryingDbContextOptionsExtension.cs
new file mode 100644
index 0000000..41d5873
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/QueryingDbContextOptionsExtension.cs
@@ -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) { }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.EntityFrameworkCore/QueryingDbContextOptionsExtensionInfo.cs b/src/Baguette.Querying.EntityFrameworkCore/QueryingDbContextOptionsExtensionInfo.cs
new file mode 100644
index 0000000..81f120c
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/QueryingDbContextOptionsExtensionInfo.cs
@@ -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 debugInfo) { }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.EntityFrameworkCore/ServiceQueryProvider.cs b/src/Baguette.Querying.EntityFrameworkCore/ServiceQueryProvider.cs
new file mode 100644
index 0000000..9ecd232
--- /dev/null
+++ b/src/Baguette.Querying.EntityFrameworkCore/ServiceQueryProvider.cs
@@ -0,0 +1,41 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Query.Internal;
+
+namespace Baguette.Querying.EntityFrameworkCore;
+
+// public class ServiceQueryProvider : IAsyncQueryProvider, IInfrastructure
+// #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.Instance => provider;
+//
+// public IQueryable CreateQuery(Expression expression) => inner.CreateQuery(expression);
+//
+// public IQueryable CreateQuery(Expression expression) => inner.CreateQuery(expression);
+//
+// public object? Execute(Expression expression) => inner.Execute(expression);
+//
+// public TResult Execute(Expression expression) => inner.Execute(expression);
+//
+// public TResult ExecuteAsync(Expression expression, CancellationToken cancellationToken = default) => inner.ExecuteAsync(expression, cancellationToken);
+// }
+#pragma warning disable EF1001
+public class ServiceQueryProvider : EntityQueryProvider, IInfrastructure
+#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.Instance => provider;
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/Baguette.Querying.Generator.Abstractions.csproj b/src/Baguette.Querying.Generator.Abstractions/Baguette.Querying.Generator.Abstractions.csproj
new file mode 100644
index 0000000..f265f5d
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/Baguette.Querying.Generator.Abstractions.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Baguette.Querying
+
+
+
diff --git a/src/Baguette.Querying.Generator.Abstractions/DisableSortingAttribute.cs b/src/Baguette.Querying.Generator.Abstractions/DisableSortingAttribute.cs
new file mode 100644
index 0000000..4b364b9
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/DisableSortingAttribute.cs
@@ -0,0 +1,4 @@
+namespace Baguette.Querying;
+
+[AttributeUsage(AttributeTargets.Property)]
+public class DisableSortingAttribute : Attribute;
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/FilterMode.cs b/src/Baguette.Querying.Generator.Abstractions/FilterMode.cs
new file mode 100644
index 0000000..55359bf
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/FilterMode.cs
@@ -0,0 +1,12 @@
+namespace Baguette.Querying;
+
+public enum FilterMode
+{
+ ByConvention,
+ Ignore,
+ Equal,
+ InRange,
+ Contains,
+ InList,
+ InDateRange
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/FilterModeAttribute.cs b/src/Baguette.Querying.Generator.Abstractions/FilterModeAttribute.cs
new file mode 100644
index 0000000..09bfe50
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/FilterModeAttribute.cs
@@ -0,0 +1,7 @@
+namespace Baguette.Querying;
+
+[AttributeUsage(AttributeTargets.Property)]
+public class FilterModeAttribute(FilterMode mode) : Attribute
+{
+ public FilterMode Mode { get; } = mode;
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/FilterNameAttribute.cs b/src/Baguette.Querying.Generator.Abstractions/FilterNameAttribute.cs
new file mode 100644
index 0000000..984bea3
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/FilterNameAttribute.cs
@@ -0,0 +1,7 @@
+namespace Baguette.Querying;
+
+[AttributeUsage(AttributeTargets.Property)]
+public class FilterNameAttribute(string name) : Attribute
+{
+ public string Name { get; } = name;
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/GenerateFiltersAttribute.cs b/src/Baguette.Querying.Generator.Abstractions/GenerateFiltersAttribute.cs
new file mode 100644
index 0000000..8ce8cb1
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/GenerateFiltersAttribute.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/GenerateNestedFiltersAttribute.cs b/src/Baguette.Querying.Generator.Abstractions/GenerateNestedFiltersAttribute.cs
new file mode 100644
index 0000000..3f01b60
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/GenerateNestedFiltersAttribute.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/SearchableAttribute.cs b/src/Baguette.Querying.Generator.Abstractions/SearchableAttribute.cs
new file mode 100644
index 0000000..087336a
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/SearchableAttribute.cs
@@ -0,0 +1,4 @@
+namespace Baguette.Querying;
+
+[AttributeUsage(AttributeTargets.Property)]
+public class SearchableAttribute : Attribute;
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator.Abstractions/SortAliasAttribute.cs b/src/Baguette.Querying.Generator.Abstractions/SortAliasAttribute.cs
new file mode 100644
index 0000000..f556bf7
--- /dev/null
+++ b/src/Baguette.Querying.Generator.Abstractions/SortAliasAttribute.cs
@@ -0,0 +1,7 @@
+namespace Baguette.Querying;
+
+[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
+public class SortAliasAttribute(string name) : Attribute
+{
+ public string Name { get; } = name;
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/ArgumentUtilities.cs b/src/Baguette.Querying.Generator/ArgumentUtilities.cs
new file mode 100644
index 0000000..1bd69bc
--- /dev/null
+++ b/src/Baguette.Querying.Generator/ArgumentUtilities.cs
@@ -0,0 +1,23 @@
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+
+namespace Baguette.Querying;
+
+public static class ArgumentUtilities
+{
+ public static T GetValue(IEnumerable> 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 GetValues(IEnumerable> collection, string key)
+ {
+ var t = collection.Where(p => p.Key == key).Select(p => p.Value).FirstOrDefault();
+
+ return t.IsNull
+ ? ImmutableArray.Empty
+ : t.Values.Select(c => (string?)c.Value).ToImmutableArray();
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/Baguette.Querying.Generator.csproj b/src/Baguette.Querying.Generator/Baguette.Querying.Generator.csproj
new file mode 100644
index 0000000..70eaebc
--- /dev/null
+++ b/src/Baguette.Querying.Generator/Baguette.Querying.Generator.csproj
@@ -0,0 +1,30 @@
+
+
+
+ netstandard2.0
+ latest
+ true
+ enable
+ true
+ true
+ Baguette.Querying
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
diff --git a/src/Baguette.Querying.Generator/Configuration/AttributeArgumentConstants.cs b/src/Baguette.Querying.Generator/Configuration/AttributeArgumentConstants.cs
new file mode 100644
index 0000000..b60c040
--- /dev/null
+++ b/src/Baguette.Querying.Generator/Configuration/AttributeArgumentConstants.cs
@@ -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);
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/Configuration/AttributeConstants.cs b/src/Baguette.Querying.Generator/Configuration/AttributeConstants.cs
new file mode 100644
index 0000000..0a7dacd
--- /dev/null
+++ b/src/Baguette.Querying.Generator/Configuration/AttributeConstants.cs
@@ -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)}";
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/Configuration/GenerationConstants.cs b/src/Baguette.Querying.Generator/Configuration/GenerationConstants.cs
new file mode 100644
index 0000000..29b70d2
--- /dev/null
+++ b/src/Baguette.Querying.Generator/Configuration/GenerationConstants.cs
@@ -0,0 +1,6 @@
+namespace Baguette.Querying.Configuration;
+
+public static class GenerationConstants
+{
+ public const string Indentation = " ";
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/Configuration/NamespaceConstants.cs b/src/Baguette.Querying.Generator/Configuration/NamespaceConstants.cs
new file mode 100644
index 0000000..8cc52fa
--- /dev/null
+++ b/src/Baguette.Querying.Generator/Configuration/NamespaceConstants.cs
@@ -0,0 +1,8 @@
+namespace Baguette.Querying.Configuration;
+
+public static class NamespaceConstants
+{
+ public const string RootNamespace = "Baguette.Querying";
+
+ public const string RangeNamespace = RootNamespace;
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/FilterGenerationContext.cs b/src/Baguette.Querying.Generator/FilterGenerationContext.cs
new file mode 100644
index 0000000..aebaaf4
--- /dev/null
+++ b/src/Baguette.Querying.Generator/FilterGenerationContext.cs
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/FilterGenerationContextBase.cs b/src/Baguette.Querying.Generator/FilterGenerationContextBase.cs
new file mode 100644
index 0000000..b68c0fd
--- /dev/null
+++ b/src/Baguette.Querying.Generator/FilterGenerationContextBase.cs
@@ -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 Properties { get; }
+
+ private static string CreateFullName(string targetNamespace, string targetName)
+ {
+ return !string.IsNullOrWhiteSpace(targetNamespace) ? $"global::{targetNamespace}.{targetName}" : targetName;
+ }
+
+ private static ImmutableArray GetProperties(INamespaceOrTypeSymbol symbol)
+ {
+ return symbol
+ .GetMembers()
+ .OfType()
+ .Where(p => p.GetMethod is not null && p.DeclaredAccessibility == Accessibility.Public)
+ .Select(x => new FilterProperty(x))
+ .ToImmutableArray();
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/FilterGenerationContextExtensions.cs b/src/Baguette.Querying.Generator/FilterGenerationContextExtensions.cs
new file mode 100644
index 0000000..2bee8ce
--- /dev/null
+++ b/src/Baguette.Querying.Generator/FilterGenerationContextExtensions.cs
@@ -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 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}";
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/FilterGenerator.cs b/src/Baguette.Querying.Generator/FilterGenerator.cs
new file mode 100644
index 0000000..d11c98b
--- /dev/null
+++ b/src/Baguette.Querying.Generator/FilterGenerator.cs
@@ -0,0 +1,41 @@
+using Baguette.Querying.Configuration;
+
+namespace Baguette.Querying;
+
+public class FilterGenerator(FilterGenerationContext context) : FilterGeneratorBase(context)
+{
+ protected override IEnumerable 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? 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();
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/FilterGeneratorBase.cs b/src/Baguette.Querying.Generator/FilterGeneratorBase.cs
new file mode 100644
index 0000000..5169962
--- /dev/null
+++ b/src/Baguette.Querying.Generator/FilterGeneratorBase.cs
@@ -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 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}}}");
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/FilterProperty.cs b/src/Baguette.Querying.Generator/FilterProperty.cs
new file mode 100644
index 0000000..efa21ae
--- /dev/null
+++ b/src/Baguette.Querying.Generator/FilterProperty.cs
@@ -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 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 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();
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/GeneratorBase.cs b/src/Baguette.Querying.Generator/GeneratorBase.cs
new file mode 100644
index 0000000..aea6495
--- /dev/null
+++ b/src/Baguette.Querying.Generator/GeneratorBase.cs
@@ -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("// ");
+ StringBuilder.AppendLine("#nullable enable");
+
+ WriteStartNamespace();
+ WriteStartBody();
+ WriteBody();
+ WriteEndBody();
+ WriteEndNamespace();
+
+ productionContext.AddSource(GeneratedFileName, StringBuilder.ToString());
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/IncrementalGenerator.cs b/src/Baguette.Querying.Generator/IncrementalGenerator.cs
new file mode 100644
index 0000000..991340f
--- /dev/null
+++ b/src/Baguette.Querying.Generator/IncrementalGenerator.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/NestedFilterGenerationContext.cs b/src/Baguette.Querying.Generator/NestedFilterGenerationContext.cs
new file mode 100644
index 0000000..8d1ee53
--- /dev/null
+++ b/src/Baguette.Querying.Generator/NestedFilterGenerationContext.cs
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/Properties/launchSettings.json b/src/Baguette.Querying.Generator/Properties/launchSettings.json
new file mode 100644
index 0000000..e9c0c6a
--- /dev/null
+++ b/src/Baguette.Querying.Generator/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Debug Console": {
+ "commandName": "DebugRoslynComponent",
+ "targetProject": "../../samples/Baguette.Querying.Console/Baguette.Querying.Console.csproj"
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/QueryProfileGenerator.cs b/src/Baguette.Querying.Generator/QueryProfileGenerator.cs
new file mode 100644
index 0000000..a76c1af
--- /dev/null
+++ b/src/Baguette.Querying.Generator/QueryProfileGenerator.cs
@@ -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}}}");
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying.Generator/TypeMapping.cs b/src/Baguette.Querying.Generator/TypeMapping.cs
new file mode 100644
index 0000000..5416d8c
--- /dev/null
+++ b/src/Baguette.Querying.Generator/TypeMapping.cs
@@ -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 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 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?", FilterKind.InList, 5);
+ yield return new TypeMapping("string?", "global::System.Collections.Generic.IReadOnlyCollection?", 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
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/Baguette.Querying.csproj b/src/Baguette.Querying/Baguette.Querying.csproj
new file mode 100644
index 0000000..b903a81
--- /dev/null
+++ b/src/Baguette.Querying/Baguette.Querying.csproj
@@ -0,0 +1,17 @@
+
+
+
+ net8.0
+ enable
+ enable
+ latest
+ Baguette.Querying
+ Provides querying based on a pre-defined model.
+
+
+
+
+
+
+
+
diff --git a/src/Baguette.Querying/DateOnlyFilterRange.cs b/src/Baguette.Querying/DateOnlyFilterRange.cs
new file mode 100644
index 0000000..3892291
--- /dev/null
+++ b/src/Baguette.Querying/DateOnlyFilterRange.cs
@@ -0,0 +1,8 @@
+namespace Baguette.Querying;
+
+public readonly record struct DateOnlyFilterRange
+{
+ public DateOnly? Min { get; init; }
+
+ public DateOnly? Max { get; init; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/DateTimeFilterRange.cs b/src/Baguette.Querying/DateTimeFilterRange.cs
new file mode 100644
index 0000000..4726bec
--- /dev/null
+++ b/src/Baguette.Querying/DateTimeFilterRange.cs
@@ -0,0 +1,8 @@
+namespace Baguette.Querying;
+
+public readonly record struct DateTimeFilterRange
+{
+ public DateTime? Min { get; init; }
+
+ public DateTime? Max { get; init; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/DateTimeOffsetFilterRange.cs b/src/Baguette.Querying/DateTimeOffsetFilterRange.cs
new file mode 100644
index 0000000..e188bd7
--- /dev/null
+++ b/src/Baguette.Querying/DateTimeOffsetFilterRange.cs
@@ -0,0 +1,8 @@
+namespace Baguette.Querying;
+
+public readonly record struct DateTimeOffsetFilterRange
+{
+ public DateTimeOffset? Min { get; init; }
+
+ public DateTimeOffset? Max { get; init; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/DefaultQueryBuilder.cs b/src/Baguette.Querying/DefaultQueryBuilder.cs
new file mode 100644
index 0000000..92ca24c
--- /dev/null
+++ b/src/Baguette.Querying/DefaultQueryBuilder.cs
@@ -0,0 +1,32 @@
+namespace Baguette.Querying;
+
+public class DefaultQueryBuilder : IQueryBuilder where TModel : notnull
+{
+ private readonly List, QueryPipeline>> queryBuilders = [];
+
+ public void Add(Func, TFilters, IQueryable> queryBuilder)
+ {
+ ArgumentNullException.ThrowIfNull(queryBuilder);
+
+ queryBuilders.Add(next => (query, filters) => next(queryBuilder(query, filters), filters));
+ }
+
+ public QueryPipeline Build()
+ {
+ var actualPipeline = BuildPipeline();
+
+ return InvokePipeline;
+
+ IQueryable InvokePipeline(IQueryable query, TFilters filters)
+ {
+ ArgumentNullException.ThrowIfNull(query);
+
+ return actualPipeline(query, filters);
+ }
+ }
+
+ private QueryPipeline BuildPipeline()
+ {
+ return queryBuilders.AsEnumerable().Reverse().Aggregate((QueryPipeline)((query, _) => query), (next, builder) => builder(next));
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/DefaultQueryPipelineProvider.cs b/src/Baguette.Querying/DefaultQueryPipelineProvider.cs
new file mode 100644
index 0000000..b3b13ff
--- /dev/null
+++ b/src/Baguette.Querying/DefaultQueryPipelineProvider.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Baguette.Querying;
+
+public class DefaultQueryPipelineProvider(IServiceProvider provider) : IQueryPipelineProvider
+{
+ public QueryPipeline GetPipeline() where TModel : notnull
+ {
+ return provider.GetRequiredService>().Pipeline;
+ }
+}
+
+public class DefaultQueryPipelineProvider(IEnumerable> profiles) : IQueryPipelineProvider
+ where TModel : notnull
+{
+ private QueryPipeline? pipeline;
+
+ public QueryPipeline Pipeline => pipeline ??= BuildPipeline();
+
+ private QueryPipeline BuildPipeline()
+ {
+ var builder = new DefaultQueryBuilder();
+
+ foreach (var profile in profiles)
+ {
+ profile.Configure(builder);
+ }
+
+ return builder.Build();
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/DefaultQueryProcessor.cs b/src/Baguette.Querying/DefaultQueryProcessor.cs
new file mode 100644
index 0000000..3b1e937
--- /dev/null
+++ b/src/Baguette.Querying/DefaultQueryProcessor.cs
@@ -0,0 +1,22 @@
+namespace Baguette.Querying;
+
+public class DefaultQueryProcessor : IQueryProcessor
+{
+ private readonly IQueryPipelineProvider pipelineProvider;
+
+ public DefaultQueryProcessor(IQueryPipelineProvider pipelineProvider)
+ {
+ this.pipelineProvider = pipelineProvider;
+ }
+
+ public IQueryable ProcessQuery(IQueryable 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()(baseQuery, filters);
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/FilterRange.cs b/src/Baguette.Querying/FilterRange.cs
new file mode 100644
index 0000000..7b4c990
--- /dev/null
+++ b/src/Baguette.Querying/FilterRange.cs
@@ -0,0 +1,10 @@
+using System.Numerics;
+
+namespace Baguette.Querying;
+
+public readonly record struct FilterRange where T : struct, INumber
+{
+ public T? Min { get; init; }
+
+ public T? Max { get; init; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/IMultiSortable.cs b/src/Baguette.Querying/IMultiSortable.cs
new file mode 100644
index 0000000..031e5f7
--- /dev/null
+++ b/src/Baguette.Querying/IMultiSortable.cs
@@ -0,0 +1,6 @@
+namespace Baguette.Querying;
+
+public interface IMultiSortable
+{
+ IEnumerable? Sorting { get; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/IPageable.cs b/src/Baguette.Querying/IPageable.cs
new file mode 100644
index 0000000..ec62851
--- /dev/null
+++ b/src/Baguette.Querying/IPageable.cs
@@ -0,0 +1,6 @@
+namespace Baguette.Querying;
+
+public interface IPageable
+{
+ PagingOptions Paging { get; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/IQueryBuilder.cs b/src/Baguette.Querying/IQueryBuilder.cs
new file mode 100644
index 0000000..4ff793e
--- /dev/null
+++ b/src/Baguette.Querying/IQueryBuilder.cs
@@ -0,0 +1,8 @@
+namespace Baguette.Querying;
+
+public interface IQueryBuilder where TModel : notnull
+{
+ void Add(Func, TFilters, IQueryable> queryBuilder);
+
+ QueryPipeline Build();
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/IQueryPipelineProvider.cs b/src/Baguette.Querying/IQueryPipelineProvider.cs
new file mode 100644
index 0000000..fdafcd2
--- /dev/null
+++ b/src/Baguette.Querying/IQueryPipelineProvider.cs
@@ -0,0 +1,11 @@
+namespace Baguette.Querying;
+
+public interface IQueryPipelineProvider
+{
+ QueryPipeline GetPipeline() where TModel : notnull;
+}
+
+public interface IQueryPipelineProvider where TModel : notnull
+{
+ QueryPipeline Pipeline { get; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/IQueryProcessor.cs b/src/Baguette.Querying/IQueryProcessor.cs
new file mode 100644
index 0000000..2af286a
--- /dev/null
+++ b/src/Baguette.Querying/IQueryProcessor.cs
@@ -0,0 +1,7 @@
+namespace Baguette.Querying;
+
+[Obsolete]
+public interface IQueryProcessor
+{
+ IQueryable ProcessQuery(IQueryable baseQuery, TFilters filters) where TModel : notnull;
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/IQueryProfile.cs b/src/Baguette.Querying/IQueryProfile.cs
new file mode 100644
index 0000000..44ff2ef
--- /dev/null
+++ b/src/Baguette.Querying/IQueryProfile.cs
@@ -0,0 +1,6 @@
+namespace Baguette.Querying;
+
+public interface IQueryProfile where TModel : notnull
+{
+ void Configure(IQueryBuilder builder);
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/ISortable.cs b/src/Baguette.Querying/ISortable.cs
new file mode 100644
index 0000000..6d76528
--- /dev/null
+++ b/src/Baguette.Querying/ISortable.cs
@@ -0,0 +1,6 @@
+namespace Baguette.Querying;
+
+public interface ISortable
+{
+ SortingOptions Sorting { get; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/LambdaQueryProfile.cs b/src/Baguette.Querying/LambdaQueryProfile.cs
new file mode 100644
index 0000000..4a7a837
--- /dev/null
+++ b/src/Baguette.Querying/LambdaQueryProfile.cs
@@ -0,0 +1,9 @@
+namespace Baguette.Querying;
+
+public class LambdaQueryProfile(Action> configure) : IQueryProfile
+ where TModel : notnull
+{
+ private readonly Action> configure = configure ?? throw new ArgumentNullException(nameof(configure));
+
+ public void Configure(IQueryBuilder builder) => configure(builder);
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/PaginatedResult.cs b/src/Baguette.Querying/PaginatedResult.cs
new file mode 100644
index 0000000..052cb66
--- /dev/null
+++ b/src/Baguette.Querying/PaginatedResult.cs
@@ -0,0 +1,13 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace Baguette.Querying;
+
+public class PaginatedResult(IEnumerable items, int? total, bool hasTotal)
+{
+ public IEnumerable Items { get; } = items;
+
+ public int? Total { get; } = total;
+
+ [MemberNotNullWhen(true, nameof(Total))]
+ public bool HasTotal { get; } = hasTotal;
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/PagingOptions.cs b/src/Baguette.Querying/PagingOptions.cs
new file mode 100644
index 0000000..459e16e
--- /dev/null
+++ b/src/Baguette.Querying/PagingOptions.cs
@@ -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; }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryBuilderExtensions.cs b/src/Baguette.Querying/QueryBuilderExtensions.cs
new file mode 100644
index 0000000..cea2932
--- /dev/null
+++ b/src/Baguette.Querying/QueryBuilderExtensions.cs
@@ -0,0 +1,73 @@
+using System.Linq.Expressions;
+using Baguette.Querying.Visitors;
+
+namespace Baguette.Querying;
+
+public static class QueryBuilderExtensions
+{
+ public static void AddTransform(
+ this IQueryBuilder builder, Func selector,
+ Action> configureFilter)
+ where TModel : notnull
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(selector);
+ ArgumentNullException.ThrowIfNull(configureFilter);
+
+ var filterBuilder = new DefaultQueryBuilder();
+
+ configureFilter(filterBuilder);
+
+ var transformPipeline = filterBuilder.Build();
+
+ builder.Add((query, filters) => transformPipeline(query, selector(filters)));
+ }
+
+ public static void AddFilter(
+ this IQueryBuilder builder,
+ Expression> predicate,
+ Func? 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>(replacedPredicate, predicate.Parameters[1]));
+ });
+ }
+
+ public static void AddPropertyFilter(
+ this IQueryBuilder builder,
+ Func filterSelector,
+ Expression> modelSelector,
+ Expression> predicate,
+ Func? 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>(replacedExpression, modelSelector.Parameters));
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryBuilderFilterExtensions.cs b/src/Baguette.Querying/QueryBuilderFilterExtensions.cs
new file mode 100644
index 0000000..48df7c6
--- /dev/null
+++ b/src/Baguette.Querying/QueryBuilderFilterExtensions.cs
@@ -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(
+ this IQueryBuilder builder,
+ Func filterSelector,
+ params Expression>[] 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>(expression, filterParameter, modelParameter);
+
+ builder.AddTransform(x => filterSelector(x)?.ToLower(), nestedBuilder => nestedBuilder.AddFilter(predicate, filter => !string.IsNullOrEmpty(filter)));
+ }
+
+ public static void AddTextFilter(
+ this IQueryBuilder builder,
+ Func filterSelector,
+ Expression> 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(
+ this IQueryBuilder builder,
+ Func?> filterSelector,
+ Expression> 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(
+ this IQueryBuilder builder,
+ Func filterSelector,
+ Expression> 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(
+ this IQueryBuilder builder,
+ Func> filterSelector,
+ Expression> modelSelector)
+ where TFilters : notnull where TModel : notnull where T : struct, INumber
+ {
+ var rangeParameter = Expression.Parameter(typeof(FilterRange), "range");
+ var modelParameter = Expression.Parameter(typeof(T), "model");
+
+ var minProperty = Expression.Property(rangeParameter, typeof(FilterRange), "Min");
+ var maxProperty = Expression.Property(rangeParameter, typeof(FilterRange), "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, T, bool>>(Expression.AndAlso(minExpression, maxExpression), rangeParameter, modelParameter),
+ range => range.Min is not null || range.Max is not null);
+ }
+
+ public static void AddInDateRangeFilter(
+ this IQueryBuilder builder,
+ Func filterSelector,
+ Expression> 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(
+ this IQueryBuilder builder,
+ Func filterSelector,
+ Expression> 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(
+ this IQueryBuilder builder,
+ Func filterSelector,
+ Expression> 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);
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryBuilderSortingExtensions.cs b/src/Baguette.Querying/QueryBuilderSortingExtensions.cs
new file mode 100644
index 0000000..24ff92a
--- /dev/null
+++ b/src/Baguette.Querying/QueryBuilderSortingExtensions.cs
@@ -0,0 +1,50 @@
+using System.Linq.Expressions;
+
+namespace Baguette.Querying;
+
+public static class QueryBuilderSortingExtensions
+{
+ public static void AddSorting(
+ this IQueryBuilder builder,
+ string key,
+ Expression> 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)
+ ? ((IOrderedQueryable)query).ThenBy(selector, filters.Sorting.SortDescending)
+ : query.OrderBy(selector, filters.Sorting.SortDescending);
+ });
+ }
+
+ public static void AddMultiSorting(
+ this IQueryBuilder builder,
+ Action> configureSorting)
+ where TFilters : IMultiSortable where TModel : notnull
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configureSorting);
+
+ var sortingBuilder = new DefaultQueryBuilder();
+
+ 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)));
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryPipeline.cs b/src/Baguette.Querying/QueryPipeline.cs
new file mode 100644
index 0000000..45cb86e
--- /dev/null
+++ b/src/Baguette.Querying/QueryPipeline.cs
@@ -0,0 +1,3 @@
+namespace Baguette.Querying;
+
+public delegate IQueryable QueryPipeline(IQueryable query, TFilters filters) where TModel : notnull;
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryPipelineProviderExtensions.cs b/src/Baguette.Querying/QueryPipelineProviderExtensions.cs
new file mode 100644
index 0000000..8ba545d
--- /dev/null
+++ b/src/Baguette.Querying/QueryPipelineProviderExtensions.cs
@@ -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> GetPredicate(this IQueryPipelineProvider provider, TFilters filters) where TFilters : notnull where TModel : notnull
+ {
+ ArgumentNullException.ThrowIfNull(provider);
+
+ var pipeline = provider.GetPipeline();
+ var query = pipeline(Enumerable.Empty().AsQueryable(), filters);
+
+ var visitor = new WhereExpressionVisitor();
+
+ visitor.Visit(query.Expression);
+
+ return visitor.Predicate;
+ }
+
+ private class WhereExpressionVisitor : ExpressionVisitor
+ {
+ private readonly MethodInfo method = new Func, Expression>, IQueryable>(Queryable.Where).Method;
+ private readonly List>> predicates = [];
+
+ public Expression> Predicate
+ {
+ get
+ {
+ if (predicates.Count == 0)
+ return _ => true;
+
+ return predicates.Aggregate((res, curr) =>
+ {
+ var parameter = Expression.Parameter(typeof(TModel), "model");
+
+ return Expression.Lambda>(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>)unary.Operand);
+
+ return base.VisitMethodCall(node);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryServiceCollectionExtensions.cs b/src/Baguette.Querying/QueryServiceCollectionExtensions.cs
new file mode 100644
index 0000000..b350760
--- /dev/null
+++ b/src/Baguette.Querying/QueryServiceCollectionExtensions.cs
@@ -0,0 +1,46 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Baguette.Querying;
+
+public static class QueryServiceCollectionExtensions
+{
+ public static IServiceCollection ConfigureQuery(
+ this IServiceCollection services,
+ Action> configure)
+ where TModel : notnull
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.AddQuerying();
+ services.AddSingleton>(new LambdaQueryProfile(configure));
+
+ return services;
+ }
+
+ public static IServiceCollection AddQuerying(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ services.TryAddTransient();
+ services.TryAddSingleton(typeof(IQueryPipelineProvider<,>), typeof(DefaultQueryPipelineProvider<,>));
+ services.TryAddSingleton();
+
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryableExtensions.cs b/src/Baguette.Querying/QueryableExtensions.cs
new file mode 100644
index 0000000..840ed80
--- /dev/null
+++ b/src/Baguette.Querying/QueryableExtensions.cs
@@ -0,0 +1,16 @@
+using System.Linq.Expressions;
+
+namespace Baguette.Querying;
+
+public static class QueryableExtensions
+{
+ public static IOrderedQueryable OrderBy(this IQueryable query, Expression> selector, bool desc)
+ {
+ return desc ? query.OrderByDescending(selector) : query.OrderBy(selector);
+ }
+
+ public static IOrderedQueryable ThenBy(this IOrderedQueryable query, Expression> selector, bool desc)
+ {
+ return desc ? query.ThenByDescending(selector) : query.ThenBy(selector);
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/QueryableProcessorExtensions.cs b/src/Baguette.Querying/QueryableProcessorExtensions.cs
new file mode 100644
index 0000000..f46aefd
--- /dev/null
+++ b/src/Baguette.Querying/QueryableProcessorExtensions.cs
@@ -0,0 +1,26 @@
+namespace Baguette.Querying;
+
+public static class QueryableProcessorExtensions
+{
+ public static IQueryable ApplyFilters(this IQueryable 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 Paginate(this IQueryable 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(results, count, !filters.Paging.ExcludeTotal);
+ }
+}
\ No newline at end of file
diff --git a/src/Baguette.Querying/SortingFilter.cs b/src/Baguette.Querying/SortingFilter.cs
new file mode 100644
index 0000000..35bca23
--- /dev/null
+++ b/src/Baguette.Querying/SortingFilter.cs
@@ -0,0 +1,3 @@
+namespace Baguette.Querying;
+
+public record SortingFilter(SortingOptions Sorting) : ISortable;
\ No newline at end of file
diff --git a/src/Baguette.Querying/SortingOptions.cs b/src/Baguette.Querying/SortingOptions.cs
new file mode 100644
index 0000000..1267267
--- /dev/null
+++ b/src/Baguette.Querying/SortingOptions.cs
@@ -0,0 +1,3 @@
+namespace Baguette.Querying;
+
+public readonly record struct SortingOptions(string? SortBy, bool SortDescending);
\ No newline at end of file
diff --git a/src/Baguette.Querying/Visitors/ExpressionExtensions.cs b/src/Baguette.Querying/Visitors/ExpressionExtensions.cs
new file mode 100644
index 0000000..82f27ab
--- /dev/null
+++ b/src/Baguette.Querying/Visitors/ExpressionExtensions.cs
@@ -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);
+ }
+ }
+}
\ No newline at end of file