diff --git a/.github/workflows/angular.yml b/.github/workflows/angular.yml index 841a0d944d..c8c9b56dd4 100644 --- a/.github/workflows/angular.yml +++ b/.github/workflows/angular.yml @@ -27,8 +27,9 @@ jobs: build-test-lint: if: ${{ !github.event.pull_request.draft }} runs-on: ubuntu-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/auto-merge-forward.yml b/.github/workflows/auto-merge-forward.yml new file mode 100644 index 0000000000..187bf488fb --- /dev/null +++ b/.github/workflows/auto-merge-forward.yml @@ -0,0 +1,144 @@ +name: Auto-merge forward + +# Push to a rel-x.y branch opens a merge PR into the next newer rel-* line, +# or into dev when this line is the newest. Merge of that PR retriggers the +# next hop, so a bug-fix on rel-1.0 flows rel-1.0 -> rel-1.1 -> ... -> dev. +on: + push: + branches: + - 'rel-*' + workflow_dispatch: + +concurrency: + group: auto-merge-forward-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + forward: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve forward target + id: target + run: | + set -euo pipefail + SOURCE="${GITHUB_REF_NAME}" + if [[ ! "$SOURCE" =~ ^rel-[0-9]+\.[0-9]+$ ]]; then + echo "Not a rel-x.y branch ($SOURCE); skipping." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + git fetch origin --prune + + mapfile -t RELS < <( + git ls-remote --heads origin 'rel-*' \ + | awk '{print $2}' \ + | sed 's|refs/heads/||' \ + | grep -E '^rel-[0-9]+\.[0-9]+$' \ + | sort -t. -k1.5,1n -k2,2n + ) + + TARGET="dev" + found=0 + for branch in "${RELS[@]}"; do + if [[ "$found" -eq 1 ]]; then + TARGET="$branch" + break + fi + if [[ "$branch" == "$SOURCE" ]]; then + found=1 + fi + done + + if [[ "$found" -eq 0 ]]; then + echo "::error::Source branch $SOURCE was not listed among origin rel-* heads." + exit 1 + fi + + if ! git rev-parse --verify "origin/$TARGET" >/dev/null 2>&1; then + echo "::error::Target branch origin/$TARGET does not exist." + exit 1 + fi + + if git merge-base --is-ancestor "origin/$SOURCE" "origin/$TARGET"; then + echo "origin/$SOURCE is already an ancestor of origin/$TARGET; nothing to forward." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "skip=false" >> "$GITHUB_OUTPUT" + echo "source=$SOURCE" >> "$GITHUB_OUTPUT" + echo "target=$TARGET" >> "$GITHUB_OUTPUT" + echo "Auto-merge forward: $SOURCE -> $TARGET" + + - name: Merge into forward branch + if: steps.target.outputs.skip != 'true' + id: merge + run: | + set -euo pipefail + SOURCE="${{ steps.target.outputs.source }}" + TARGET="${{ steps.target.outputs.target }}" + FORWARD_BRANCH="auto-merge-forward/${SOURCE}-to-${TARGET}-${{ github.run_number }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git checkout -B "$FORWARD_BRANCH" "origin/$TARGET" + if git merge --no-edit "origin/$SOURCE"; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + else + git merge --abort + git checkout -B "$FORWARD_BRANCH" "origin/$SOURCE" + echo "conflict=true" >> "$GITHUB_OUTPUT" + echo "::warning::Merge conflict forwarding ${SOURCE} to ${TARGET}. PR left open for manual resolution." + fi + + git push origin "$FORWARD_BRANCH" + echo "branch=$FORWARD_BRANCH" >> "$GITHUB_OUTPUT" + + - name: Create pull request + if: steps.target.outputs.skip != 'true' + id: pr + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + SOURCE="${{ steps.target.outputs.source }}" + TARGET="${{ steps.target.outputs.target }}" + FORWARD_BRANCH="${{ steps.merge.outputs.branch }}" + CONFLICT="${{ steps.merge.outputs.conflict }}" + + BODY="Automated forward merge of \`${SOURCE}\` into \`${TARGET}\`." + if [[ "$CONFLICT" == "true" ]]; then + BODY+=$'\n\n**Merge conflict:** this branch is \`${SOURCE}\` as-is. Resolve against \`${TARGET}\` before merging.' + fi + + URL="$(gh pr create \ + --base "$TARGET" \ + --head "$FORWARD_BRANCH" \ + --title "Auto-merge forward ${SOURCE} → ${TARGET}" \ + --body "$BODY")" + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "Created $URL" + + # BOT_SECRET, not github.token: a merge performed with the default token produces a push + # that triggers no workflow, which would stop the chain at the first hop. + - name: Approve and auto-merge + if: steps.target.outputs.skip != 'true' && steps.merge.outputs.conflict != 'true' + env: + GH_TOKEN: ${{ secrets.BOT_SECRET }} + run: | + set -euo pipefail + FORWARD_BRANCH="${{ steps.merge.outputs.branch }}" + gh pr review "$FORWARD_BRANCH" --approve + gh pr merge "$FORWARD_BRANCH" --merge --auto --delete-branch diff --git a/.github/workflows/auto-pr.yml b/.github/workflows/auto-pr.yml deleted file mode 100644 index fb800ff51e..0000000000 --- a/.github/workflows/auto-pr.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Merge branch dev with rel-10.7 -on: - push: - branches: - - rel-10.7 -permissions: - contents: read - -jobs: - merge-dev-with-rel-10-7: - permissions: - contents: write # for peter-evans/create-pull-request to create branch - pull-requests: write # for peter-evans/create-pull-request to create a PR - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - ref: dev - - name: Reset promotion branch - run: | - git fetch origin rel-10.7:rel-10.7 - git reset --hard rel-10.7 - - name: Create Pull Request - uses: peter-evans/create-pull-request@v3 - with: - branch: auto-merge/rel-10-7/${{github.run_number}} - title: Merge branch dev with rel-10.7 - body: This PR generated automatically to merge dev with rel-10.7. Please review the changed files before merging to prevent any errors that may occur. - draft: true - token: ${{ github.token }} - - name: Merge Pull Request - env: - GH_TOKEN: ${{ secrets.BOT_SECRET }} - run: | - gh pr ready - gh pr review auto-merge/rel-10-7/${{github.run_number}} --approve - gh pr merge auto-merge/rel-10-7/${{github.run_number}} --merge --auto --delete-branch diff --git a/.github/workflows/image-compression.yml b/.github/workflows/image-compression.yml index ed4111cdd3..2f9840227a 100644 --- a/.github/workflows/image-compression.yml +++ b/.github/workflows/image-compression.yml @@ -22,9 +22,10 @@ jobs: if: github.event.pull_request.head.repo.full_name == github.repository && !github.event.pull_request.draft name: calibreapp/image-actions runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Checkout Repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Compress Images uses: calibreapp/image-actions@main diff --git a/docs/en/framework/architecture/domain-driven-design/application-services.md b/docs/en/framework/architecture/domain-driven-design/application-services.md index 8683241534..a2d2db5acc 100644 --- a/docs/en/framework/architecture/domain-driven-design/application-services.md +++ b/docs/en/framework/architecture/domain-driven-design/application-services.md @@ -444,6 +444,7 @@ These methods are low level methods that can control how to query entities from * `ApplyPaging` is used to make paging on the query. If your `TGetListInput` already implements `IPagedResultRequest`, you don't need to override this since the ABP automatically understands it and performs the paging. * `ApplySorting` is used to sort (order by...) the query. If your `TGetListInput` already implements the `ISortedResultRequest`, ABP automatically sorts the query. If not, it fallbacks to the `ApplyDefaultSorting` which tries to sort by creation time, if your entity implements the standard `IHasCreationTime` interface. * `GetEntityByIdAsync` is used to get an entity by id, which calls `Repository.GetAsync(id)` by default. +* `CreateEntityQueryOrNullAsync` is used to create a query for a single entity by id, which is only needed for the *Query Projection* explained below. It returns `null` if the application service can not create such a query, then `GetEntityByIdAsync` is used. * `DeleteByIdAsync` is used to delete an entity by id, which calls `Repository.DeleteAsync(id)` by default. #### Object to Object Mapping @@ -456,6 +457,103 @@ These methods are used to convert Entities to DTOs and vice verse. They use the * `MapToEntityAsync(TCreateInput)` is used to create an entity from `TCreateInput`. * `MapToEntityAsync(TUpdateInput, TEntity)` is used to update an existing entity from `TUpdateInput`. +#### Query Projection + +`GetAsync` and `GetListAsync` get the entities from the database, then map them to DTOs in the memory. If your DTO uses only a few properties of a large entity, you can project the query to the DTO instead, so the database returns only the columns you need. + +Implement the `IQueryProjector` interface to define a projection: + +````csharp +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace MyProject.Books; + +public class BookProjector : IQueryProjector +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name + }); + } +} +```` + +You don't have to write the `Select` by hand. Both [Mapperly](https://mapperly.riok.app/) and [AutoMapper](https://docs.automapper.org) can project an `IQueryable`, refer to their own documentation for it and to the [object to object mapping document](../../infrastructure/object-to-object-mapping.md) for their ABP integrations. Your existing maps are not used for the projection, a projector is always a class implementing `IQueryProjector`. + +ABP registers the projectors by convention, you don't need to configure anything else. Implement a projector once for an entity and DTO pair, and use the `ReplaceServices` option of the `DependencyAttribute` to replace an existing one. Filters (like soft delete and multi-tenancy), sorting and paging are still applied to the query before the projection. + +> A projection must return one row per entity. The total count and the paging are calculated on the entity query before the projection runs, so a projection that filters out rows (an inner join to an optional relation) or multiplies them (a join to a collection) returns a page that doesn't match the reported total count. Use a left join for optional relations. + +The projector is synchronous, so it can not obtain the query of another aggregate root, which is only +available through the asynchronous `GetQueryableAsync`. Override `CreateGetOutputDtoQueryOrNullAsync` or +`CreateGetListOutputDtoQueryOrNullAsync` for that. They replace the projector for that application service: + +````csharp +public class BookAppService : ReadOnlyAppService +{ + private readonly IBookDtoQuery _bookDtoQuery; + + //... + + protected override async Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return await _bookDtoQuery.ProjectAsync(query); + } +} + +//The projection is a class of its own, so the other application services returning a BookDto reuse it +public class BookDtoQuery : IBookDtoQuery, ITransientDependency +{ + private readonly IReadOnlyRepository _authorRepository; + + //... + + public async Task> ProjectAsync(IQueryable books) + { + var authors = await _authorRepository.GetQueryableAsync(); + + return from book in books + join author in authors on book.AuthorId equals author.Id into bookAuthors + from bookAuthor in bookAuthors.DefaultIfEmpty() + select new BookDto + { + Id = book.Id, + Name = book.Name, + AuthorName = bookAuthor != null ? bookAuthor.Name : null + }; + } +} +```` + +Both queries must come from the same database context, otherwise they can not be executed as a single query, +and the provider has to be able to translate the join. The one row per entity rule above applies here too, +that's why the example uses a left join. A joined column can not be used for the sorting, and the paging is +based on the entity query, since both are applied before this method is called. + +A projector is resolved by the `(entity, DTO)` type pair, just like an `IObjectMapper`, so registering one enables the projection for every application service using that pair. It replaces the way the DTOs are read: + +* `GetListAsync` doesn't use `MapToGetListOutputDtosAsync` anymore. +* `GetAsync` doesn't use `GetEntityByIdAsync` and `MapToGetOutputDtoAsync` anymore, as long as the application service can create a query for a single entity. `ReadOnlyAppService` and `CrudAppService` already do that. A class deriving from `AbstractKeyReadOnlyAppService` has to override `CreateEntityQueryOrNullAsync`, otherwise `GetAsync` keeps loading the entity and mapping it. + +The rest of the pipeline is untouched. The authorization policies are still checked, `CreateFilteredQueryAsync`, `ApplySorting` and `ApplyPaging` are still used, the data filters (like soft delete and multi-tenancy) are still applied, and the create, update and delete methods still use the [IObjectMapper](../../infrastructure/object-to-object-mapping.md). + +> If an application service needs to keep using the entity based extension points, override the `GetOutputDtoQueryProjector` or `GetListOutputDtoQueryProjector` property and return `null`: + +````csharp +public class BookAppService : CrudAppService +{ + protected override IQueryProjector? GetOutputDtoQueryProjector => null; + + protected override IQueryProjector? GetListOutputDtoQueryProjector => null; + + //... +} +```` + ## Miscellaneous ### Working with Streams diff --git a/docs/en/framework/ui/mvc-razor-pages/bundling-minification.md b/docs/en/framework/ui/mvc-razor-pages/bundling-minification.md index 0114361188..5e6078c155 100644 --- a/docs/en/framework/ui/mvc-razor-pages/bundling-minification.md +++ b/docs/en/framework/ui/mvc-razor-pages/bundling-minification.md @@ -35,7 +35,7 @@ The simplest way of creating a bundle is to use `abp-script-bundle` or `abp-styl - + ```` @@ -65,7 +65,7 @@ The `name` is **optional** for the razor bundle tag helpers. If you don't define - + @if (ViewBag.IncludeCustomStyles != false) { @@ -115,7 +115,7 @@ public class MyWebModule : AbpModule bundle.AddFiles( "/libs/jquery/jquery.js", "/libs/bootstrap/js/bootstrap.js", - "/libs/toastr/toastr.min.js", + "/libs/lodash/lodash.min.js", "/scripts/my-global-scripts.js" ); }); @@ -220,7 +220,7 @@ Contributors can also be used in the bundle tag helpers. Example: - + ```` @@ -386,7 +386,7 @@ Configure(options => ````html - + ```` ### External/CDN file Support diff --git a/docs/en/framework/ui/mvc-razor-pages/overall.md b/docs/en/framework/ui/mvc-razor-pages/overall.md index b029fda57c..5e0fc41435 100644 --- a/docs/en/framework/ui/mvc-razor-pages/overall.md +++ b/docs/en/framework/ui/mvc-razor-pages/overall.md @@ -57,14 +57,11 @@ There are a set of standard JavaScript/CSS libraries that comes pre-installed an - [JQuery Validation](https://jqueryvalidation.org/) for client side & [unobtrusive](https://github.com/aspnet/jquery-validation-unobtrusive) validation - [FontAwesome](https://fontawesome.com/) as the fundamental CSS font library. - [SweetAlert](https://sweetalert.js.org/) to show fancy alert message and confirmation dialogs. -- [Toastr](https://github.com/CodeSeven/toastr) to show toast notifications. - [Lodash](https://lodash.com/) as a utility library. - [Luxon](https://moment.github.io/luxon/) for date/time operations. -- [JQuery Form](https://github.com/jquery-form/form) for AJAX forms. - [bootstrap-datepicker](https://github.com/uxsolutions/bootstrap-datepicker) to show date pickers. - [Select2](https://select2.org/) for better select/combo boxes. -- [Timeago](http://timeago.yarp.com/) to show automatically updating fuzzy timestamps. -- [malihu-custom-scrollbar-plugin](https://github.com/malihu/malihu-custom-scrollbar-plugin) for custom scrollbars. +- [timeago.js](https://timeago.org/) to show automatically updating fuzzy timestamps. You can use these libraries directly in your applications, without needing to manually import your page. @@ -140,7 +137,7 @@ ABP provides a flexible and modular Bundling & Minification system to create bun - + ``` diff --git a/docs/en/framework/ui/mvc-razor-pages/theming.md b/docs/en/framework/ui/mvc-razor-pages/theming.md index 138b52e3d2..2508ccb851 100644 --- a/docs/en/framework/ui/mvc-razor-pages/theming.md +++ b/docs/en/framework/ui/mvc-razor-pages/theming.md @@ -44,14 +44,11 @@ All the themes must depend on the [@abp/aspnetcore.mvc.ui.theme.shared](https:// * [JQuery Validation](https://github.com/jquery-validation/jquery-validation) for client side & [unobtrusive](https://github.com/aspnet/jquery-validation-unobtrusive) validation * [FontAwesome](https://fontawesome.com/) as the fundamental CSS font library. * [SweetAlert](https://sweetalert.js.org/) to show fancy alert message and confirmation dialogs. -* [Toastr](https://github.com/CodeSeven/toastr) to show toast notifications. * [Lodash](https://lodash.com/) as a utility library. * [Luxon](https://moment.github.io/luxon/) for date/time operations. -* [JQuery Form](https://github.com/jquery-form/form) for AJAX forms. * [bootstrap-datepicker](https://github.com/uxsolutions/bootstrap-datepicker) to show date pickers. * [Select2](https://select2.org/) for better select/combo boxes. -* [Timeago](http://timeago.yarp.com/) to show automatically updating fuzzy timestamps. -* [malihu-custom-scrollbar-plugin](https://github.com/malihu/malihu-custom-scrollbar-plugin) for custom scrollbars. +* [timeago.js](https://timeago.org/) to show automatically updating fuzzy timestamps. These libraries are selected as the base libraries and available to the applications and modules. @@ -60,7 +57,7 @@ These libraries are selected as the base libraries and available to the applicat There are some abstractions in the ABP to make your code independent from some of these libraries too. Examples; * [Tag Helpers](tag-helpers) makes it easy to generate the Bootstrap UIs. -* JavaScript [Message](javascript-api/message.md) and [Notification](javascript-api/notify.md) APIs provides abstractions to use the Sweetalert and Toastr. +* JavaScript [Message](javascript-api/message.md) and [Notification](javascript-api/notify.md) APIs provides abstractions to use the Sweetalert and the built-in toast implementation. * [Forms & Validation](forms-validation.md) system automatically handles the validation, so you mostly don't directly type any validation code. ### The Standard Layouts diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/AbpAspNetCoreMvcUiPackagesModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/AbpAspNetCoreMvcUiPackagesModule.cs index 7ba8105d43..0c4bab0b04 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/AbpAspNetCoreMvcUiPackagesModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/AbpAspNetCoreMvcUiPackagesModule.cs @@ -2,7 +2,6 @@ using Volo.Abp.AspNetCore.Mvc.UI.Packages.BootstrapDatepicker; using Volo.Abp.AspNetCore.Mvc.UI.Packages.JQueryValidation; using Volo.Abp.AspNetCore.Mvc.UI.Packages.Moment; -using Volo.Abp.AspNetCore.Mvc.UI.Packages.Timeago; using Volo.Abp.Localization; using Volo.Abp.Modularity; @@ -38,11 +37,6 @@ public class AbpAspNetCoreMvcUiPackagesModule : AbpModule new NameValue("zh-Hant", "zh-tw"), new NameValue("de-DE", "de")); - //Timeago - options.AddLanguageFilesMapOrUpdate(TimeagoScriptContributor.PackageName, - new NameValue("zh-Hans", "zh-CN"), - new NameValue("zh-Hant", "zh-TW")); - //JQueryValidation options.AddLanguageFilesMapOrUpdate(JQueryValidationScriptContributor.PackageName, new NameValue("zh-Hans", "zh"), diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/BootstrapDatepicker/BootstrapDatepickerScriptContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/BootstrapDatepicker/BootstrapDatepickerScriptContributor.cs index dac2c051aa..eef9f3478e 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/BootstrapDatepicker/BootstrapDatepickerScriptContributor.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/BootstrapDatepicker/BootstrapDatepickerScriptContributor.cs @@ -14,7 +14,7 @@ public class BootstrapDatepickerScriptContributor : BundleContributor public override void ConfigureBundle(BundleConfigurationContext context) { - context.Files.AddIfNotContains("/libs/bootstrap-datepicker/bootstrap-datepicker.min.js"); + context.Files.AddIfNotContains("/libs/bootstrap-datepicker/bootstrap-datepicker.js"); } public override void ConfigureDynamicResources(BundleConfigurationContext context) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/MalihuCustomScrollbar/MalihuCustomScrollbarPluginScriptBundleContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/MalihuCustomScrollbar/MalihuCustomScrollbarPluginScriptBundleContributor.cs deleted file mode 100644 index 30639e1583..0000000000 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/MalihuCustomScrollbar/MalihuCustomScrollbarPluginScriptBundleContributor.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; -using Volo.Abp.AspNetCore.Mvc.UI.Bundling; - -namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.MalihuCustomScrollbar; - -public class MalihuCustomScrollbarPluginScriptBundleContributor : BundleContributor -{ - public override void ConfigureBundle(BundleConfigurationContext context) - { - context.Files.AddIfNotContains("/libs/malihu-custom-scrollbar-plugin/jquery.mCustomScrollbar.concat.min.js"); - } -} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/MalihuCustomScrollbar/MalihuCustomScrollbarPluginStyleBundleContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/MalihuCustomScrollbar/MalihuCustomScrollbarPluginStyleBundleContributor.cs deleted file mode 100644 index d24ccb9f77..0000000000 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/MalihuCustomScrollbar/MalihuCustomScrollbarPluginStyleBundleContributor.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Collections.Generic; -using Volo.Abp.AspNetCore.Mvc.UI.Bundling; - -namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.MalihuCustomScrollbar; - -public class MalihuCustomScrollbarPluginStyleBundleContributor : BundleContributor -{ - public override void ConfigureBundle(BundleConfigurationContext context) - { - context.Files.AddIfNotContains("/libs/malihu-custom-scrollbar-plugin/jquery.mCustomScrollbar.css"); - } -} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/OwlCarousel/OwlCarouselScriptContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/OwlCarousel/OwlCarouselScriptContributor.cs new file mode 100644 index 0000000000..41172a9539 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/OwlCarousel/OwlCarouselScriptContributor.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; +using Volo.Abp.AspNetCore.Mvc.UI.Packages.JQuery; +using Volo.Abp.Modularity; + +namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.OwlCarousel; + +[DependsOn(typeof(JQueryScriptContributor))] +public class OwlCarouselScriptContributor : BundleContributor +{ + public override void ConfigureBundle(BundleConfigurationContext context) + { + context.Files.AddIfNotContains("/libs/owl.carousel/owl.carousel.min.js"); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/OwlCarousel/OwlCarouselStyleContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/OwlCarousel/OwlCarouselStyleContributor.cs new file mode 100644 index 0000000000..b0a5ce8014 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/OwlCarousel/OwlCarouselStyleContributor.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Volo.Abp.AspNetCore.Mvc.UI.Bundling; + +namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.OwlCarousel; + +public class OwlCarouselStyleContributor : BundleContributor +{ + public override void ConfigureBundle(BundleConfigurationContext context) + { + //TODO: Theming! + context.Files.AddIfNotContains("/libs/owl.carousel/assets/owl.carousel.min.css"); + context.Files.AddIfNotContains("/libs/owl.carousel/assets/owl.theme.default.min.css"); + context.Files.AddIfNotContains("/libs/owl.carousel/assets/owl.theme.green.min.css"); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/Timeago/TimeagoScriptContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/Timeago/TimeagoScriptContributor.cs index 3b09dd1022..94fbd9a3b9 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/Timeago/TimeagoScriptContributor.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Packages/Volo/Abp/AspNetCore/Mvc/UI/Packages/Timeago/TimeagoScriptContributor.cs @@ -1,8 +1,6 @@ using System.Collections.Generic; -using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Mvc.UI.Bundling; using Volo.Abp.AspNetCore.Mvc.UI.Packages.JQuery; -using Volo.Abp.Localization; using Volo.Abp.Modularity; namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.Timeago; @@ -10,20 +8,9 @@ namespace Volo.Abp.AspNetCore.Mvc.UI.Packages.Timeago; [DependsOn(typeof(JQueryScriptContributor))] public class TimeagoScriptContributor : BundleContributor { - public const string PackageName = "jquery.timeago"; - public override void ConfigureBundle(BundleConfigurationContext context) { - context.Files.AddIfNotContains("/libs/timeago/jquery.timeago.js"); - } - - public override void ConfigureDynamicResources(BundleConfigurationContext context) - { - var fileName = context.LazyServiceProvider.LazyGetRequiredService>().Value.GetCurrentUICultureLanguageFilesMap(PackageName); - var filePath = $"/libs/timeago/locales/jquery.timeago.{fileName}.js"; - if (context.FileProvider.GetFileInfo(filePath).Exists) - { - context.Files.Add(filePath); - } + context.Files.AddIfNotContains("/libs/timeago/timeago.full.min.js"); + context.Files.AddIfNotContains("/libs/abp/timeago/abp.timeago.js"); } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalScriptContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalScriptContributor.cs index 728becdf6d..7865ec9c3d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalScriptContributor.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalScriptContributor.cs @@ -7,7 +7,6 @@ using Volo.Abp.AspNetCore.Mvc.UI.Packages.JQuery; using Volo.Abp.AspNetCore.Mvc.UI.Packages.JQueryValidationUnobtrusive; using Volo.Abp.AspNetCore.Mvc.UI.Packages.Lodash; using Volo.Abp.AspNetCore.Mvc.UI.Packages.Luxon; -using Volo.Abp.AspNetCore.Mvc.UI.Packages.MalihuCustomScrollbar; using Volo.Abp.AspNetCore.Mvc.UI.Packages.Select2; using Volo.Abp.AspNetCore.Mvc.UI.Packages.SweetAlert2; using Volo.Abp.AspNetCore.Mvc.UI.Packages.Timeago; @@ -23,7 +22,6 @@ namespace Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Bundling; typeof(Select2ScriptContributor), typeof(DatatablesNetBs5ScriptContributor), typeof(Sweetalert2ScriptContributor), - typeof(MalihuCustomScrollbarPluginScriptBundleContributor), typeof(LuxonScriptContributor), typeof(TimeagoScriptContributor), typeof(BootstrapDatepickerScriptContributor), diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalStyleContributor.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalStyleContributor.cs index 4f436a02b0..b030508711 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalStyleContributor.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/Bundling/SharedThemeGlobalStyleContributor.cs @@ -5,7 +5,6 @@ using Volo.Abp.AspNetCore.Mvc.UI.Packages.BootstrapDaterangepicker; using Volo.Abp.AspNetCore.Mvc.UI.Packages.Core; using Volo.Abp.AspNetCore.Mvc.UI.Packages.DatatablesNetBs5; using Volo.Abp.AspNetCore.Mvc.UI.Packages.FontAwesome; -using Volo.Abp.AspNetCore.Mvc.UI.Packages.MalihuCustomScrollbar; using Volo.Abp.AspNetCore.Mvc.UI.Packages.Select2; using Volo.Abp.Modularity; @@ -16,7 +15,6 @@ namespace Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared.Bundling; typeof(BootstrapStyleContributor), typeof(FontAwesomeStyleContributor), typeof(Select2StyleContributor), - typeof(MalihuCustomScrollbarPluginStyleBundleContributor), typeof(DatatablesNetBs5StyleContributor), typeof(BootstrapDatepickerStyleContributor), typeof(BootstrapDaterangepickerStyleContributor) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/wwwroot/libs/abp/aspnetcore-mvc-ui-theme-shared/bootstrap/dom-event-handlers.js b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/wwwroot/libs/abp/aspnetcore-mvc-ui-theme-shared/bootstrap/dom-event-handlers.js index 3199900536..cb1b38ab8c 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/wwwroot/libs/abp/aspnetcore-mvc-ui-theme-shared/bootstrap/dom-event-handlers.js +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.UI.Theme.Shared/wwwroot/libs/abp/aspnetcore-mvc-ui-theme-shared/bootstrap/dom-event-handlers.js @@ -72,7 +72,7 @@ } abp.dom.initializers.initializeTimeAgos = function ($timeagos) { - $timeagos.timeago(); + abp.timeago.render($timeagos.toArray()); } abp.dom.initializers.initializeAutocompleteSelects = function ($autocompleteSelects) { @@ -217,6 +217,7 @@ args.$el.findWithSelf('[data-bs-toggle="tooltip"]').each(function () { $('#' + $(this).attr('aria-describedby')).remove(); }); + abp.timeago.cancel(args.$el.findWithSelf('[timeago-id]').toArray()); }); abp.event.on('abp.configurationInitialized', function () { diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs index 3791c4202f..fa8aafc76a 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/AbstractKeyReadOnlyAppService.cs @@ -2,12 +2,14 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Dynamic.Core; +using System.Threading; using System.Threading.Tasks; using Volo.Abp.Application.Dtos; using Volo.Abp.Auditing; using Volo.Abp.Domain.Entities; using Volo.Abp.Domain.Repositories; using Volo.Abp.ObjectMapping; +using Volo.Abp.Threading; namespace Volo.Abp.Application.Services; @@ -44,6 +46,20 @@ public abstract class AbstractKeyReadOnlyAppService + /// Used by the to project the query to the . + /// The and the are not used while the query is projected. + /// + protected virtual IQueryProjector? GetOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); + + /// + /// Used by the to project the query to the . + /// The is not used while the query is projected. + /// + protected virtual IQueryProjector? GetListOutputDtoQueryProjector + => LazyServiceProvider.LazyGetService>(); + protected AbstractKeyReadOnlyAppService(IReadOnlyRepository repository) { ReadOnlyRepository = repository; @@ -53,6 +69,19 @@ public abstract class AbstractKeyReadOnlyAppService(id); + } + + return dtos[0]; + } + var entity = await GetEntityByIdAsync(id); return await MapToGetOutputDtoAsync(entity); @@ -65,7 +94,6 @@ public abstract class AbstractKeyReadOnlyAppService(); var entityDtos = new List(); if (totalCount > 0) @@ -73,8 +101,16 @@ public abstract class AbstractKeyReadOnlyAppService( @@ -85,6 +121,57 @@ public abstract class AbstractKeyReadOnlyAppService GetEntityByIdAsync(TKey id); + private CancellationToken GetCancellationToken() + { + return LazyServiceProvider + .LazyGetService(NullCancellationTokenProvider.Instance) + .FallbackToProvider(); + } + + /// + /// Should create a query that selects the entity with the given . + /// It returns null by default, then the entity is not projected. + /// + /// The id of the entity. + protected virtual Task?> CreateEntityQueryOrNullAsync(TKey id) + { + return Task.FromResult?>(null); + } + + /// + /// Projects the query of the entity with the given to the . + /// It uses the and the by default, + /// and the is used when it returns null. + /// Override it to await other queries, like the query of another aggregate root to join. + /// + /// The id of the entity. + protected virtual async Task?> CreateGetOutputDtoQueryOrNullAsync(TKey id) + { + var queryProjector = GetOutputDtoQueryProjector; + if (queryProjector == null) + { + return null; + } + + var query = await CreateEntityQueryOrNullAsync(id); + + return query == null ? null : queryProjector.ProjectTo(query); + } + + /// + /// Projects the given entity query to the . + /// It uses the by default, + /// and the is used when it returns null. + /// Override it to await other queries, like the query of another aggregate root to join. + /// The projection must return one row per entity: the total count is already calculated and the paging is + /// already applied, so adding or removing rows makes the page inconsistent with the total count. + /// + /// The sorted and paged entity query. + protected virtual Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return Task.FromResult(GetListOutputDtoQueryProjector?.ProjectTo(query)); + } + protected virtual async Task CheckGetPolicyAsync() { await CheckPolicyAsync(GetPolicyName); diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs index 672d85a2be..906b138fba 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/CrudAppService.cs @@ -84,6 +84,13 @@ public abstract class CrudAppService?> CreateEntityQueryOrNullAsync(TKey id) + { + var query = await Repository.GetQueryableAsync(); + + return query.Where(e => e.Id!.Equals(id)); + } + protected override void MapToEntity(TUpdateInput updateInput, TEntity entity) { if (updateInput is IEntityDto entityDto) diff --git a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs index 867ca35ad4..c36d17ffc8 100644 --- a/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs +++ b/framework/src/Volo.Abp.Ddd.Application/Volo/Abp/Application/Services/ReadOnlyAppService.cs @@ -47,6 +47,13 @@ public abstract class ReadOnlyAppService?> CreateEntityQueryOrNullAsync(TKey id) + { + var query = await Repository.GetQueryableAsync(); + + return query.Where(e => e.Id!.Equals(id)); + } + protected override IQueryable ApplyDefaultSorting(IQueryable query) { if (typeof(TEntity).IsAssignableTo()) diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs index e572d6e155..2f1b8830ac 100644 --- a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/AbpObjectMappingModule.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; using Volo.Abp.DependencyInjection; using Volo.Abp.Modularity; using Volo.Abp.Reflection; @@ -18,6 +19,14 @@ public class AbpObjectMappingModule : AbpModule typeof(IObjectMapper<,>) ).ConvertAll(t => new ServiceIdentifier(t)) ); + + //Register types for IQueryProjector if implements + foreach (var serviceType in ReflectionHelper.GetImplementedGenericTypes( + onServiceExposingContext.ImplementationType, + typeof(IQueryProjector<,>))) + { + onServiceExposingContext.ExposedTypes.AddIfNotContains(new ServiceIdentifier(serviceType)); + } }); } diff --git a/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs new file mode 100644 index 0000000000..89652e5f40 --- /dev/null +++ b/framework/src/Volo.Abp.ObjectMapping/Volo/Abp/ObjectMapping/IQueryProjector.cs @@ -0,0 +1,24 @@ +using System.Linq; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.ObjectMapping; + +/// +/// Maps a query to another. +/// Implement this interface to project a query on the data store side, instead of loading the +/// source objects into the memory and mapping them one by one. +/// Implement it once for a source and destination pair. Use the ReplaceServices option of the +/// DependencyAttribute to replace an existing implementation. +/// +/// Type of the source objects +/// Type of the destination objects +public interface IQueryProjector : ITransientDependency +{ + /// + /// Projects the given query. The returned query must be built on top of it and must keep its order, + /// with a single destination object for each source object, using expressions the query provider can + /// translate. The caller may have already sorted, paged or counted the source query. + /// + /// The query to project + IQueryable ProjectTo(IQueryable source); +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/Book.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/Book.cs new file mode 100644 index 0000000000..6211160df4 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/Book.cs @@ -0,0 +1,23 @@ +using System; +using Volo.Abp.Domain.Entities; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class Book : Entity +{ + public string Name { get; set; } = default!; + + public int Price { get; set; } + + public Book() + { + + } + + public Book(Guid id, string name, int price) + : base(id) + { + Name = name; + Price = price; + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyAppService.cs new file mode 100644 index 0000000000..1110ff841e --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyAppService.cs @@ -0,0 +1,27 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAbstractKeyAppService : AbstractKeyReadOnlyAppService +{ + public BookAbstractKeyAppService(IReadOnlyRepository repository) + : base(repository) + { + + } + + protected override async Task GetEntityByIdAsync(Guid id) + { + var query = await ReadOnlyRepository.GetQueryableAsync(); + + return await AsyncExecuter.FirstAsync(query, book => book.Id == id); + } + + protected override IQueryable ApplyDefaultSorting(IQueryable query) + { + return query.OrderBy(book => book.Id); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs new file mode 100644 index 0000000000..c6bcf8dbce --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAbstractKeyProjectingAppService.cs @@ -0,0 +1,35 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAbstractKeyProjectingAppService : AbstractKeyReadOnlyAppService +{ + public BookAbstractKeyProjectingAppService(IReadOnlyRepository repository) + : base(repository) + { + + } + + protected override async Task GetEntityByIdAsync(Guid id) + { + var query = await ReadOnlyRepository.GetQueryableAsync(); + + return await AsyncExecuter.FirstAsync(query, book => book.Id == id); + } + + protected override async Task?> CreateEntityQueryOrNullAsync(Guid id) + { + var query = await ReadOnlyRepository.GetQueryableAsync(); + + return query.Where(book => book.Id == id); + } + + protected override IQueryable ApplyDefaultSorting(IQueryable query) + { + return query.OrderBy(book => book.Id); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAppService.cs new file mode 100644 index 0000000000..c3ae530e7d --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAppService : CrudAppService +{ + public BookAppService(IRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs new file mode 100644 index 0000000000..21f7516116 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookAsyncProjectionAppService.cs @@ -0,0 +1,46 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookAsyncProjectionAppService : CrudAppService +{ + public const string Marker = "-async"; + + private readonly IBookNameSuffixProvider _suffixProvider; + + public BookAsyncProjectionAppService( + IRepository repository, + IBookNameSuffixProvider suffixProvider) + : base(repository) + { + _suffixProvider = suffixProvider; + } + + protected override async Task?> CreateGetOutputDtoQueryOrNullAsync(Guid id) + { + var query = await Repository.GetQueryableAsync(); + + return await ProjectAsync(query.Where(book => book.Id == id)); + } + + protected override async Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return await ProjectAsync(query); + } + + private async Task> ProjectAsync(IQueryable query) + { + var suffix = await _suffixProvider.GetAsync(); + + return query.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name + suffix + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs new file mode 100644 index 0000000000..f5df26baaa --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookCustomizedAppService.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookCustomizedAppService : CrudAppService +{ + public const string Marker = "-customized"; + + public BookCustomizedAppService(IRepository repository) + : base(repository) + { + + } + + protected override async Task GetEntityByIdAsync(Guid id) + { + var book = await base.GetEntityByIdAsync(id); + book.Name += Marker; + return book; + } + + protected override Task MapToGetOutputDtoAsync(Book entity) + { + return Task.FromResult(new BookDto { Id = entity.Id, Name = entity.Name + Marker }); + } + + protected override Task> MapToGetListOutputDtosAsync(List entities) + { + return Task.FromResult(entities.ConvertAll(entity => new BookDto { Id = entity.Id, Name = entity.Name + Marker })); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs new file mode 100644 index 0000000000..ec2139a118 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailAppService.cs @@ -0,0 +1,15 @@ +using System; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDetailAppService : + ReadOnlyAppService +{ + public BookDetailAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs new file mode 100644 index 0000000000..f278d6d721 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDetailDto : EntityDto +{ + public string Name { get; set; } = default!; +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs new file mode 100644 index 0000000000..d7e3e19966 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDetailProjector.cs @@ -0,0 +1,18 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDetailProjector : IQueryProjector +{ + public const string Marker = "-detail"; + + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookDetailDto + { + Id = book.Id, + Name = book.Name + Marker + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDto.cs new file mode 100644 index 0000000000..08a2117ec2 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookDto : EntityDto +{ + public string Name { get; set; } = default!; +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteAppService.cs new file mode 100644 index 0000000000..c5b28e058b --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookLiteAppService : CrudAppService +{ + public BookLiteAppService(IRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteDto.cs new file mode 100644 index 0000000000..7ab0408c85 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookLiteDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookLiteDto : EntityDto +{ + public string Name { get; set; } = default!; +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs new file mode 100644 index 0000000000..c398f9e25f --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookObjectMapper.cs @@ -0,0 +1,48 @@ +using Volo.Abp.DependencyInjection; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookObjectMapper : + IObjectMapper, + IObjectMapper, + IObjectMapper, + ITransientDependency +{ + public const string Marker = "-mapped"; + + public BookDto Map(Book source) + { + return new BookDto { Id = source.Id, Name = source.Name + Marker }; + } + + public BookDto Map(Book source, BookDto destination) + { + destination.Id = source.Id; + destination.Name = source.Name + Marker; + return destination; + } + + BookLiteDto IObjectMapper.Map(Book source) + { + return new BookLiteDto { Id = source.Id, Name = source.Name + Marker }; + } + + BookLiteDto IObjectMapper.Map(Book source, BookLiteDto destination) + { + destination.Id = source.Id; + destination.Name = source.Name + Marker; + return destination; + } + + Book IObjectMapper.Map(BookDto source) + { + return new Book(source.Id, source.Name, 0); + } + + Book IObjectMapper.Map(BookDto source, Book destination) + { + destination.Name = source.Name; + return destination; + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookPolicyCheckedAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookPolicyCheckedAppService.cs new file mode 100644 index 0000000000..ee8e73db14 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookPolicyCheckedAppService.cs @@ -0,0 +1,29 @@ +using System; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookPolicyCheckedException : Exception +{ + +} + +public class BookPolicyCheckedAppService : CrudAppService +{ + public BookPolicyCheckedAppService(IRepository repository) + : base(repository) + { + + } + + protected override Task CheckGetPolicyAsync() + { + throw new BookPolicyCheckedException(); + } + + protected override Task CheckGetListPolicyAsync() + { + throw new BookPolicyCheckedException(); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs new file mode 100644 index 0000000000..fa292161fc --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookProjector.cs @@ -0,0 +1,18 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookProjector : IQueryProjector +{ + public const string Marker = "-projected"; + + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookDto + { + Id = book.Id, + Name = book.Name + Marker + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookReadOnlyAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookReadOnlyAppService.cs new file mode 100644 index 0000000000..9a80d741fb --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookReadOnlyAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookReadOnlyAppService : ReadOnlyAppService +{ + public BookReadOnlyAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookRepository.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookRepository.cs new file mode 100644 index 0000000000..75da4e0da6 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookRepository.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +[ExposeServices( + typeof(IRepository), + typeof(IReadOnlyRepository), + typeof(IReadOnlyRepository))] +public class BookRepository : RepositoryBase, ISingletonDependency +{ + private readonly List _books = new(); + + public BookRepository() + : base("InMemory") + { + + } + + public override Task> GetQueryableAsync() + { + return Task.FromResult(_books.AsQueryable()); + } + + [Obsolete("Use GetQueryableAsync method.")] + protected override IQueryable GetQueryable() + { + return _books.AsQueryable(); + } + + public override Task GetAsync(Guid id, bool includeDetails = true, CancellationToken cancellationToken = default) + { + var book = _books.FirstOrDefault(x => x.Id == id); + if (book == null) + { + throw new EntityNotFoundException(typeof(Book), id); + } + + return Task.FromResult(book); + } + + public override Task FindAsync(Guid id, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.FirstOrDefault(x => x.Id == id)); + } + + public override Task FindAsync(Expression> predicate, bool includeDetails = true, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.AsQueryable().FirstOrDefault(predicate)); + } + + public override Task> GetListAsync(bool includeDetails = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.ToList()); + } + + public override Task> GetListAsync(Expression> predicate, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.AsQueryable().Where(predicate).ToList()); + } + + public override Task> GetPagedListAsync(int skipCount, int maxResultCount, string sorting, bool includeDetails = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(_books.Skip(skipCount).Take(maxResultCount).ToList()); + } + + public override Task GetCountAsync(CancellationToken cancellationToken = default) + { + return Task.FromResult((long)_books.Count); + } + + public override Task InsertAsync(Book entity, bool autoSave = false, CancellationToken cancellationToken = default) + { + _books.Add(entity); + return Task.FromResult(entity); + } + + public override Task UpdateAsync(Book entity, bool autoSave = false, CancellationToken cancellationToken = default) + { + return Task.FromResult(entity); + } + + public override Task DeleteAsync(Book entity, bool autoSave = false, CancellationToken cancellationToken = default) + { + _books.Remove(entity); + return Task.CompletedTask; + } + + public override Task DeleteAsync(Expression> predicate, bool autoSave = false, CancellationToken cancellationToken = default) + { + _books.RemoveAll(new Predicate(predicate.Compile())); + return Task.CompletedTask; + } + + public override Task DeleteDirectAsync(Expression> predicate, CancellationToken cancellationToken = default) + { + return DeleteAsync(predicate); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs new file mode 100644 index 0000000000..34b4945b85 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructAppService.cs @@ -0,0 +1,13 @@ +using System; +using Volo.Abp.Domain.Repositories; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookStructAppService : ReadOnlyAppService +{ + public BookStructAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs new file mode 100644 index 0000000000..f2690bb139 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public struct BookStructDto +{ + public Guid Id { get; set; } + + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs new file mode 100644 index 0000000000..4ae26a4b11 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookStructProjector.cs @@ -0,0 +1,16 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookStructProjector : IQueryProjector +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(book => new BookStructDto + { + Id = book.Id, + Name = book.Name + }); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs new file mode 100644 index 0000000000..928c8fee66 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/BookWithoutProjectionAppService.cs @@ -0,0 +1,33 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class BookWithoutProjectionAppService : CrudAppService +{ + public const string Marker = "-not-projected"; + + protected override IQueryProjector? GetOutputDtoQueryProjector => null; + + protected override IQueryProjector? GetListOutputDtoQueryProjector => null; + + public BookWithoutProjectionAppService(IRepository repository) + : base(repository) + { + + } + + protected override Task MapToGetOutputDtoAsync(Book entity) + { + return Task.FromResult(new BookDto { Id = entity.Id, Name = entity.Name + Marker }); + } + + protected override Task> MapToGetListOutputDtosAsync(List entities) + { + return Task.FromResult(entities.ConvertAll(entity => new BookDto { Id = entity.Id, Name = entity.Name + Marker })); + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs new file mode 100644 index 0000000000..747a93c15d --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/IBookNameSuffixProvider.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public interface IBookNameSuffixProvider +{ + Task GetAsync(); +} + +public class BookNameSuffixProvider : IBookNameSuffixProvider, ITransientDependency +{ + public async Task GetAsync() + { + await Task.Yield(); + + return BookAsyncProjectionAppService.Marker; + } +} diff --git a/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs new file mode 100644 index 0000000000..425021fb03 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Application.Tests/Volo/Abp/Application/Services/QueryProjection/QueryProjection_Tests.cs @@ -0,0 +1,196 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.ObjectMapping; +using Xunit; + +namespace Volo.Abp.Application.Services.QueryProjection; + +public class QueryProjection_Tests : AbpDddApplicationTestBase +{ + private readonly Guid _bookId = Guid.NewGuid(); + + public QueryProjection_Tests() + { + var repository = GetRequiredService>(); + repository.InsertAsync(new Book(_bookId, "Hitchhiker's Guide", 42)).GetAwaiter().GetResult(); + } + + [Fact] + public void Should_Resolve_The_Projector_Independent_From_The_Class_Name() + { + ServiceProvider.GetService>() + .ShouldBeOfType(); + } + + [Fact] + public async Task Should_Project_On_The_Query_For_CrudAppService() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Project_On_The_Query_For_ReadOnlyAppService() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_If_The_Projected_Entity_Does_Not_Exist() + { + var appService = GetRequiredService(); + + await Should.ThrowAsync(async () => await appService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Use_The_Object_Mapper_If_No_Projector_Was_Registered() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookObjectMapper.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookObjectMapper.Marker); + } + + [Fact] + public async Task Should_Use_The_Entity_Based_Overrides_If_The_Projection_Was_Disabled() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookWithoutProjectionAppService.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookWithoutProjectionAppService.Marker); + } + + [Fact] + public async Task Should_Not_Use_The_Entity_Based_Overrides_While_Projecting() + { + var appService = GetRequiredService(); + + var dto = await appService.GetAsync(_bookId); + + dto.Name.ShouldEndWith(BookProjector.Marker); + dto.Name.ShouldNotContain(BookCustomizedAppService.Marker); + + var items = (await appService.GetListAsync(new PagedAndSortedResultRequestDto())).Items; + + items[0].Name.ShouldEndWith(BookProjector.Marker); + items[0].Name.ShouldNotContain(BookCustomizedAppService.Marker); + } + + [Fact] + public async Task Should_Use_The_Object_Mapper_If_The_Entity_Query_Can_Not_Be_Created() + { + var appService = GetRequiredService(); + + //GetAsync has no query to project, it falls back to GetEntityByIdAsync + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookObjectMapper.Marker); + + //GetListAsync always has a query, so it is still projected + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Use_The_Asynchronously_Created_Projection_Query() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookAsyncProjectionAppService.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookAsyncProjectionAppService.Marker); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_From_An_Overridden_Projection_Query() + { + var appService = GetRequiredService(); + + await Should.ThrowAsync(async () => await appService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_For_A_Value_Type_Dto() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldBe("Hitchhiker's Guide"); + + await Should.ThrowAsync(async () => await appService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Not_Project_On_The_Create_And_Update_Paths() + { + var appService = GetRequiredService(); + + var created = await appService.CreateAsync(new BookDto { Name = "New Book" }); + created.Name.ShouldEndWith(BookObjectMapper.Marker); + + var updated = await appService.UpdateAsync(created.Id, new BookDto { Name = "Updated Book" }); + updated.Name.ShouldEndWith(BookObjectMapper.Marker); + } + + [Fact] + public async Task Should_Use_A_Different_Projector_For_The_Get_And_The_List() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookDetailProjector.Marker); + (await appService.GetListAsync(new PagedAndSortedResultRequestDto())) + .Items[0].Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Apply_Paging_And_Sorting_Before_Projecting() + { + var repository = GetRequiredService>(); + await repository.InsertAsync(new Book(Guid.NewGuid(), "A Book", 1)); + await repository.InsertAsync(new Book(Guid.NewGuid(), "Z Book", 2)); + + var appService = GetRequiredService(); + + var firstPage = await appService.GetListAsync( + new PagedAndSortedResultRequestDto { MaxResultCount = 1, Sorting = "Name" }); + + firstPage.TotalCount.ShouldBe(3); + firstPage.Items.Count.ShouldBe(1); + firstPage.Items[0].Name.ShouldBe("A Book" + BookProjector.Marker); + + var secondPage = await appService.GetListAsync( + new PagedAndSortedResultRequestDto { MaxResultCount = 1, SkipCount = 1, Sorting = "Name" }); + + secondPage.Items[0].Name.ShouldBe("Hitchhiker's Guide" + BookProjector.Marker); + } + + [Fact] + public async Task Should_Project_A_Single_Entity_From_An_AbstractKey_Application_Service() + { + var appService = GetRequiredService(); + + (await appService.GetAsync(_bookId)).Name.ShouldEndWith(BookProjector.Marker); + } + + [Fact] + public async Task Should_Check_The_Policies_While_Projecting() + { + var appService = GetRequiredService(); + + await Should.ThrowAsync(async () => await appService.GetAsync(_bookId)); + await Should.ThrowAsync(async () => + await appService.GetListAsync(new PagedAndSortedResultRequestDto())); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs index 27992ed124..fae245bc3a 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/AbpEntityFrameworkCoreTestModule.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Volo.Abp.Autofac; using Volo.Abp.Data; using Volo.Abp.Domain.Repositories; +using Volo.Abp.EntityFrameworkCore.Applications; using Volo.Abp.EntityFrameworkCore.Domain; using Volo.Abp.EntityFrameworkCore.Sqlite; using Volo.Abp.EntityFrameworkCore.TestApp.FifthContext; @@ -92,6 +93,7 @@ public class AbpEntityFrameworkCoreTestModule : AbpModule options.Configure(abpDbContextConfigurationContext => { abpDbContextConfigurationContext.UseSqlite().AddAbpDbContextOptionsExtension(); + abpDbContextConfigurationContext.DbContextOptions.AddInterceptors(new SqlCommandCapture()); }); }); } diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionAppService.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionAppService.cs new file mode 100644 index 0000000000..cc6176cddd --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionAppService.cs @@ -0,0 +1,14 @@ +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class EntityWithIntPkProjectionAppService : ReadOnlyAppService +{ + public EntityWithIntPkProjectionAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs new file mode 100644 index 0000000000..f6bbebe850 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjectionDto.cs @@ -0,0 +1,8 @@ +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class EntityWithIntPkProjectionDto : EntityDto +{ + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs new file mode 100644 index 0000000000..5e97ceca51 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/EntityWithIntPkProjector.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class EntityWithIntPkProjector : IQueryProjector +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(entity => new EntityWithIntPkProjectionDto + { + Id = entity.Id, + Name = entity.Name + }); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionAppService.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionAppService.cs new file mode 100644 index 0000000000..fa51e9407f --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionAppService.cs @@ -0,0 +1,15 @@ +using System; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonProjectionAppService : ReadOnlyAppService +{ + public PersonProjectionAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs new file mode 100644 index 0000000000..57eef129d0 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjectionDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonProjectionDto : EntityDto +{ + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs new file mode 100644 index 0000000000..d9abce7cd8 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonProjector.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonProjector : IQueryProjector +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(person => new PersonProjectionDto + { + Id = person.Id, + Name = person.Name + }); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs new file mode 100644 index 0000000000..f8a8ba01e5 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityAppService.cs @@ -0,0 +1,51 @@ +#nullable enable +using System; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +//City is another aggregate root, so Person has no City navigation property to project. +public class PersonWithCityAppService : ReadOnlyAppService +{ + private readonly IReadOnlyRepository _cityRepository; + + public PersonWithCityAppService( + IReadOnlyRepository repository, + IReadOnlyRepository cityRepository) + : base(repository) + { + _cityRepository = cityRepository; + } + + protected override async Task?> CreateGetOutputDtoQueryOrNullAsync(Guid id) + { + var people = await CreateEntityQueryOrNullAsync(id); + + return people == null ? null : await JoinCitiesAsync(people); + } + + protected override async Task?> CreateGetListOutputDtoQueryOrNullAsync(IQueryable query) + { + return await JoinCitiesAsync(query); + } + + //left join, an inner join would drop the people without a city and break the total count + private async Task> JoinCitiesAsync(IQueryable people) + { + var cities = await _cityRepository.GetQueryableAsync(); + + return from person in people + join city in cities on person.CityId equals city.Id into personCities + from personCity in personCities.DefaultIfEmpty() + select new PersonWithCityDto + { + Id = person.Id, + Name = person.Name, + CityName = personCity != null ? personCity.Name : null + }; + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs new file mode 100644 index 0000000000..dac278c7b6 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/PersonWithCityDto.cs @@ -0,0 +1,12 @@ +#nullable enable +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class PersonWithCityDto : EntityDto +{ + public string Name { get; set; } + + public string? CityName { get; set; } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs new file mode 100644 index 0000000000..d4410c15e7 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/QueryProjection_Tests.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Entities; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.ObjectMapping; +using Volo.Abp.TestApp; +using Volo.Abp.TestApp.Application; +using Volo.Abp.Threading; +using Volo.Abp.TestApp.Domain; +using Xunit; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class QueryProjection_Tests : EntityFrameworkCoreTestBase +{ + private readonly PersonProjectionAppService _personProjectionAppService; + + public QueryProjection_Tests() + { + _personProjectionAppService = GetRequiredService(); + } + + [Fact] + public async Task Should_Get_A_Projected_Entity() + { + var dto = await _personProjectionAppService.GetAsync(TestDataBuilder.UserDouglasId); + + dto.Id.ShouldBe(TestDataBuilder.UserDouglasId); + dto.Name.ShouldBe("Douglas"); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_For_A_Missing_Entity() + { + (await Should.ThrowAsync>( + async () => await _personProjectionAppService.GetAsync(Guid.NewGuid())) + ).EntityType.ShouldBe(typeof(Person)); + } + + [Fact] + public async Task Should_Get_A_Projected_List() + { + var result = await _personProjectionAppService.GetListAsync(new PagedAndSortedResultRequestDto()); + + result.TotalCount.ShouldBeGreaterThan(0); + result.Items.Count.ShouldBe((int)result.TotalCount); + result.Items.ShouldContain(x => x.Name == "Douglas"); + } + + [Fact] + public async Task Should_Get_A_Projected_Entity_With_A_Non_Guid_Key() + { + var appService = GetRequiredService(); + var entity = await WithUnitOfWorkAsync( + async () => await GetRequiredService>().FirstAsync()); + + (await appService.GetAsync(entity.Id)).Name.ShouldBe(entity.Name); + } + + [Fact] + public async Task Should_Apply_The_Soft_Delete_Filter_While_Projecting() + { + var result = await _personProjectionAppService.GetListAsync(new PagedAndSortedResultRequestDto()); + + result.Items.ShouldNotContain(x => x.Id == TestDataBuilder.UserJohnDeletedId); + } + + [Fact] + public async Task Should_Only_Select_The_Projected_Columns() + { + await WithUnitOfWorkAsync(async () => + { + var repository = GetRequiredService>(); + var projector = GetRequiredService>(); + + var sql = projector.ProjectTo(await repository.GetQueryableAsync()).ToQueryString(); + + sql.ShouldContain("\"Name\""); + sql.ShouldNotContain("\"Birthday\""); + sql.ShouldNotContain("\"ExtraProperties\""); + + //the data filters are still a part of the query + sql.ShouldContain("Is_Deleted"); + }); + } + + [Fact] + public async Task Should_Join_Another_Aggregate_While_Projecting() + { + var appService = GetRequiredService(); + + var dto = await appService.GetAsync(TestDataBuilder.UserDouglasId); + dto.CityName.ShouldBe("London"); + + var result = await appService.GetListAsync(new PagedAndSortedResultRequestDto()); + result.Items.ShouldContain(x => x.CityName == "London"); + } + + [Fact] + public async Task Should_Keep_The_Total_Count_While_Joining_Another_Aggregate() + { + await WithUnitOfWorkAsync(async () => + { + await GetRequiredService>() + .InsertAsync(new Person(Guid.NewGuid(), "PersonWithoutCity", 30), autoSave: true); + }); + + var result = await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + + result.Items.Count.ShouldBe((int)result.TotalCount); + result.Items.ShouldContain(x => x.Name == "PersonWithoutCity" && x.CityName == null); + } + + [Fact] + public async Task Should_Execute_The_Projected_Query_From_The_Application_Service() + { + System.Collections.Concurrent.ConcurrentQueue commands; + using (SqlCommandCapture.Begin(out commands)) + { + await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + } + + //the application service must run the projection itself, not materialize the entities first + var selects = commands.Where(x => x.Contains("FROM \"People\"") && !x.Contains("COUNT")).ToList(); + + var select = selects.ShouldHaveSingleItem(); + select.ShouldContain("\"Name\""); + select.ShouldNotContain("\"Birthday\""); + select.ShouldNotContain("\"ExtraProperties\""); + } + + [Fact] + public async Task Should_Apply_The_Multi_Tenancy_Filter_While_Projecting() + { + var result = await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + + result.Items.ShouldNotContain(x => x.Name.StartsWith(TestDataBuilder.TenantId1.ToString())); + } + + [Fact] + public async Task Should_Use_The_Ambient_Cancellation_Token_While_Projecting() + { + var cancellationTokenProvider = GetRequiredService(); + var appService = GetRequiredService(); + + //the entity path passes it through Repository.GetAsync, so the projected path must not regress + using (cancellationTokenProvider.Use(new CancellationToken(canceled: true))) + { + await Should.ThrowAsync(async () => + await appService.GetAsync(TestDataBuilder.UserDouglasId)); + } + } + + [Fact] + public async Task Should_Execute_The_Projected_Query_From_The_Application_Service_For_A_Single_Entity() + { + System.Collections.Concurrent.ConcurrentQueue commands; + using (SqlCommandCapture.Begin(out commands)) + { + await GetRequiredService().GetAsync(TestDataBuilder.UserDouglasId); + } + + var selects = commands.Where(x => x.Contains("FROM \"People\"")).ToList(); + + var select = selects.ShouldHaveSingleItem(); + select.ShouldContain("\"Name\""); + select.ShouldNotContain("\"Birthday\""); + select.ShouldNotContain("\"ExtraProperties\""); + } + + [Fact] + public async Task Should_Apply_The_Data_Filters_Of_The_Joined_Aggregate() + { + var cityRepository = GetRequiredService>(); + + await WithUnitOfWorkAsync(async () => + { + var london = await cityRepository.GetAsync(TestDataBuilder.LondonCityId); + await cityRepository.DeleteAsync(london, autoSave: true); + }); + + var result = await GetRequiredService() + .GetListAsync(new PagedAndSortedResultRequestDto()); + + //the soft deleted city is filtered out, the left join still keeps the person + result.Items.ShouldContain(x => x.Name == "Douglas" && x.CityName == null); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs new file mode 100644 index 0000000000..7f70e562e5 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Applications/SqlCommandCapture.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace Volo.Abp.EntityFrameworkCore.Applications; + +public class SqlCommandCapture : DbCommandInterceptor +{ + private static readonly AsyncLocal> Commands = new(); + + public static IDisposable Begin(out ConcurrentQueue commands) + { + commands = new ConcurrentQueue(); + Commands.Value = commands; + return new DisposeAction(() => Commands.Value = null); + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, + CommandEventData eventData, + InterceptionResult result) + { + Commands.Value?.Enqueue(command.CommandText); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, + CommandEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Commands.Value?.Enqueue(command.CommandText); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } +} diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs new file mode 100644 index 0000000000..6715fdfa74 --- /dev/null +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/AbpMapperlyQueryProjection_Tests.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Mapperly.SampleClasses; +using Volo.Abp.ObjectMapping; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.Mapperly; + +public class AbpMapperlyQueryProjection_Tests : AbpIntegratedTest +{ + [Fact] + public void Should_Project_A_Queryable() + { + var queryProjector = ServiceProvider.GetRequiredService>(); + + var entities = new List + { + new MyEntity { Id = Guid.NewGuid(), Number = 42 } + }.AsQueryable(); + + var dtos = queryProjector.ProjectTo(entities).ToList(); + + dtos.Count.ShouldBe(1); + dtos[0].Id.ShouldBe(entities.First().Id); + dtos[0].Number.ShouldBe(42); + } + + + [Fact] + public void Should_Register_A_Projector_Only_Once() + { + //MyEntityQueryProjector also matches the class name convention of ExposeServicesAttribute + ServiceProvider.GetServices>().ShouldHaveSingleItem(); + } +} diff --git a/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs new file mode 100644 index 0000000000..980e40a94b --- /dev/null +++ b/framework/test/Volo.Abp.Mapperly.Tests/Volo/Abp/Mapperly/SampleClasses/MyEntityQueryProjector.cs @@ -0,0 +1,11 @@ +using System.Linq; +using Riok.Mapperly.Abstractions; +using Volo.Abp.ObjectMapping; + +namespace Volo.Abp.Mapperly.SampleClasses; + +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] +public partial class MyEntityQueryProjector : IQueryProjector +{ + public partial IQueryable ProjectTo(IQueryable source); +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionAppService.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionAppService.cs new file mode 100644 index 0000000000..d625b518e8 --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionAppService.cs @@ -0,0 +1,15 @@ +using System; +using Volo.Abp.Application.Services; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.MongoDB.Applications; + +public class PersonProjectionAppService : ReadOnlyAppService +{ + public PersonProjectionAppService(IReadOnlyRepository repository) + : base(repository) + { + + } +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs new file mode 100644 index 0000000000..2a8e716a8c --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjectionDto.cs @@ -0,0 +1,9 @@ +using System; +using Volo.Abp.Application.Dtos; + +namespace Volo.Abp.MongoDB.Applications; + +public class PersonProjectionDto : EntityDto +{ + public string Name { get; set; } +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs new file mode 100644 index 0000000000..eac0c6fd3a --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/PersonProjector.cs @@ -0,0 +1,17 @@ +using System.Linq; +using Volo.Abp.ObjectMapping; +using Volo.Abp.TestApp.Domain; + +namespace Volo.Abp.MongoDB.Applications; + +public class PersonProjector : IQueryProjector +{ + public IQueryable ProjectTo(IQueryable source) + { + return source.Select(person => new PersonProjectionDto + { + Id = person.Id, + Name = person.Name + }); + } +} diff --git a/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/QueryProjection_Tests.cs b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/QueryProjection_Tests.cs new file mode 100644 index 0000000000..a7bf1367a6 --- /dev/null +++ b/framework/test/Volo.Abp.MongoDB.Tests/Volo/Abp/MongoDB/Applications/QueryProjection_Tests.cs @@ -0,0 +1,46 @@ +using System; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Application.Dtos; +using Volo.Abp.Domain.Entities; +using Volo.Abp.TestApp; +using Volo.Abp.TestApp.Domain; +using Xunit; + +namespace Volo.Abp.MongoDB.Applications; + +[Collection(MongoTestCollection.Name)] +public class QueryProjection_Tests : MongoDbTestBase +{ + private readonly PersonProjectionAppService _personProjectionAppService; + + public QueryProjection_Tests() + { + _personProjectionAppService = GetRequiredService(); + } + + [Fact] + public async Task Should_Get_A_Projected_Entity() + { + var dto = await _personProjectionAppService.GetAsync(TestDataBuilder.UserDouglasId); + + dto.Id.ShouldBe(TestDataBuilder.UserDouglasId); + dto.Name.ShouldBe("Douglas"); + } + + [Fact] + public async Task Should_Throw_EntityNotFoundException_For_A_Missing_Entity() + { + await Should.ThrowAsync>( + async () => await _personProjectionAppService.GetAsync(Guid.NewGuid())); + } + + [Fact] + public async Task Should_Get_A_Projected_List() + { + var result = await _personProjectionAppService.GetListAsync(new PagedAndSortedResultRequestDto()); + + result.TotalCount.ShouldBeGreaterThan(0); + result.Items.ShouldContain(x => x.Name == "Douglas"); + } +} diff --git a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml index e031837165..91b9841a65 100644 --- a/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml +++ b/modules/docs/src/Volo.Docs.Web/Pages/Documents/Project/Index.cshtml @@ -10,7 +10,6 @@ @using Volo.Abp.AspNetCore.Mvc.UI.Layout @using Volo.Abp.AspNetCore.Mvc.UI.Packages.Anchor @using Volo.Abp.AspNetCore.Mvc.UI.Packages.Clipboard -@using Volo.Abp.AspNetCore.Mvc.UI.Packages.MalihuCustomScrollbar @using Volo.Abp.AspNetCore.Mvc.UI.Packages.Popper @using Volo.Abp.AspNetCore.Mvc.UI.Packages.Prismjs @using Volo.Abp.AspNetCore.Mvc.UI.Theming @@ -38,7 +37,6 @@ @section styles { - @if (DocsUiOptions.Value.EnableEnlargeImage) @@ -64,7 +62,6 @@ } - @@ -306,7 +303,7 @@
-