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