Browse Source

Entity Framework Support (#1187)

* Entity Framework implementation.

* Usage repository.

* Migrations.

* More dialects

* More stores.

* Add health checks and migrator.

* Temp

* Asset store tests

* Fix

* Asset folder repository

* More stuff.

* TextState

* Add missing files.

* Temp

* T

* First round of content tests

* Progress.

* Run test

* Update dependencies.

* Fixture improvements.

* Update YDotNet

* Add trait.

* Fix compose.

* Fix filter

* Fix compose

* Run postgres earlier.

* Update squidex libs.

* Another attempt.

* Minor improvements.

* Update build.

* Upload all docker logs.

* Fix folder.

* Use other host names.

* Another attempt.

* Fixes

* Update SQL server

* AllowLoadLocalInfile

* Fixes

* Fix type.

* Use migrations

* Test and update migrations.

* Fix asset selector.
pull/1188/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
4fe545e8d6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 70
      .github/workflows/dev.yml
  2. 38
      .github/workflows/release.yml
  3. 2
      Dockerfile
  4. 2
      backend/.editorconfig
  5. 15
      backend/Squidex.sln
  6. 8
      backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs
  7. 2
      backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs
  8. 2
      backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs
  9. 2
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs
  10. 6
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs
  11. 6
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs
  12. 6
      backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs
  13. 4
      backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs
  14. 2
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs
  15. 2
      backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaActionHandler.cs
  16. 4
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs
  17. 2
      backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs
  18. 6
      backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs
  19. 2
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs
  20. 2
      backend/extensions/Squidex.Extensions/Actions/RuleHelper.cs
  21. 2
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs
  22. 2
      backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs
  23. 4
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs
  24. 4
      backend/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs
  25. 8
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseActionHandler.cs
  26. 2
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs
  27. 2
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs
  28. 6
      backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSource.cs
  29. 4
      backend/extensions/Squidex.Extensions/Samples/Middleware/DoubleLinkedContentMiddleware.cs
  30. 28
      backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs
  31. 2
      backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs
  32. 8
      backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs
  33. 20
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs
  34. 10
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs
  35. 58
      backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs
  36. 28
      backend/src/Migrations/MigrationPath.cs
  37. 2
      backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs
  38. 4
      backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs
  39. 2
      backend/src/Migrations/Migrations/MongoDb/ConvertOldSnapshotStores.cs
  40. 2
      backend/src/Migrations/OldEvents/AppClientPermission.cs
  41. 2
      backend/src/Migrations/OldEvents/AppContributorPermission.cs
  42. 8
      backend/src/Migrations/OldEvents/AppPatternAdded.cs
  43. 2
      backend/src/Migrations/OldEvents/AppPatternDeleted.cs
  44. 8
      backend/src/Migrations/OldEvents/AppPatternUpdated.cs
  45. 2
      backend/src/Migrations/OldEvents/ContentChangesPublished.cs
  46. 6
      backend/src/Migrations/OldEvents/SchemaCreated.cs
  47. 114
      backend/src/Squidex.Data.EntityFramework/AppDbContext.cs
  48. 25
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Apps/EFAppBuilder.cs
  49. 48
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Apps/EFAppEntity.cs
  50. 115
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Apps/EFAppRepository.cs
  51. 64
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/AssetSqlQueryBuilder.cs
  52. 47
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetBuilder.cs
  53. 40
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetEntity.cs
  54. 40
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetFolderEntity.cs
  55. 70
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetFolderRepository.cs
  56. 130
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetFolderRepository_SnapshotStore.cs
  57. 189
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetRepository.cs
  58. 135
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetRepository_SnapshotStore.cs
  59. 28
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentQueryBuilder.cs
  60. 73
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentBuilder.cs
  61. 186
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentEntity.cs
  62. 147
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository.cs
  63. 204
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository_Dynamic.cs
  64. 295
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository_SnapshotStore.cs
  65. 133
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository_Streaming.cs
  66. 29
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFReferenceEntity.cs
  67. 75
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Extensions.cs
  68. 11
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/QueriedStatus.cs
  69. 121
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexerState.cs
  70. 38
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/NullTextIndex.cs
  71. 27
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/History/EFHistoryBuilder.cs
  72. 75
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/History/EFHistoryEventRepository.cs
  73. 40
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleBuilder.cs
  74. 32
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleEntity.cs
  75. 65
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleEventEntity.cs
  76. 167
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleEventRepository.cs
  77. 45
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleRepository.cs
  78. 26
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Schemas/EFSchemaBuilder.cs
  79. 36
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Schemas/EFSchemaEntity.cs
  80. 119
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Schemas/EFSchemaRepository.cs
  81. 21
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Teams/EFTeamBuilder.cs
  82. 39
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Teams/EFTeamEntity.cs
  83. 82
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Teams/EFTeamRepository.cs
  84. 24
      backend/src/Squidex.Data.EntityFramework/Domain/Users/EFUserFactory.cs
  85. 21
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Caching/EFCacheBuilder.cs
  86. 22
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Caching/EFCacheEntity.cs
  87. 147
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Caching/EFDistributedCache.cs
  88. 143
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs
  89. 9
      backend/src/Squidex.Data.EntityFramework/Infrastructure/IVersionedEntity.cs
  90. 13
      backend/src/Squidex.Data.EntityFramework/Infrastructure/JsonAttribute.cs
  91. 184
      backend/src/Squidex.Data.EntityFramework/Infrastructure/JsonConversion.cs
  92. 25
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Log/EFRequestBuilder.cs
  93. 38
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Log/EFRequestEntity.cs
  94. 101
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Log/EFRequestLogRepository.cs
  95. 32
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/DatabaseCreator.cs
  96. 25
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/DatabaseMigrator.cs
  97. 21
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/EFMigrationBuilder.cs
  98. 22
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/EFMigrationEntity.cs
  99. 84
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/EFMigrationStatus.cs
  100. 30
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/Extensions.cs

70
.github/workflows/dev.yml

@ -40,11 +40,19 @@ jobs:
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
tags: squidex-local tags: squidex-local
- name: Build - TestContainers
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: mcr.microsoft.com/dotnet/sdk:8.0
default_network: host
volumes: ${{ github.workspace }}:/src
run: dotnet test /src/backend/Squidex.sln --filter Category==TestContainers
- name: Test - Start Compose - name: Test - Start Compose
run: docker compose up -d run: docker compose up -d
working-directory: tools/TestSuite working-directory: tools/TestSuite
- name: Test - RUN - name: Test - RUN on Mongo
uses: kohlerdominik/docker-run-action@v2.0.0 uses: kohlerdominik/docker-run-action@v2.0.0
with: with:
image: mcr.microsoft.com/dotnet/sdk:8.0 image: mcr.microsoft.com/dotnet/sdk:8.0
@ -58,21 +66,63 @@ jobs:
volumes: ${{ github.workspace }}:/src volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: Test - RUN on path - name: Test - RUN on Postgres
uses: kohlerdominik/docker-run-action@v2.0.0 uses: kohlerdominik/docker-run-action@v2.0.0
with: with:
image: mcr.microsoft.com/dotnet/sdk:8.0 image: mcr.microsoft.com/dotnet/sdk:8.0
environment: | environment: |
CONFIG__BACKUPURL=http://localhost:5000 CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60 CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8081/squidex CONFIG__SERVER__URL=http://localhost:8083
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host default_network: host
options: --name test2 options: --name test2
volumes: ${{ github.workspace }}:/src volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter "Category!=NotAutomated & Category!=MongoOnly"
- name: Test - RUN on MySql
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8084
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host
options: --name test3
volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter "Category!=NotAutomated & Category!=MongoOnly"
- name: Test - RUN on SqlServer
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8085
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host
options: --name test4
volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter "Category!=NotAutomated & Category!=MongoOnly"
- name: Test - RUN on Mongo with path
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8081/squidex
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host
options: --name test5
volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: Test - RUN with dedicated collections - name: Test - RUN on Mongo with dedicated collections
uses: kohlerdominik/docker-run-action@v2.0.0 uses: kohlerdominik/docker-run-action@v2.0.0
with: with:
image: mcr.microsoft.com/dotnet/sdk:8.0 image: mcr.microsoft.com/dotnet/sdk:8.0
@ -82,7 +132,7 @@ jobs:
CONFIG__SERVER__URL=http://localhost:8082 CONFIG__SERVER__URL=http://localhost:8082
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host default_network: host
options: --name test3 options: --name test6
volumes: ${{ github.workspace }}:/src volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
@ -112,8 +162,14 @@ jobs:
if: failure() if: failure()
uses: jwalton/gh-docker-logs@v2.2.2 uses: jwalton/gh-docker-logs@v2.2.2
with: with:
images: 'squidex-local,squidex/resizer' dest: './docker-logs'
tail: '100'
- name: Test - Upload docker logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-logs
path: './docker-logs'
- name: Test - Cleanup - name: Test - Cleanup
if: always() if: always()

38
.github/workflows/release.yml

@ -54,34 +54,6 @@ jobs:
volumes: ${{ github.workspace }}:/src volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: Test - RUN on path
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: mcr.microsoft.com/dotnet/sdk:8.0
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8081/squidex
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host
options: --name test2
volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: Test - RUN with dedicated collections
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: squidex/build:9
environment: |
CONFIG__BACKUPURL=http://localhost:5000
CONFIG__WAIT=60
CONFIG__SERVER__URL=http://localhost:8082
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host
options: --name test3
volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
- name: Test - Install Playwright Dependencies - name: Test - Install Playwright Dependencies
run: npm ci run: npm ci
working-directory: './tools/e2e' working-directory: './tools/e2e'
@ -108,8 +80,14 @@ jobs:
if: failure() if: failure()
uses: jwalton/gh-docker-logs@v2.2.2 uses: jwalton/gh-docker-logs@v2.2.2
with: with:
images: 'squidex-local,squidex/resizer' dest: './docker-logs'
tail: '100'
- name: Test - Upload docker logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-logs
path: './docker-logs'
- name: Test - Cleanup - name: Test - Cleanup
if: always() if: always()

2
Dockerfile

@ -28,7 +28,7 @@ RUN dotnet restore
COPY backend . COPY backend .
# Test Backend # Test Backend
RUN dotnet test --no-restore --filter Category!=Dependencies RUN dotnet test --no-restore --filter "Category!=Dependencies & Category!=TestContainer"
# Publish # Publish
RUN dotnet publish --no-restore src/Squidex/Squidex.csproj --output /build/ --configuration Release -p:version=$SQUIDEX__BUILD__VERSION RUN dotnet publish --no-restore src/Squidex/Squidex.csproj --output /build/ --configuration Release -p:version=$SQUIDEX__BUILD__VERSION

2
backend/.editorconfig

@ -55,7 +55,7 @@ dotnet_diagnostic.MA0004.severity = none
dotnet_diagnostic.MA0006.severity = none dotnet_diagnostic.MA0006.severity = none
# MA0007: Add a comma after the last value # MA0007: Add a comma after the last value
dotnet_diagnostic.MA0007.severity = none dotnet_diagnostic.MA0007.severity = warning
# MA0008: Add StructLayoutAttribute # MA0008: Add StructLayoutAttribute
dotnet_diagnostic.MA0008.severity = none dotnet_diagnostic.MA0008.severity = none

15
backend/Squidex.sln

@ -56,6 +56,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.MongoDb", "src
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.Tests", "tests\Squidex.Data.Tests\Squidex.Data.Tests.csproj", "{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.Tests", "tests\Squidex.Data.Tests\Squidex.Data.Tests.csproj", "{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Data.EntityFramework", "src\Squidex.Data.EntityFramework\Squidex.Data.EntityFramework.csproj", "{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -262,6 +264,18 @@ Global
{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}.Release|x64.Build.0 = Release|Any CPU {AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}.Release|x64.Build.0 = Release|Any CPU
{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}.Release|x86.ActiveCfg = Release|Any CPU {AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}.Release|x86.ActiveCfg = Release|Any CPU
{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}.Release|x86.Build.0 = Release|Any CPU {AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}.Release|x86.Build.0 = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Debug|x64.ActiveCfg = Debug|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Debug|x64.Build.0 = Debug|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Debug|x86.ActiveCfg = Debug|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Debug|x86.Build.0 = Debug|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|Any CPU.Build.0 = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|x64.ActiveCfg = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|x64.Build.0 = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|x86.ActiveCfg = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -284,6 +298,7 @@ Global
{23615A39-F3FB-4575-A91C-535899DFB636} = {94207AA6-4923-4183-A558-E0F8196B8CA3} {23615A39-F3FB-4575-A91C-535899DFB636} = {94207AA6-4923-4183-A558-E0F8196B8CA3}
{F754F05E-02FF-47B2-AB46-BB05C7E6B29D} = {3378B841-53F8-48CC-87C1-1B30CF912BFD} {F754F05E-02FF-47B2-AB46-BB05C7E6B29D} = {3378B841-53F8-48CC-87C1-1B30CF912BFD}
{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD} = {3378B841-53F8-48CC-87C1-1B30CF912BFD} {AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD} = {3378B841-53F8-48CC-87C1-1B30CF912BFD}
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA} = {3378B841-53F8-48CC-87C1-1B30CF912BFD}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {02F2E872-3141-44F5-BD6A-33CD84E9FE08} SolutionGuid = {02F2E872-3141-44F5-BD6A-33CD84E9FE08}

8
backend/extensions/Squidex.Extensions/Actions/Algolia/AlgoliaActionHandler.cs

@ -71,8 +71,8 @@ public sealed class AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEn
{ {
More = new Dictionary<string, object> More = new Dictionary<string, object>
{ {
["error"] = $"Invalid JSON: {ex.Message}" ["error"] = $"Invalid JSON: {ex.Message}",
} },
}; };
} }
@ -85,7 +85,7 @@ public sealed class AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEn
ApiKey = action.ApiKey, ApiKey = action.ApiKey,
Content = delete ? null : serializer.Serialize(content, true), Content = delete ? null : serializer.Serialize(content, true),
ContentId = contentId, ContentId = contentId,
IndexName = indexName IndexName = indexName,
}; };
return (ruleDescription, ruleJob); return (ruleDescription, ruleJob);
@ -106,7 +106,7 @@ public sealed class AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEn
{ {
var raw = new[] var raw = new[]
{ {
new JRaw(job.Content) new JRaw(job.Content),
}; };
var response = await index.SaveObjectsAsync(raw, null, ct, true); var response = await index.SaveObjectsAsync(raw, null, ct, true);

2
backend/extensions/Squidex.Extensions/Actions/AzureQueue/AzureQueueActionHandler.cs

@ -46,7 +46,7 @@ public sealed class AzureQueueActionHandler(RuleEventFormatter formatter) : Rule
{ {
QueueConnectionString = action.ConnectionString, QueueConnectionString = action.ConnectionString,
QueueName = queueName!, QueueName = queueName!,
MessageBodyV2 = requestBody MessageBodyV2 = requestBody,
}; };
return (ruleText, ruleJob); return (ruleText, ruleJob);

2
backend/extensions/Squidex.Extensions/Actions/Comment/CommentActionHandler.cs

@ -26,7 +26,7 @@ public sealed class CommentActionHandler(RuleEventFormatter formatter, ICollabor
var ruleJob = new CommentCreated var ruleJob = new CommentCreated
{ {
AppId = contentEvent.AppId AppId = contentEvent.AppId,
}; };
var text = await FormatAsync(action.Text, @event); var text = await FormatAsync(action.Text, @event);

2
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentActionHandler.cs

@ -25,7 +25,7 @@ public sealed class CreateContentActionHandler(RuleEventFormatter formatter, IAp
{ {
var ruleJob = new Command var ruleJob = new Command
{ {
AppId = @event.AppId AppId = @event.AppId,
}; };
var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true) var schema = await appProvider.GetSchemaAsync(@event.AppId.Id, action.Schema, true)

6
backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectActionHandler.cs

@ -54,7 +54,7 @@ internal sealed partial class DeepDetectActionHandler(
AssetId = assetEvent.Id, AssetId = assetEvent.Id,
MaximumTags = action.MaximumTags, MaximumTags = action.MaximumTags,
MinimumPropability = action.MinimumProbability, MinimumPropability = action.MinimumProbability,
Url = urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString(), assetEvent.FileVersion) Url = urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString(), assetEvent.FileVersion),
}; };
return Task.FromResult((Description, ruleJob)); return Task.FromResult((Description, ruleJob));
@ -81,7 +81,7 @@ internal sealed partial class DeepDetectActionHandler(
data = new[] data = new[]
{ {
job.Url, job.Url,
} },
}, ct); }, ct);
var body = await response.Content.ReadAsStringAsync(ct); var body = await response.Content.ReadAsStringAsync(ct);
@ -120,7 +120,7 @@ internal sealed partial class DeepDetectActionHandler(
AssetId = asset.Id, AssetId = asset.Id,
AppId = asset.AppId, AppId = asset.AppId,
Actor = job.Actor, Actor = job.Actor,
FromRule = true FromRule = true,
}; };
foreach (var tag in tags) foreach (var tag in tags)

6
backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseActionHandler.cs

@ -24,7 +24,7 @@ public sealed class DiscourseActionHandler(RuleEventFormatter formatter, IHttpCl
var json = new Dictionary<string, object?> var json = new Dictionary<string, object?>
{ {
["title"] = await FormatAsync(action.Title!, @event) ["title"] = await FormatAsync(action.Title!, @event),
}; };
if (action.Topic != null) if (action.Topic != null)
@ -46,7 +46,7 @@ public sealed class DiscourseActionHandler(RuleEventFormatter formatter, IHttpCl
ApiKey = action.ApiKey, ApiKey = action.ApiKey,
ApiUserName = action.ApiUsername, ApiUserName = action.ApiUsername,
RequestUrl = url, RequestUrl = url,
RequestBody = requestBody RequestBody = requestBody,
}; };
var description = var description =
@ -64,7 +64,7 @@ public sealed class DiscourseActionHandler(RuleEventFormatter formatter, IHttpCl
var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl)
{ {
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json"),
}; };
request.Headers.TryAddWithoutValidation("Api-Key", job.ApiKey); request.Headers.TryAddWithoutValidation("Api-Key", job.ApiKey);

6
backend/extensions/Squidex.Extensions/Actions/ElasticSearch/ElasticSearchActionHandler.cs

@ -54,7 +54,7 @@ public sealed class ElasticSearchActionHandler(RuleEventFormatter formatter, ISc
ServerHost = action.Host.ToString(), ServerHost = action.Host.ToString(),
ServerUser = action.Username, ServerUser = action.Username,
ServerPassword = action.Password, ServerPassword = action.Password,
ContentId = contentId ContentId = contentId,
}; };
if (delete) if (delete)
@ -88,8 +88,8 @@ public sealed class ElasticSearchActionHandler(RuleEventFormatter formatter, ISc
{ {
More = new Dictionary<string, object> More = new Dictionary<string, object>
{ {
["error"] = $"Invalid JSON: {ex.Message}" ["error"] = $"Invalid JSON: {ex.Message}",
} },
}; };
} }

4
backend/extensions/Squidex.Extensions/Actions/Email/EmailActionHandler.cs

@ -28,7 +28,7 @@ public sealed class EmailActionHandler(RuleEventFormatter formatter) : RuleActio
MessageFrom = (await FormatAsync(action.MessageFrom, @event))!, MessageFrom = (await FormatAsync(action.MessageFrom, @event))!,
MessageTo = (await FormatAsync(action.MessageTo, @event))!, MessageTo = (await FormatAsync(action.MessageTo, @event))!,
MessageSubject = (await FormatAsync(action.MessageSubject, @event))!, MessageSubject = (await FormatAsync(action.MessageSubject, @event))!,
MessageBody = (await FormatAsync(action.MessageBody, @event))! MessageBody = (await FormatAsync(action.MessageBody, @event))!,
}; };
var description = $"Send an email to {ruleJob.MessageTo}"; var description = $"Send an email to {ruleJob.MessageTo}";
@ -58,7 +58,7 @@ public sealed class EmailActionHandler(RuleEventFormatter formatter) : RuleActio
smtpMessage.Body = new TextPart(TextFormat.Html) smtpMessage.Body = new TextPart(TextFormat.Html)
{ {
Text = job.MessageBody Text = job.MessageBody,
}; };
smtpMessage.Subject = job.MessageSubject; smtpMessage.Subject = job.MessageSubject;

2
backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyActionHandler.cs

@ -30,7 +30,7 @@ public sealed class FastlyActionHandler(RuleEventFormatter formatter, IHttpClien
{ {
Key = id, Key = id,
FastlyApiKey = action.ApiKey, FastlyApiKey = action.ApiKey,
FastlyServiceID = action.ServiceId FastlyServiceID = action.ServiceId,
}; };
return (Description, ruleJob); return (Description, ruleJob);

2
backend/extensions/Squidex.Extensions/Actions/Kafka/KafkaActionHandler.cs

@ -46,7 +46,7 @@ public sealed class KafkaActionHandler(RuleEventFormatter formatter, KafkaProduc
Headers = await ParseHeadersAsync(action.Headers, @event), Headers = await ParseHeadersAsync(action.Headers, @event),
Schema = action.Schema, Schema = action.Schema,
PartitionKey = await FormatAsync(action.PartitionKey, @event), PartitionKey = await FormatAsync(action.PartitionKey, @event),
PartitionCount = action.PartitionCount PartitionCount = action.PartitionCount,
}; };
return (Description, ruleJob); return (Description, ruleJob);

4
backend/extensions/Squidex.Extensions/Actions/Medium/MediumActionHandler.cs

@ -39,7 +39,7 @@ public sealed class MediumActionHandler(RuleEventFormatter formatter, IHttpClien
contentFormat = action.IsHtml ? "html" : "markdown", contentFormat = action.IsHtml ? "html" : "markdown",
content = await FormatAsync(action.Content, @event), content = await FormatAsync(action.Content, @event),
canonicalUrl = await FormatAsync(action.CanonicalUrl, @event), canonicalUrl = await FormatAsync(action.CanonicalUrl, @event),
tags = await ParseTagsAsync(@event, action) tags = await ParseTagsAsync(@event, action),
}; };
ruleJob.RequestBody = ToJson(requestBody); ruleJob.RequestBody = ToJson(requestBody);
@ -108,7 +108,7 @@ public sealed class MediumActionHandler(RuleEventFormatter formatter, IHttpClien
{ {
var request = new HttpRequestMessage(HttpMethod.Post, path) var request = new HttpRequestMessage(HttpMethod.Post, path)
{ {
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json"),
}; };
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");

2
backend/extensions/Squidex.Extensions/Actions/Notification/NotificationActionHandler.cs

@ -41,7 +41,7 @@ public sealed class NotificationActionHandler(RuleEventFormatter formatter, ICol
CommentId = DomainId.NewGuid(), CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id), CommentsId = DomainId.Create(user.Id),
FromRule = true, FromRule = true,
Text = (await FormatAsync(action.Text, @event))! Text = (await FormatAsync(action.Text, @event))!,
}; };
if (!string.IsNullOrWhiteSpace(action.Url)) if (!string.IsNullOrWhiteSpace(action.Url))

6
backend/extensions/Squidex.Extensions/Actions/OpenSearch/OpenSearchActionHandler.cs

@ -54,7 +54,7 @@ public sealed class OpenSearchActionHandler(RuleEventFormatter formatter, IScrip
ServerHost = action.Host.ToString(), ServerHost = action.Host.ToString(),
ServerUser = action.Username, ServerUser = action.Username,
ServerPassword = action.Password, ServerPassword = action.Password,
ContentId = contentId ContentId = contentId,
}; };
if (delete) if (delete)
@ -88,8 +88,8 @@ public sealed class OpenSearchActionHandler(RuleEventFormatter formatter, IScrip
{ {
More = new Dictionary<string, object> More = new Dictionary<string, object>
{ {
["error"] = $"Invalid JSON: {ex.Message}" ["error"] = $"Invalid JSON: {ex.Message}",
} },
}; };
} }

2
backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderActionHandler.cs

@ -32,7 +32,7 @@ public sealed class PrerenderActionHandler(RuleEventFormatter formatter, IHttpCl
var request = new HttpRequestMessage(HttpMethod.Post, "/recache") var request = new HttpRequestMessage(HttpMethod.Post, "/recache")
{ {
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json"),
}; };
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct); return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);

2
backend/extensions/Squidex.Extensions/Actions/RuleHelper.cs

@ -21,7 +21,7 @@ public static class RuleHelper
// Script vars are just wrappers over dictionaries for better performance. // Script vars are just wrappers over dictionaries for better performance.
var vars = new EventScriptVars var vars = new EventScriptVars
{ {
Event = @event Event = @event,
}; };
return scriptEngine.Evaluate(vars, expression); return scriptEngine.Evaluate(vars, expression);

2
backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRAction.cs

@ -72,5 +72,5 @@ public enum ActionTypeEnum
{ {
Broadcast, Broadcast,
User, User,
Group Group,
} }

2
backend/extensions/Squidex.Extensions/Actions/SignalR/SignalRActionHandler.cs

@ -54,7 +54,7 @@ public sealed class SignalRActionHandler(RuleEventFormatter formatter) : RuleAct
HubName = hubName!, HubName = hubName!,
MethodName = action.MethodName, MethodName = action.MethodName,
MethodPayload = requestBody!, MethodPayload = requestBody!,
Targets = target.Split("\n") Targets = target.Split("\n"),
}; };
return (ruleText, ruleJob); return (ruleText, ruleJob);

4
backend/extensions/Squidex.Extensions/Actions/Slack/SlackActionHandler.cs

@ -24,7 +24,7 @@ public sealed class SlackActionHandler(RuleEventFormatter formatter, IHttpClient
var ruleJob = new SlackJob var ruleJob = new SlackJob
{ {
RequestUrl = action.WebhookUrl.ToString(), RequestUrl = action.WebhookUrl.ToString(),
RequestBody = ToJson(body) RequestBody = ToJson(body),
}; };
return (Description, ruleJob); return (Description, ruleJob);
@ -37,7 +37,7 @@ public sealed class SlackActionHandler(RuleEventFormatter formatter, IHttpClient
var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl) var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl)
{ {
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json"),
}; };
return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct); return await httpClient.OneWayRequestAsync(request, job.RequestBody, ct);

4
backend/extensions/Squidex.Extensions/Actions/Twitter/TweetActionHandler.cs

@ -26,7 +26,7 @@ public sealed class TweetActionHandler(RuleEventFormatter formatter, IOptions<Tw
{ {
Text = (await FormatAsync(action.Text, @event))!, Text = (await FormatAsync(action.Text, @event))!,
AccessToken = action.AccessToken, AccessToken = action.AccessToken,
AccessSecret = action.AccessSecret AccessSecret = action.AccessSecret,
}; };
return (Description, ruleJob); return (Description, ruleJob);
@ -43,7 +43,7 @@ public sealed class TweetActionHandler(RuleEventFormatter formatter, IOptions<Tw
var request = new Dictionary<string, object> var request = new Dictionary<string, object>
{ {
["status"] = job.Text ["status"] = job.Text,
}; };
await tokens.Statuses.UpdateAsync(request, ct); await tokens.Statuses.UpdateAsync(request, ct);

8
backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseActionHandler.cs

@ -41,7 +41,7 @@ public sealed class TypesenseActionHandler(RuleEventFormatter formatter, IHttpCl
{ {
ServerUrl = $"{action.Host.ToString().TrimEnd('/')}/collections/{indexName}/documents", ServerUrl = $"{action.Host.ToString().TrimEnd('/')}/collections/{indexName}/documents",
ServerKey = action.ApiKey, ServerKey = action.ApiKey,
ContentId = contentId ContentId = contentId,
}; };
if (delete) if (delete)
@ -75,8 +75,8 @@ public sealed class TypesenseActionHandler(RuleEventFormatter formatter, IHttpCl
{ {
More = new Dictionary<string, object> More = new Dictionary<string, object>
{ {
["error"] = $"Invalid JSON: {ex.Message}" ["error"] = $"Invalid JSON: {ex.Message}",
} },
}; };
} }
@ -104,7 +104,7 @@ public sealed class TypesenseActionHandler(RuleEventFormatter formatter, IHttpCl
{ {
request = new HttpRequestMessage(HttpMethod.Post, $"{job.ServerUrl}?action=upsert") request = new HttpRequestMessage(HttpMethod.Post, $"{job.ServerUrl}?action=upsert")
{ {
Content = new StringContent(job.Content, Encoding.UTF8, "application/json") Content = new StringContent(job.Content, Encoding.UTF8, "application/json"),
}; };
} }
else else

2
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookAction.cs

@ -58,5 +58,5 @@ public enum WebhookMethod
PUT, PUT,
GET, GET,
DELETE, DELETE,
PATCH PATCH,
} }

2
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookActionHandler.cs

@ -44,7 +44,7 @@ public sealed class WebhookActionHandler(RuleEventFormatter formatter, IHttpClie
RequestSignature = requestSignature, RequestSignature = requestSignature,
RequestBody = requestBody!, RequestBody = requestBody!,
RequestBodyType = action.PayloadType, RequestBodyType = action.PayloadType,
Headers = await ParseHeadersAsync(action.Headers, @event) Headers = await ParseHeadersAsync(action.Headers, @event),
}; };
return (ruleText, ruleJob); return (ruleText, ruleJob);

6
backend/extensions/Squidex.Extensions/Assets/Azure/AzureMetadataSource.cs

@ -25,13 +25,13 @@ public sealed class AzureMetadataSource : IAssetMetadataSource
[ [
' ', ' ',
'_', '_',
'-' '-',
]; ];
private readonly List<VisualFeatureTypes?> features = private readonly List<VisualFeatureTypes?> features =
[ [
VisualFeatureTypes.Categories, VisualFeatureTypes.Categories,
VisualFeatureTypes.Description, VisualFeatureTypes.Description,
VisualFeatureTypes.Color VisualFeatureTypes.Color,
]; ];
public int Order => int.MaxValue; public int Order => int.MaxValue;
@ -41,7 +41,7 @@ public sealed class AzureMetadataSource : IAssetMetadataSource
{ {
client = new ComputerVisionClient(new ApiKeyServiceClientCredentials(options.Value.ApiKey)) client = new ComputerVisionClient(new ApiKeyServiceClientCredentials(options.Value.ApiKey))
{ {
Endpoint = options.Value.Endpoint Endpoint = options.Value.Endpoint,
}; };
this.log = log; this.log = log;

4
backend/extensions/Squidex.Extensions/Samples/Middleware/DoubleLinkedContentMiddleware.cs

@ -69,7 +69,7 @@ public sealed class DoubleLinkedContentMiddleware(IContentLoader contentLoader)
// Add the reference to the new referenced content. // Add the reference to the new referenced content.
data["referencing"] = new ContentFieldData data["referencing"] = new ContentFieldData
{ {
["iv"] = JsonValue.Array(content.Id) ["iv"] = JsonValue.Array(content.Id),
}; };
await UpdateReferencing(context, newReferenced, data, ct); await UpdateReferencing(context, newReferenced, data, ct);
@ -90,7 +90,7 @@ public sealed class DoubleLinkedContentMiddleware(IContentLoader contentLoader)
DoNotScript = true, DoNotScript = true,
DoNotValidate = true, DoNotValidate = true,
Data = data, Data = data,
ExpectedVersion = reference.Version ExpectedVersion = reference.Version,
}, ct); }, ct);
} }

28
backend/extensions/Squidex.Extensions/Text/Azure/AzureIndexDefinition.cs

@ -15,7 +15,7 @@ public static class AzureIndexDefinition
private static readonly Dictionary<string, (string Field, string Analyzer)> FieldAnalyzers = new (StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, (string Field, string Analyzer)> FieldAnalyzers = new (StringComparer.OrdinalIgnoreCase)
{ {
["iv"] = ("iv", LexicalAnalyzerName.StandardLucene.ToString()), ["iv"] = ("iv", LexicalAnalyzerName.StandardLucene.ToString()),
["zh"] = ("zh", LexicalAnalyzerName.ZhHansLucene.ToString()) ["zh"] = ("zh", LexicalAnalyzerName.ZhHansLucene.ToString()),
}; };
static AzureIndexDefinition() static AzureIndexDefinition()
@ -78,44 +78,44 @@ public static class AzureIndexDefinition
{ {
new SimpleField("docId", SearchFieldDataType.String) new SimpleField("docId", SearchFieldDataType.String)
{ {
IsKey = true IsKey = true,
}, },
new SimpleField("appId", SearchFieldDataType.String) new SimpleField("appId", SearchFieldDataType.String)
{ {
IsFilterable = true IsFilterable = true,
}, },
new SimpleField("appName", SearchFieldDataType.String) new SimpleField("appName", SearchFieldDataType.String)
{ {
IsFilterable = false IsFilterable = false,
}, },
new SimpleField("contentId", SearchFieldDataType.String) new SimpleField("contentId", SearchFieldDataType.String)
{ {
IsFilterable = false IsFilterable = false,
}, },
new SimpleField("schemaId", SearchFieldDataType.String) new SimpleField("schemaId", SearchFieldDataType.String)
{ {
IsFilterable = true IsFilterable = true,
}, },
new SimpleField("schemaName", SearchFieldDataType.String) new SimpleField("schemaName", SearchFieldDataType.String)
{ {
IsFilterable = false IsFilterable = false,
}, },
new SimpleField("serveAll", SearchFieldDataType.Boolean) new SimpleField("serveAll", SearchFieldDataType.Boolean)
{ {
IsFilterable = true IsFilterable = true,
}, },
new SimpleField("servePublished", SearchFieldDataType.Boolean) new SimpleField("servePublished", SearchFieldDataType.Boolean)
{ {
IsFilterable = true IsFilterable = true,
}, },
new SimpleField("geoObject", SearchFieldDataType.GeographyPoint) new SimpleField("geoObject", SearchFieldDataType.GeographyPoint)
{ {
IsFilterable = true IsFilterable = true,
}, },
new SimpleField("geoField", SearchFieldDataType.String) new SimpleField("geoField", SearchFieldDataType.String)
{ {
IsFilterable = true IsFilterable = true,
} },
}; };
foreach (var (field, analyzer) in FieldAnalyzers.Values) foreach (var (field, analyzer) in FieldAnalyzers.Values)
@ -125,13 +125,13 @@ public static class AzureIndexDefinition
{ {
IsFilterable = false, IsFilterable = false,
IsFacetable = false, IsFacetable = false,
AnalyzerName = analyzer AnalyzerName = analyzer,
}); });
} }
var index = new SearchIndex(indexName) var index = new SearchIndex(indexName)
{ {
Fields = fields Fields = fields,
}; };
return index; return index;

2
backend/extensions/Squidex.Extensions/Text/Azure/AzureTextIndex.cs

@ -144,7 +144,7 @@ public sealed class AzureTextIndex : IInitializable, ITextIndex
{ {
var searchOptions = new SearchOptions var searchOptions = new SearchOptions
{ {
Filter = filter Filter = filter,
}; };
searchOptions.Select.Add("contentId"); searchOptions.Select.Add("contentId");

8
backend/extensions/Squidex.Extensions/Text/Azure/CommandFactory.cs

@ -48,8 +48,8 @@ public static class CommandFactory
coordinates = new[] coordinates = new[]
{ {
point.Coordinate.X, point.Coordinate.X,
point.Coordinate.Y point.Coordinate.Y,
} },
}; };
break; break;
} }
@ -69,7 +69,7 @@ public static class CommandFactory
["serveAll"] = upsert.ServeAll, ["serveAll"] = upsert.ServeAll,
["servePublished"] = upsert.ServePublished, ["servePublished"] = upsert.ServePublished,
["geoField"] = geoField, ["geoField"] = geoField,
["geoObject"] = geoObject ["geoObject"] = geoObject,
}; };
foreach (var (key, value) in upsert.Texts) foreach (var (key, value) in upsert.Texts)
@ -96,7 +96,7 @@ public static class CommandFactory
{ {
["docId"] = update.ToDocId(), ["docId"] = update.ToDocId(),
["serveAll"] = update.ServeAll, ["serveAll"] = update.ServeAll,
["servePublished"] = update.ServePublished ["servePublished"] = update.ServePublished,
}; };
batch.Add(IndexDocumentsAction.MergeOrUpload(document)); batch.Add(IndexDocumentsAction.MergeOrUpload(document));

20
backend/extensions/Squidex.Extensions/Text/ElasticSearch/CommandFactory.cs

@ -43,7 +43,7 @@ public static class CommandFactory
geoObject = new geoObject = new
{ {
lat = point.Coordinate.X, lat = point.Coordinate.X,
lon = point.Coordinate.Y lon = point.Coordinate.Y,
}; };
break; break;
} }
@ -57,8 +57,8 @@ public static class CommandFactory
index = new index = new
{ {
_id = upsert.ToDocId(), _id = upsert.ToDocId(),
_index = indexName _index = indexName,
} },
}); });
var texts = new Dictionary<string, string>(); var texts = new Dictionary<string, string>();
@ -91,7 +91,7 @@ public static class CommandFactory
servePublished = upsert.ServePublished, servePublished = upsert.ServePublished,
texts, texts,
geoField, geoField,
geoObject geoObject,
}); });
} }
} }
@ -103,8 +103,8 @@ public static class CommandFactory
update = new update = new
{ {
_id = update.ToDocId(), _id = update.ToDocId(),
_index = indexName _index = indexName,
} },
}); });
args.Add(new args.Add(new
@ -112,8 +112,8 @@ public static class CommandFactory
doc = new doc = new
{ {
serveAll = update.ServeAll, serveAll = update.ServeAll,
servePublished = update.ServePublished servePublished = update.ServePublished,
} },
}); });
} }
@ -124,8 +124,8 @@ public static class CommandFactory
delete = new delete = new
{ {
_id = delete.ToDocId(), _id = delete.ToDocId(),
_index = indexName _index = indexName,
} },
}); });
} }
} }

10
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchIndexDefinition.cs

@ -46,7 +46,7 @@ public static class ElasticSearchIndexDefinition
["es"] = "spanish", ["es"] = "spanish",
["sv"] = "swedish", ["sv"] = "swedish",
["tr"] = "turkish", ["tr"] = "turkish",
["th"] = "thai" ["th"] = "thai",
}; };
static ElasticSearchIndexDefinition() static ElasticSearchIndexDefinition()
@ -103,9 +103,9 @@ public static class ElasticSearchIndexDefinition
{ {
["geoObject"] = new ["geoObject"] = new
{ {
type = "geo_point" type = "geo_point",
} },
} },
}; };
foreach (var (key, analyzer) in FieldAnalyzers) foreach (var (key, analyzer) in FieldAnalyzers)
@ -113,7 +113,7 @@ public static class ElasticSearchIndexDefinition
query.properties[GetFieldPath(key)] = new query.properties[GetFieldPath(key)] = new
{ {
type = "text", type = "text",
analyzer analyzer,
}; };
} }

58
backend/extensions/Squidex.Extensions/Text/ElasticSearch/ElasticSearchTextIndex.cs

@ -71,22 +71,22 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
{ {
term = new Dictionary<string, object> term = new Dictionary<string, object>
{ {
["schemaId.keyword"] = query.SchemaId.ToString() ["schemaId.keyword"] = query.SchemaId.ToString(),
} },
}, },
new new
{ {
term = new Dictionary<string, string> term = new Dictionary<string, string>
{ {
["geoField.keyword"] = query.Field ["geoField.keyword"] = query.Field,
} },
}, },
new new
{ {
term = new Dictionary<string, string> term = new Dictionary<string, string>
{ {
[serveField] = "true" [serveField] = "true",
} },
}, },
new new
{ {
@ -95,19 +95,19 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
geoObject = new geoObject = new
{ {
lat = query.Latitude, lat = query.Latitude,
lon = query.Longitude lon = query.Longitude,
}, },
distance = $"{query.Radius}m" distance = $"{query.Radius}m",
} },
} },
} },
} },
}, },
_source = new[] _source = new[]
{ {
"contentId" "contentId",
}, },
size = query.Take size = query.Take,
}; };
return await SearchAsync(elasticQuery, ct); return await SearchAsync(elasticQuery, ct);
@ -140,32 +140,32 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
{ {
term = new Dictionary<string, object> term = new Dictionary<string, object>
{ {
["appId.keyword"] = app.Id.ToString() ["appId.keyword"] = app.Id.ToString(),
} },
}, },
new new
{ {
term = new Dictionary<string, string> term = new Dictionary<string, string>
{ {
[serveField] = "true" [serveField] = "true",
} },
} },
}, },
must = new must = new
{ {
query_string = new query_string = new
{ {
query = parsed.Text query = parsed.Text,
} },
}, },
should = new List<object>() should = new List<object>(),
} },
}, },
_source = new[] _source = new[]
{ {
"contentId" "contentId",
}, },
size = query.Take size = query.Take,
}; };
if (query.RequiredSchemaIds?.Count > 0) if (query.RequiredSchemaIds?.Count > 0)
@ -174,8 +174,8 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
{ {
terms = new Dictionary<string, object> terms = new Dictionary<string, object>
{ {
["schemaId.keyword"] = query.RequiredSchemaIds.Select(x => x.ToString()).ToArray() ["schemaId.keyword"] = query.RequiredSchemaIds.Select(x => x.ToString()).ToArray(),
} },
}; };
elasticQuery.query.@bool.filter.Add(bySchema); elasticQuery.query.@bool.filter.Add(bySchema);
@ -186,8 +186,8 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
{ {
terms = new Dictionary<string, object?> terms = new Dictionary<string, object?>
{ {
["schemaId.keyword"] = query.PreferredSchemaId.ToString() ["schemaId.keyword"] = query.PreferredSchemaId.ToString(),
} },
}; };
elasticQuery.query.@bool.should.Add(bySchema); elasticQuery.query.@bool.should.Add(bySchema);

28
backend/src/Migrations/MigrationPath.cs

@ -7,10 +7,10 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Migrations.Migrations; using Migrations.Migrations;
using Migrations.Migrations.Backup;
using Migrations.Migrations.MongoDb; using Migrations.Migrations.MongoDb;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations; using Squidex.Infrastructure.Migrations;
using Squidex.Migrations.Backup;
namespace Migrations; namespace Migrations;
@ -35,25 +35,25 @@ public sealed class MigrationPath(IServiceProvider serviceProvider) : IMigration
// Version 06: Convert Event store. Must always be executed first. // Version 06: Convert Event store. Must always be executed first.
if (version < 6) if (version < 6)
{ {
yield return serviceProvider.GetRequiredService<ConvertEventStore>(); yield return serviceProvider.GetService<ConvertEventStore>();
} }
// Version 22: Integrate Domain Id. // Version 22: Integrate Domain Id.
if (version < 22) if (version < 22)
{ {
yield return serviceProvider.GetRequiredService<AddAppIdToEventStream>(); yield return serviceProvider.GetService<AddAppIdToEventStream>();
} }
// Version 07: Introduces AppId for backups. // Version 07: Introduces AppId for backups.
else if (version < 7) else if (version < 7)
{ {
yield return serviceProvider.GetRequiredService<ConvertEventStoreAppId>(); yield return serviceProvider.GetService<ConvertEventStoreAppId>();
} }
// Version 05: Fixes the broken command architecture and requires a rebuild of all snapshots. // Version 05: Fixes the broken command architecture and requires a rebuild of all snapshots.
if (version < 5) if (version < 5)
{ {
yield return serviceProvider.GetRequiredService<RebuildSnapshots>(); yield return serviceProvider.GetService<RebuildSnapshots>();
} }
else else
{ {
@ -68,9 +68,9 @@ public sealed class MigrationPath(IServiceProvider serviceProvider) : IMigration
// Version 25: Introduce full deletion. // Version 25: Introduce full deletion.
if (version < 25) if (version < 25)
{ {
yield return serviceProvider.GetRequiredService<RebuildApps>(); yield return serviceProvider.GetService<RebuildApps>();
yield return serviceProvider.GetRequiredService<RebuildSchemas>(); yield return serviceProvider.GetService<RebuildSchemas>();
yield return serviceProvider.GetRequiredService<RebuildRules>(); yield return serviceProvider.GetService<RebuildRules>();
} }
// Version 18: Rebuild assets. // Version 18: Rebuild assets.
@ -91,7 +91,7 @@ public sealed class MigrationPath(IServiceProvider serviceProvider) : IMigration
// Version 23: Fix parent id. // Version 23: Fix parent id.
if (version < 23) if (version < 23)
{ {
yield return serviceProvider.GetRequiredService<ConvertDocumentIds>().ForAssets(); yield return serviceProvider.GetService<ConvertDocumentIds>()?.ForAssets();
} }
} }
@ -99,32 +99,32 @@ public sealed class MigrationPath(IServiceProvider serviceProvider) : IMigration
// Version 25: Convert content ids to names. // Version 25: Convert content ids to names.
if (version < 25) if (version < 25)
{ {
yield return serviceProvider.GetRequiredService<RebuildContents>(); yield return serviceProvider.GetService<RebuildContents>();
} }
// Version 16: Introduce file name slugs for assets. // Version 16: Introduce file name slugs for assets.
if (version < 16) if (version < 16)
{ {
yield return serviceProvider.GetRequiredService<CreateAssetSlugs>(); yield return serviceProvider.GetService<CreateAssetSlugs>();
} }
} }
// Version 13: Json refactoring. // Version 13: Json refactoring.
if (version < 13) if (version < 13)
{ {
yield return serviceProvider.GetRequiredService<ConvertRuleEventsJson>(); yield return serviceProvider.GetService<ConvertRuleEventsJson>();
} }
// Version 26: New rule statistics using normal usage collection. // Version 26: New rule statistics using normal usage collection.
if (version < 26) if (version < 26)
{ {
yield return serviceProvider.GetRequiredService<CopyRuleStatistics>(); yield return serviceProvider.GetService<CopyRuleStatistics>();
} }
// Version 27: General jobs state. // Version 27: General jobs state.
if (version < 27) if (version < 27)
{ {
yield return serviceProvider.GetRequiredService<ConvertBackup>(); yield return serviceProvider.GetService<ConvertBackup>();
} }
} }
} }

2
backend/src/Migrations/Migrations/MongoDb/AddAppIdToEventStream.cs

@ -83,7 +83,7 @@ public sealed class AddAppIdToEventStream(IMongoDatabase database) : MongoBase<B
writes.Add(new ReplaceOneModel<BsonDocument>(filter, document) writes.Add(new ReplaceOneModel<BsonDocument>(filter, document)
{ {
IsUpsert = true IsUpsert = true,
}); });
} }

4
backend/src/Migrations/Migrations/MongoDb/ConvertDocumentIds.cs

@ -21,7 +21,7 @@ public sealed class ConvertDocumentIds(IMongoDatabase databaseDefault, IMongoDat
{ {
None, None,
Assets, Assets,
Contents Contents,
} }
public override string ToString() public override string ToString()
@ -109,7 +109,7 @@ public sealed class ConvertDocumentIds(IMongoDatabase databaseDefault, IMongoDat
writes.Add(new ReplaceOneModel<BsonDocument>(filter, document) writes.Add(new ReplaceOneModel<BsonDocument>(filter, document)
{ {
IsUpsert = true IsUpsert = true,
}); });
} }

2
backend/src/Migrations/Migrations/MongoDb/ConvertOldSnapshotStores.cs

@ -22,7 +22,7 @@ public sealed class ConvertOldSnapshotStores(IMongoDatabase database) : MongoBas
{ {
"States_Apps", "States_Apps",
"States_Rules", "States_Rules",
"States_Schemas" "States_Schemas",
}.Select(x => database.GetCollection<BsonDocument>(x)); }.Select(x => database.GetCollection<BsonDocument>(x));
var update = Update.Rename("State", "Doc"); var update = Update.Rename("State", "Doc");

2
backend/src/Migrations/OldEvents/AppClientPermission.cs

@ -12,5 +12,5 @@ public enum AppClientPermission
{ {
Developer, Developer,
Editor, Editor,
Reader Reader,
} }

2
backend/src/Migrations/OldEvents/AppContributorPermission.cs

@ -12,5 +12,5 @@ public enum AppContributorPermission
{ {
Owner, Owner,
Developer, Developer,
Editor Editor,
} }

8
backend/src/Migrations/OldEvents/AppPatternAdded.cs

@ -36,14 +36,14 @@ public sealed class AppPatternAdded : AppEvent, IMigratedStateEvent<App>
{ {
new Pattern(Name, Pattern) new Pattern(Name, Pattern)
{ {
Message = Message Message = Message,
} },
}.ToReadonlyList() }.ToReadonlyList(),
}; };
var newEvent = new AppSettingsUpdated var newEvent = new AppSettingsUpdated
{ {
Settings = newSettings Settings = newSettings,
}; };
return SimpleMapper.Map(this, newEvent); return SimpleMapper.Map(this, newEvent);

2
backend/src/Migrations/OldEvents/AppPatternDeleted.cs

@ -25,7 +25,7 @@ public sealed class AppPatternDeleted : AppEvent, IMigratedStateEvent<App>
{ {
var newEvent = new AppSettingsUpdated var newEvent = new AppSettingsUpdated
{ {
Settings = state.Settings Settings = state.Settings,
}; };
return SimpleMapper.Map(this, newEvent); return SimpleMapper.Map(this, newEvent);

8
backend/src/Migrations/OldEvents/AppPatternUpdated.cs

@ -36,15 +36,15 @@ public sealed class AppPatternUpdated : AppEvent, IMigratedStateEvent<App>
{ {
new Pattern(Name, Pattern) new Pattern(Name, Pattern)
{ {
Message = Message Message = Message,
} },
}.ToReadonlyList(), }.ToReadonlyList(),
Editors = state.Settings.Editors Editors = state.Settings.Editors,
}; };
var newEvent = new AppSettingsUpdated var newEvent = new AppSettingsUpdated
{ {
Settings = newSettings Settings = newSettings,
}; };
return SimpleMapper.Map(this, newEvent); return SimpleMapper.Map(this, newEvent);

2
backend/src/Migrations/OldEvents/ContentChangesPublished.cs

@ -23,7 +23,7 @@ public sealed class ContentChangesPublished : ContentEvent, IMigrated<IEvent>
return SimpleMapper.Map(this, new ContentStatusChangedV2 return SimpleMapper.Map(this, new ContentStatusChangedV2
{ {
Status = Status.Published, Status = Status.Published,
Change = StatusChange.Published Change = StatusChange.Published,
}); });
} }
} }

6
backend/src/Migrations/OldEvents/SchemaCreated.cs

@ -47,7 +47,7 @@ public sealed class SchemaCreated : SchemaEvent, IMigrated<IEvent>
{ {
IsLocked = eventField.IsLocked, IsLocked = eventField.IsLocked,
IsHidden = eventField.IsHidden, IsHidden = eventField.IsHidden,
IsDisabled = eventField.IsDisabled IsDisabled = eventField.IsDisabled,
}; };
if (field is ArrayField arrayField && eventField.Nested?.Length > 0) if (field is ArrayField arrayField && eventField.Nested?.Length > 0)
@ -62,7 +62,7 @@ public sealed class SchemaCreated : SchemaEvent, IMigrated<IEvent>
{ {
IsLocked = nestedEventField.IsLocked, IsLocked = nestedEventField.IsLocked,
IsHidden = nestedEventField.IsHidden, IsHidden = nestedEventField.IsHidden,
IsDisabled = nestedEventField.IsDisabled IsDisabled = nestedEventField.IsDisabled,
}; };
arrayFields.Add(nestedField); arrayFields.Add(nestedField);
@ -82,7 +82,7 @@ public sealed class SchemaCreated : SchemaEvent, IMigrated<IEvent>
SchemaType.Singleton : SchemaType.Singleton :
SchemaType.Default, SchemaType.Default,
IsPublished = Publish, IsPublished = Publish,
FieldCollection = FieldCollection<RootField>.Create(fields.ToArray()) FieldCollection = FieldCollection<RootField>.Create(fields.ToArray()),
}; };
return SimpleMapper.Map(this, new SchemaCreatedV2 { Schema = schema }); return SimpleMapper.Map(this, new SchemaCreatedV2 { Schema = schema });

114
backend/src/Squidex.Data.EntityFramework/AppDbContext.cs

@ -0,0 +1,114 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Squidex.Assets.TusAdapter;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Billing;
using Squidex.Domain.Apps.Entities.Contents.Counter;
using Squidex.Domain.Apps.Entities.Jobs;
using Squidex.Domain.Apps.Entities.Rules.UsageTracking;
using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Domain.Users;
using Squidex.Infrastructure.EventSourcing.Consume;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.States;
using YDotNet.Server.EntityFramework;
namespace Squidex;
public class AppDbContext(DbContextOptions options, IJsonSerializer jsonSerializer) : IdentityDbContext(options)
{
protected override void OnModelCreating(ModelBuilder builder)
{
var jsonColumnType = JsonColumnType();
builder.UseApps(jsonSerializer, jsonColumnType);
builder.UseAssetKeyValueStore<TusMetadata>();
builder.UseAssets(jsonSerializer, jsonColumnType);
builder.UseCache();
builder.UseCounters(jsonSerializer, jsonColumnType);
builder.UseChatStore();
builder.UseContent(jsonSerializer, jsonColumnType);
builder.UseEvents(jsonSerializer, jsonColumnType);
builder.UseEventStore();
builder.UseHistory(jsonSerializer, jsonColumnType);
builder.UseIdentity(jsonSerializer, jsonColumnType);
builder.UseJobs(jsonSerializer, jsonColumnType);
builder.UseMessagingDataStore();
builder.UseMessagingTransport();
builder.UseMigration();
builder.UseNames(jsonSerializer, jsonColumnType);
builder.UseOpenIddict();
builder.UseRequest(jsonSerializer, jsonColumnType);
builder.UseRules(jsonSerializer, jsonColumnType);
builder.UseSchema(jsonSerializer, jsonColumnType);
builder.UseSettings(jsonSerializer, jsonColumnType);
builder.UseTags(jsonSerializer, jsonColumnType);
builder.UseTeams(jsonSerializer, jsonColumnType);
builder.UseUsage();
builder.UseUsageTracking(jsonSerializer, jsonColumnType);
builder.UseYDotNet();
base.OnModelCreating(builder);
}
protected virtual string? JsonColumnType()
{
return null;
}
}
#pragma warning disable MA0048 // File name must match type name
internal static class Extensions
#pragma warning restore MA0048 // File name must match type name
{
public static void UseIdentity(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<DefaultKeyStore.State>(jsonSerializer, jsonColumn);
builder.UseSnapshot<DefaultXmlRepository.State>(jsonSerializer, jsonColumn);
}
public static void UseUsageTracking(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<AssetUsageTracker.State>(jsonSerializer, jsonColumn);
builder.UseSnapshot<UsageNotifierWorker.State>(jsonSerializer, jsonColumn);
builder.UseSnapshot<UsageTrackerWorker.State>(jsonSerializer, jsonColumn);
}
public static void UseCounters(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<CounterService.State>(jsonSerializer, jsonColumn);
}
public static void UseEvents(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<EventConsumerState>(jsonSerializer, jsonColumn);
}
public static void UseNames(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<NameReservationState.State>(jsonSerializer, jsonColumn);
}
public static void UseJobs(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<JobsState>(jsonSerializer, jsonColumn);
}
public static void UseSettings(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<AppUISettings.State>(jsonSerializer, jsonColumn);
}
public static void UseTags(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<TagService.State>(jsonSerializer, jsonColumn);
}
}

25
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Apps/EFAppBuilder.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.States;
namespace Microsoft.EntityFrameworkCore;
public static class EFAppBuilder
{
public static void UseApps(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<App, EFAppEntity>(jsonSerializer, jsonColumn, b =>
{
b.Property(x => x.IndexedTeamId).AsString();
});
}
}

48
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Apps/EFAppEntity.cs

@ -0,0 +1,48 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations.Schema;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Apps;
public sealed class EFAppEntity : EFState<App>
{
[Column("Name")]
public string IndexedName { get; set; }
[Column("UserIds")]
public string IndexedUserIds { get; set; }
[Column("TeamId")]
public DomainId? IndexedTeamId { get; set; }
[Column("Deleted")]
public bool IndexedDeleted { get; set; }
[Column("Created")]
public DateTimeOffset IndexedCreated { get; set; }
public override void Prepare()
{
var users = new HashSet<string>
{
Document.CreatedBy.Identifier,
};
users.AddRange(Document.Contributors.Keys);
users.AddRange(Document.Clients.Keys);
IndexedCreated = Document.Created.ToDateTimeOffset();
IndexedDeleted = Document.IsDeleted;
IndexedName = Document.Name;
IndexedTeamId = Document.TeamId;
IndexedUserIds = TagsConverter.ToString(users);
}
}

115
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Apps/EFAppRepository.cs

@ -0,0 +1,115 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Apps;
public sealed class EFAppRepository<TContext>(IDbContextFactory<TContext> dbContextFactory)
: EFSnapshotStore<TContext, App, EFAppEntity>(dbContextFactory), IAppRepository where TContext : DbContext
{
public async Task<List<App>> QueryAllAsync(string contributorId, IEnumerable<string> names,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAppRepository/QueryAllAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var formattedId = TagsConverter.FormatFilter(contributorId);
var entities =
await dbContext.Set<EFAppEntity>()
.Where(x => x.IndexedUserIds.Contains(formattedId) || names.Contains(x.IndexedName))
.Where(x => !x.IndexedDeleted)
.ToListAsync(ct);
return RemoveDuplicateNames(entities);
}
}
public async Task<List<App>> QueryAllAsync(DomainId teamId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAppRepository/QueryAllAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
await dbContext.Set<EFAppEntity>()
.Where(x => x.IndexedTeamId == teamId)
.Where(x => !x.IndexedDeleted)
.ToListAsync(ct);
return RemoveDuplicateNames(entities);
}
}
public async Task<App?> FindAsync(DomainId id,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAppRepository/FindAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<EFAppEntity>()
.Where(x => x.DocumentId == id)
.Where(x => !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
public async Task<App?> FindAsync(string name,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAppRepository/FindAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<EFAppEntity>()
.Where(x => x.IndexedName == name)
.Where(x => !x.IndexedDeleted)
.OrderByDescending(x => x.IndexedCreated)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
private static List<App> RemoveDuplicateNames(List<EFAppEntity> entities)
{
var byName = new Dictionary<string, App>();
// Remove duplicate names, the latest wins.
foreach (var entity in entities.OrderBy(x => x.IndexedCreated))
{
byName[entity.IndexedName] = entity.Document;
}
return byName.Values.ToList();
}
protected override Expression<Func<SetPropertyCalls<EFAppEntity>, SetPropertyCalls<EFAppEntity>>> BuildUpdate(EFAppEntity entity)
{
return u => u
.SetProperty(x => x.Document, entity.Document)
.SetProperty(x => x.IndexedCreated, entity.IndexedCreated)
.SetProperty(x => x.IndexedDeleted, entity.IndexedDeleted)
.SetProperty(x => x.IndexedName, entity.IndexedName)
.SetProperty(x => x.IndexedTeamId, entity.IndexedTeamId)
.SetProperty(x => x.IndexedUserIds, entity.IndexedUserIds)
.SetProperty(x => x.Version, entity.Version);
}
}

64
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/AssetSqlQueryBuilder.cs

@ -0,0 +1,64 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
using Squidex.Text;
namespace Squidex.Domain.Apps.Entities.Assets;
internal sealed class AssetSqlQueryBuilder(SqlDialect dialect) : SqlQueryBuilder(dialect, "Assets")
{
public override string Visit(CompareFilter<ClrValue> nodeIn, None args)
{
if (!IsTagsField(nodeIn.Path))
{
return base.Visit(nodeIn, args);
}
switch (nodeIn.Operator)
{
case CompareOperator.Equals when nodeIn.Value.Value is string value:
return Visit(ClrFilter.Contains(nodeIn.Path, TagsConverter.FormatFilter(value)), args);
case CompareOperator.NotEquals when nodeIn.Value.Value is string value:
return Visit(
ClrFilter.Not(
ClrFilter.Contains(nodeIn.Path, TagsConverter.FormatFilter(value))
),
args);
case CompareOperator.In when nodeIn.Value.Value is List<string> values:
return Visit(
ClrFilter.Or(
values.Select(v =>
ClrFilter.Contains(nodeIn.Path, TagsConverter.FormatFilter(v))
).ToArray()
),
args);
}
return base.Visit(nodeIn, args);
}
public override PropertyPath Visit(PropertyPath path)
{
var elements = path.ToList();
elements[0] = elements[0].ToPascalCase();
return new PropertyPath(elements);
}
public override bool IsJsonPath(PropertyPath path)
{
return path.Count > 1 && string.Equals(path[0], "metadata", StringComparison.OrdinalIgnoreCase);
}
private static bool IsTagsField(PropertyPath path)
{
return path.Count == 1 && string.Equals(path[0], "tags", StringComparison.OrdinalIgnoreCase);
}
}

47
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetBuilder.cs

@ -0,0 +1,47 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
namespace Microsoft.EntityFrameworkCore;
public static class EFAssetBuilder
{
public static void UseAssets(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.Entity<EFAssetEntity>(b =>
{
b.Property(x => x.Id).AsString();
b.Property(x => x.AppId).AsString();
b.Property(x => x.Created).AsDateTimeOffset();
b.Property(x => x.CreatedBy).AsString();
b.Property(x => x.DocumentId).AsString();
b.Property(x => x.IndexedAppId).AsString();
b.Property(x => x.LastModified).AsDateTimeOffset();
b.Property(x => x.LastModifiedBy).AsString();
b.Property(x => x.Metadata).AsJsonString(jsonSerializer, jsonColumn);
b.Property(x => x.ParentId).AsString();
b.Property(x => x.Tags).AsString();
b.Property(x => x.Type).AsString();
});
builder.Entity<EFAssetFolderEntity>(b =>
{
b.Property(x => x.Id).AsString();
b.Property(x => x.AppId).AsString();
b.Property(x => x.Created).AsDateTimeOffset();
b.Property(x => x.CreatedBy).AsString();
b.Property(x => x.DocumentId).AsString();
b.Property(x => x.IndexedAppId).AsString();
b.Property(x => x.LastModified).AsDateTimeOffset();
b.Property(x => x.LastModifiedBy).AsString();
b.Property(x => x.ParentId).AsString();
});
}
}

40
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetEntity.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Assets;
[Table("Assets")]
[Index(nameof(IndexedAppId), nameof(Id))]
public record EFAssetEntity : Asset, IVersionedEntity<DomainId>
{
[Key]
public DomainId DocumentId { get; set; }
public DomainId IndexedAppId { get; set; }
public static EFAssetEntity Create(SnapshotWriteJob<Asset> job)
{
var entity = new EFAssetEntity
{
DocumentId = job.Key,
// Both version and ID cannot be changed by the mapper method anymore.
Version = job.NewVersion,
// Use an app ID without the name to reduce the memory usage of the index.
IndexedAppId = job.Value.AppId.Id,
};
return SimpleMapper.Map(job.Value, entity);
}
}

40
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetFolderEntity.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Assets;
[Table("AssetFolders")]
[Index(nameof(IndexedAppId), nameof(Id))]
public sealed record EFAssetFolderEntity : AssetFolder, IVersionedEntity<DomainId>
{
[Key]
public DomainId DocumentId { get; set; }
public DomainId IndexedAppId { get; set; }
public static EFAssetFolderEntity Create(SnapshotWriteJob<AssetFolder> job)
{
var entity = new EFAssetFolderEntity
{
DocumentId = job.Key,
// Both version and ID cannot be changed by the mapper method anymore.
Version = job.NewVersion,
// Use an app ID without the name to reduce the memory usage of the index.
IndexedAppId = job.Value.AppId.Id,
};
return SimpleMapper.Map(job.Value, entity);
}
}

70
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetFolderRepository.cs

@ -0,0 +1,70 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Assets;
public sealed partial class EFAssetFolderRepository<TContext>(IDbContextFactory<TContext> dbContextFactory)
: IAssetFolderRepository where TContext : DbContext
{
public async Task<IResultList<AssetFolder>> QueryAsync(DomainId appId, DomainId? parentId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetFolderRepository/QueryAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var assetFolderEntities =
await dbContext.Set<EFAssetFolderEntity>()
.Where(x => x.IndexedAppId == appId)
.WhereIf(x => x.ParentId == parentId!.Value, parentId.HasValue)
.ToListAsync(ct);
return ResultList.Create(assetFolderEntities.Count, assetFolderEntities);
}
}
public async Task<IReadOnlyList<DomainId>> QueryChildIdsAsync(DomainId appId, DomainId? parentId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetFolderRepository/QueryChildIdsAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var assetFolderIds =
await dbContext.Set<EFAssetFolderEntity>()
.Where(x => x.IndexedAppId == appId)
.WhereIf(x => x.ParentId == parentId!.Value, parentId.HasValue)
.Select(x => x.Id)
.ToListAsync(ct);
return assetFolderIds;
}
}
public async Task<AssetFolder?> FindAssetFolderAsync(DomainId appId, DomainId id,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetFolderRepository/FindAssetFolderAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var documentId = DomainId.Combine(appId, id);
var assetFolderEntity =
await dbContext.Set<EFAssetFolderEntity>()
.Where(x => x.DocumentId == documentId)
.Where(x => !x.IsDeleted)
.FirstOrDefaultAsync(ct);
return assetFolderEntity;
}
}
}

130
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetFolderRepository_SnapshotStore.cs

@ -0,0 +1,130 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Assets;
public sealed partial class EFAssetFolderRepository<TContext> : ISnapshotStore<AssetFolder>, IDeleter
{
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFAssetFolderEntity>().Where(x => x.IndexedAppId == app.Id)
.ExecuteDeleteAsync(ct);
}
async Task ISnapshotStore<AssetFolder>.ClearAsync(
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFAssetFolderEntity>()
.ExecuteDeleteAsync(ct);
}
async Task ISnapshotStore<AssetFolder>.RemoveAsync(DomainId key,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetFolderRepository/RemoveAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFAssetFolderEntity>().Where(x => x.DocumentId == key)
.ExecuteDeleteAsync(ct);
}
}
async IAsyncEnumerable<SnapshotResult<AssetFolder>> ISnapshotStore<AssetFolder>.ReadAllAsync(
[EnumeratorCancellation] CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities = dbContext.Set<EFAssetFolderEntity>().ToAsyncEnumerable();
await foreach (var entity in entities.WithCancellation(ct))
{
yield return new SnapshotResult<AssetFolder>(entity.DocumentId, entity, entity.Version);
}
}
async Task<SnapshotResult<AssetFolder>> ISnapshotStore<AssetFolder>.ReadAsync(DomainId key,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetFolderRepository/ReadAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity = await dbContext.Set<EFAssetFolderEntity>().Where(x => x.DocumentId == key).FirstOrDefaultAsync(ct);
if (entity == null)
{
return new SnapshotResult<AssetFolder>(default, default!, EtagVersion.Empty);
}
return new SnapshotResult<AssetFolder>(entity.DocumentId, entity, entity.Version);
}
}
async Task ISnapshotStore<AssetFolder>.WriteAsync(SnapshotWriteJob<AssetFolder> job,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetFolderRepository/WriteAsync"))
{
var entity = EFAssetFolderEntity.Create(job);
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.UpsertAsync(entity, job.OldVersion, BuildUpdate, ct);
}
}
async Task ISnapshotStore<AssetFolder>.WriteManyAsync(IEnumerable<SnapshotWriteJob<AssetFolder>> jobs,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetFolderRepository/WriteManyAsync"))
{
var entities = jobs.Select(EFAssetFolderEntity.Create).ToList();
if (entities.Count == 0)
{
return;
}
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.BulkInsertAsync(entities, cancellationToken: ct);
}
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
private static Expression<Func<SetPropertyCalls<EFAssetFolderEntity>, SetPropertyCalls<EFAssetFolderEntity>>> BuildUpdate(EFAssetFolderEntity entity)
{
return b => b
.SetProperty(x => x.AppId, entity.AppId)
.SetProperty(x => x.Created, entity.Created)
.SetProperty(x => x.CreatedBy, entity.CreatedBy)
.SetProperty(x => x.FolderName, entity.FolderName)
.SetProperty(x => x.IsDeleted, entity.IsDeleted)
.SetProperty(x => x.LastModified, entity.LastModified)
.SetProperty(x => x.LastModifiedBy, entity.LastModifiedBy)
.SetProperty(x => x.ParentId, entity.ParentId)
.SetProperty(x => x.Version, entity.Version);
}
}

189
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetRepository.cs

@ -0,0 +1,189 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.CompilerServices;
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities.Assets;
public sealed partial class EFAssetRepository<TContext>(IDbContextFactory<TContext> dbContextFactory, SqlDialect dialect)
: IAssetRepository where TContext : DbContext
{
public async IAsyncEnumerable<Asset> StreamAll(DomainId appId,
[EnumeratorCancellation] CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
dbContext.Set<EFAssetEntity>()
.Where(x => x.IndexedAppId == appId)
.Where(x => !x.IsDeleted)
.ToAsyncEnumerable();
await foreach (var entity in entities.WithCancellation(ct))
{
yield return entity;
}
}
public async Task<IResultList<Asset>> QueryAsync(DomainId appId, DomainId? parentId, Q q,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/QueryAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
if (q.Ids is { Count: > 0 })
{
var result =
await dbContext.Set<EFAssetEntity>()
.Where(x => x.IndexedAppId == appId)
.Where(x => q.Ids.Contains(x.Id))
.Where(x => !x.IsDeleted)
.QueryAsync(q, ct);
return result;
}
var sqlQuery =
new AssetSqlQueryBuilder(dialect)
.Where(ClrFilter.Eq(nameof(EFAssetEntity.IndexedAppId), appId));
if (q.Query.Filter?.HasField("IsDeleted") != true)
{
sqlQuery.Where(ClrFilter.Eq(nameof(EFAssetEntity.IsDeleted), false));
}
if (parentId != null)
{
sqlQuery.Where(ClrFilter.Eq(nameof(EFAssetEntity.ParentId), parentId));
}
sqlQuery.Where(q.Query);
return await dbContext.QueryAsync<EFAssetEntity>(sqlQuery, q, ct);
}
}
public async Task<IReadOnlyList<DomainId>> QueryIdsAsync(DomainId appId, HashSet<DomainId> ids,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/QueryIdsAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var assetIds =
await dbContext.Set<EFAssetEntity>()
.Where(x => x.IndexedAppId == appId)
.Where(x => ids.Contains(x.Id))
.Where(x => !x.IsDeleted)
.Select(x => x.Id)
.ToListAsync(ct);
return assetIds;
}
}
public async Task<IReadOnlyList<DomainId>> QueryChildIdsAsync(DomainId appId, DomainId parentId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/QueryChildIdsAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var assetIds =
await dbContext.Set<EFAssetEntity>()
.Where(x => x.IndexedAppId == appId)
.Where(x => x.ParentId == parentId)
.Where(x => !x.IsDeleted)
.Select(x => x.Id)
.ToListAsync(ct);
return assetIds;
}
}
public async Task<Asset?> FindAssetByHashAsync(DomainId appId, string hash, string fileName, long fileSize,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/FindAssetByHashAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var assetEntity =
await dbContext.Set<EFAssetEntity>()
.Where(x => x.IndexedAppId == appId)
.Where(x => x.FileHash == hash && x.FileName == fileName && x.FileSize == fileSize)
.Where(x => !x.IsDeleted)
.FirstOrDefaultAsync(ct);
return assetEntity;
}
}
public async Task<Asset?> FindAssetBySlugAsync(DomainId appId, string slug, bool allowDeleted,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/FindAssetBySlugAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var assetEntity =
await dbContext.Set<EFAssetEntity>()
.Where(x => x.IndexedAppId == appId && x.Slug == slug)
.WhereIf(x => !x.IsDeleted, !allowDeleted)
.FirstOrDefaultAsync(ct);
return assetEntity;
}
}
public async Task<Asset?> FindAssetAsync(DomainId appId, DomainId id, bool allowDeleted,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/FindAssetAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var docId = DomainId.Combine(appId, id);
var assetEntity =
await dbContext.Set<EFAssetEntity>()
.Where(x => x.DocumentId == docId)
.WhereIf(x => !x.IsDeleted, !allowDeleted)
.FirstOrDefaultAsync(ct);
return assetEntity;
}
}
public async Task<Asset?> FindAssetAsync(DomainId id,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/FindAssetAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var assetEntity =
await dbContext.Set<EFAssetEntity>()
.Where(x => x.Id == id)
.Where(x => !x.IsDeleted)
.FirstOrDefaultAsync(ct);
return assetEntity;
}
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

135
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Assets/EFAssetRepository_SnapshotStore.cs

@ -0,0 +1,135 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Assets;
public sealed partial class EFAssetRepository<TContext> : ISnapshotStore<Asset>, IDeleter
{
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFAssetEntity>().Where(x => x.IndexedAppId == app.Id)
.ExecuteDeleteAsync(ct);
}
async Task ISnapshotStore<Asset>.ClearAsync(
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFAssetEntity>()
.ExecuteDeleteAsync(ct);
}
async Task ISnapshotStore<Asset>.RemoveAsync(DomainId key,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/RemoveAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFAssetEntity>().Where(x => x.DocumentId == key)
.ExecuteDeleteAsync(ct);
}
}
async IAsyncEnumerable<SnapshotResult<Asset>> ISnapshotStore<Asset>.ReadAllAsync(
[EnumeratorCancellation] CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities = dbContext.Set<EFAssetEntity>().ToAsyncEnumerable();
await foreach (var entity in entities.WithCancellation(ct))
{
yield return new SnapshotResult<Asset>(entity.DocumentId, entity, entity.Version);
}
}
async Task<SnapshotResult<Asset>> ISnapshotStore<Asset>.ReadAsync(DomainId key,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/ReadAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity = await dbContext.Set<EFAssetEntity>().Where(x => x.DocumentId == key).FirstOrDefaultAsync(ct);
if (entity == null)
{
return new SnapshotResult<Asset>(default, default!, EtagVersion.Empty);
}
return new SnapshotResult<Asset>(entity.DocumentId, entity, entity.Version);
}
}
async Task ISnapshotStore<Asset>.WriteAsync(SnapshotWriteJob<Asset> job,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/WriteAsync"))
{
var entity = EFAssetEntity.Create(job);
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.UpsertAsync(entity, job.OldVersion, BuildUpdate, ct);
}
}
async Task ISnapshotStore<Asset>.WriteManyAsync(IEnumerable<SnapshotWriteJob<Asset>> jobs,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFAssetRepository/WriteManyAsync"))
{
var entities = jobs.Select(EFAssetEntity.Create).ToList();
if (entities.Count == 0)
{
return;
}
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.BulkInsertAsync(entities, cancellationToken: ct);
}
}
private static Expression<Func<SetPropertyCalls<EFAssetEntity>, SetPropertyCalls<EFAssetEntity>>> BuildUpdate(EFAssetEntity entity)
{
return b => b
.SetProperty(x => x.AppId, entity.AppId)
.SetProperty(x => x.Created, entity.Created)
.SetProperty(x => x.CreatedBy, entity.CreatedBy)
.SetProperty(x => x.FileHash, entity.FileHash)
.SetProperty(x => x.FileName, entity.FileName)
.SetProperty(x => x.FileSize, entity.FileSize)
.SetProperty(x => x.FileVersion, entity.FileVersion)
.SetProperty(x => x.IsDeleted, entity.IsDeleted)
.SetProperty(x => x.IsProtected, entity.IsProtected)
.SetProperty(x => x.LastModified, entity.LastModified)
.SetProperty(x => x.LastModifiedBy, entity.LastModifiedBy)
.SetProperty(x => x.Metadata, entity.Metadata)
.SetProperty(x => x.MimeType, entity.MimeType)
.SetProperty(x => x.ParentId, entity.ParentId)
.SetProperty(x => x.Slug, entity.Slug)
.SetProperty(x => x.Tags, entity.Tags)
.SetProperty(x => x.TotalSize, entity.TotalSize)
.SetProperty(x => x.Type, entity.Type)
.SetProperty(x => x.Version, entity.Version);
}
}

28
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/ContentQueryBuilder.cs

@ -0,0 +1,28 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Queries;
using Squidex.Text;
namespace Squidex.Domain.Apps.Entities.Contents;
public class ContentQueryBuilder(SqlDialect dialect, string table, SqlParams? parameters = null) : SqlQueryBuilder(dialect, table, parameters)
{
public override PropertyPath Visit(PropertyPath path)
{
var elements = path.ToList();
elements[0] = elements[0].ToPascalCase();
return new PropertyPath(elements);
}
public override bool IsJsonPath(PropertyPath path)
{
return path.Count > 1 && string.Equals(path[0], "data", StringComparison.OrdinalIgnoreCase);
}
}

73
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentBuilder.cs

@ -0,0 +1,73 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
namespace Microsoft.EntityFrameworkCore;
public static class EFContentBuilder
{
public static void UseContent(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.Entity<TextContentState>(b =>
{
b.ToTable("TextState");
b.HasKey(x => x.UniqueContentId);
b.Property(x => x.UniqueContentId).AsString();
b.Property(x => x.State).AsString();
});
builder.UseContentEntity<EFContentCompleteEntity>("ContentsAll", jsonSerializer, jsonColumn);
builder.UseContentReference<EFReferenceCompleteEntity>("ContentReferencesAll");
builder.UseContentEntity<EFContentPublishedEntity>("ContentsPublished", jsonSerializer, jsonColumn);
builder.UseContentReference<EFReferencePublishedEntity>("ContentReferencesPublished");
}
private static void UseContentEntity<T>(this ModelBuilder builder, string tableName, IJsonSerializer jsonSerializer, string? jsonColumn)
where T : EFContentEntity
{
builder.Entity<T>(b =>
{
b.ToTable(tableName);
b.Property(x => x.Id).AsString();
b.Property(x => x.AppId).AsString();
b.Property(x => x.Created).AsDateTimeOffset();
b.Property(x => x.CreatedBy).AsString();
b.Property(x => x.Data).AsJsonString(jsonSerializer, jsonColumn);
b.Property(x => x.DocumentId).AsString();
b.Property(x => x.IndexedAppId).AsString();
b.Property(x => x.IndexedSchemaId).AsString();
b.Property(x => x.LastModified).AsDateTimeOffset();
b.Property(x => x.LastModifiedBy).AsString();
b.Property(x => x.NewData).AsNullableJsonString(jsonSerializer, jsonColumn);
b.Property(x => x.NewStatus).AsNullableString();
b.Property(x => x.SchemaId).AsString();
b.Property(x => x.ScheduledAt).AsDateTimeOffset();
b.Property(x => x.ScheduleJob).AsNullableJsonString(jsonSerializer, jsonColumn);
b.Property(x => x.Status).AsString();
b.Property(x => x.TranslationStatus).AsNullableJsonString(jsonSerializer, jsonColumn);
});
}
private static void UseContentReference<T>(this ModelBuilder builder, string tableName)
where T : EFReferenceEntity
{
builder.Entity<T>(b =>
{
b.ToTable(tableName);
b.HasKey("AppId", "FromKey", "ToId");
b.Property(x => x.AppId).AsString();
b.Property(x => x.FromKey).AsString();
b.Property(x => x.ToId).AsString();
});
}
}

186
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentEntity.cs

@ -0,0 +1,186 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Contents;
public record EFContentCompleteEntity : EFContentEntity
{
public static async Task<(EFContentCompleteEntity, EFReferenceCompleteEntity[])> CreateAsync(
SnapshotWriteJob<WriteContent> job,
IAppProvider appProvider,
CancellationToken ct)
{
var source = job.Value;
var appId = source.AppId.Id;
var (referencedIds, translationStatus) = await CreateExtendedValuesAsync(source, source.CurrentVersion.Data, appProvider, ct);
var references =
referencedIds
.Select(x => new EFReferenceCompleteEntity { AppId = appId, FromKey = job.Key, ToId = x })
.ToArray();
var entity = new EFContentCompleteEntity
{
Id = source.Id,
AppId = source.AppId,
Created = source.Created,
CreatedBy = source.CreatedBy,
Data = source.EditingData,
DocumentId = job.Key,
IndexedAppId = source.AppId.Id,
IndexedSchemaId = source.SchemaId.Id,
IsDeleted = source.IsDeleted,
LastModified = source.LastModified,
LastModifiedBy = source.LastModifiedBy,
NewData = source.NewVersion != null ? source.CurrentVersion.Data : null,
NewStatus = source.NewVersion?.Status,
ScheduledAt = source.ScheduleJob?.DueTime,
ScheduleJob = source.ScheduleJob,
SchemaId = source.SchemaId,
Status = source.CurrentVersion.Status,
TranslationStatus = translationStatus,
Version = source.Version,
};
return (entity, references);
}
}
public record EFContentPublishedEntity : EFContentEntity
{
public static async Task<(EFContentPublishedEntity, EFReferencePublishedEntity[])> CreateAsync(
SnapshotWriteJob<WriteContent> job,
IAppProvider appProvider,
CancellationToken ct)
{
var source = job.Value;
var appId = source.AppId.Id;
var (referencedIds, translationStatus) = await CreateExtendedValuesAsync(source, source.CurrentVersion.Data, appProvider, ct);
var references =
referencedIds
.Select(x => new EFReferencePublishedEntity { AppId = appId, FromKey = job.Key, ToId = x })
.ToArray();
var entity = new EFContentPublishedEntity
{
Id = source.Id,
AppId = source.AppId,
Created = source.Created,
CreatedBy = source.CreatedBy,
Data = source.CurrentVersion.Data,
DocumentId = job.Key,
IndexedAppId = appId,
IndexedSchemaId = source.SchemaId.Id,
IsDeleted = source.IsDeleted,
LastModified = source.LastModified,
LastModifiedBy = source.LastModifiedBy,
NewData = null,
NewStatus = null,
ScheduledAt = null,
ScheduleJob = null,
SchemaId = source.SchemaId,
Status = source.CurrentVersion.Status,
TranslationStatus = translationStatus,
Version = source.Version,
};
return (entity, references);
}
}
public record EFContentEntity : Content, IVersionedEntity<DomainId>
{
[Key]
public DomainId DocumentId { get; set; }
public DomainId IndexedAppId { get; set; }
public DomainId IndexedSchemaId { get; set; }
public Instant? ScheduledAt { get; set; }
public ContentData? NewData { get; set; }
public TranslationStatus? TranslationStatus { get; set; }
public WriteContent ToState()
{
if (NewData != null && NewStatus.HasValue)
{
return new WriteContent
{
Id = Id,
AppId = AppId,
Created = Created,
CreatedBy = CreatedBy,
CurrentVersion = new ContentVersion(Status, NewData),
IsDeleted = IsDeleted,
LastModified = LastModified,
LastModifiedBy = LastModifiedBy,
NewVersion = new ContentVersion(NewStatus.Value, Data),
ScheduleJob = ScheduleJob,
SchemaId = SchemaId,
Version = Version,
};
}
else
{
return new WriteContent
{
Id = Id,
AppId = AppId,
Created = Created,
CreatedBy = CreatedBy,
CurrentVersion = new ContentVersion(Status, Data),
IsDeleted = IsDeleted,
LastModified = LastModified,
LastModifiedBy = LastModifiedBy,
NewVersion = null,
ScheduleJob = ScheduleJob,
SchemaId = SchemaId,
Version = Version,
};
}
}
protected static async Task<(HashSet<DomainId>, TranslationStatus?)> CreateExtendedValuesAsync(
WriteContent content,
ContentData data,
IAppProvider appProvider,
CancellationToken ct)
{
var referencedIds = new HashSet<DomainId>();
var (app, schema) = await appProvider.GetAppWithSchemaAsync(content.AppId.Id, content.SchemaId.Id, true, ct);
if (app == null || schema == null)
{
return (referencedIds, null);
}
if (data.CanHaveReference())
{
var components = await appProvider.GetComponentsAsync(schema, ct: ct);
data.AddReferencedIds(schema, referencedIds, components);
}
var translationStatus = TranslationStatus.Create(data, schema, app.Languages);
return (referencedIds, translationStatus);
}
}

147
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository.cs

@ -0,0 +1,147 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed partial class EFContentRepository<TContext>(
IDbContextFactory<TContext> dbContextFactory, IAppProvider appProvider,
SqlDialect dialect)
: IContentRepository where TContext : DbContext
{
public async Task<Content?> FindContentAsync(App app, Schema schema, DomainId id, SearchScope scope,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/FindContentAsync"))
{
return scope == SearchScope.All ?
await FindContentAsync<EFContentCompleteEntity>(app.Id, schema.Id, id, ct) :
await FindContentAsync<EFContentPublishedEntity>(app.Id, schema.Id, id, ct);
}
}
public async Task<Content?> FindContentAsync<T>(DomainId appId, DomainId schemaId, DomainId id,
CancellationToken ct = default) where T : EFContentEntity
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<T>()
.Where(x => x.DocumentId == DomainId.Combine(appId, id))
.Where(x => x.IndexedSchemaId == schemaId)
.FirstOrDefaultAsync(ct);
return entity;
}
public async Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(App app, HashSet<DomainId> ids, SearchScope scope,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/QueryIdsAsync"))
{
return scope == SearchScope.All ?
await QueryIdsAsync<EFContentCompleteEntity>(app.Id, ids, ct) :
await QueryIdsAsync<EFContentPublishedEntity>(app.Id, ids, ct);
}
}
private async Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync<T>(DomainId appId, HashSet<DomainId> ids,
CancellationToken ct = default) where T : EFContentEntity
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
await dbContext.Set<T>()
.Where(x => x.IndexedAppId == appId)
.Where(x => ids.Contains(x.Id))
.Select(x => new { SchemaId = x.IndexedSchemaId, x.Id, x.Status })
.ToListAsync(ct);
return entities.Select(x => new ContentIdStatus(x.SchemaId, x.Id, x.Status)).ToList();
}
public async Task<bool> HasReferrersAsync(App app, DomainId reference, SearchScope scope,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/HasReferrersAsync"))
{
return scope == SearchScope.All ?
await HasReferrersAsync<EFReferenceCompleteEntity>(app.Id, reference, ct) :
await HasReferrersAsync<EFReferencePublishedEntity>(app.Id, reference, ct);
}
}
public async Task<bool> HasReferrersAsync<TReference>(DomainId appId, DomainId reference,
CancellationToken ct = default) where TReference : EFReferenceEntity
{
using (Telemetry.Activities.StartActivity("EFContentRepository/QueryIdsAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var result =
await dbContext.Set<TReference>()
.Where(x => x.AppId == appId)
.Where(x => x.ToId == reference)
.AnyAsync(ct);
return result;
}
}
public Task ResetScheduledAsync(DomainId appId, DomainId id, SearchScope scope,
CancellationToken ct = default)
{
return scope == SearchScope.All ?
ResetScheduledAsync<EFContentCompleteEntity>(appId, id, ct) :
ResetScheduledAsync<EFContentPublishedEntity>(appId, id, ct);
}
public async Task ResetScheduledAsync<T>(DomainId appId, DomainId id,
CancellationToken ct = default) where T : EFContentEntity
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<T>()
.Where(x => x.DocumentId == DomainId.Combine(appId, id))
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.ScheduledAt, (Instant?)null)
.SetProperty(x => x.ScheduleJob, (ScheduleJob?)null),
ct);
}
public Task CreateIndexAsync(DomainId appId, DomainId schemaId, IndexDefinition index,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task<List<IndexDefinition>> GetIndexesAsync(DomainId appId, DomainId schemaId,
CancellationToken ct = default)
{
return Task.FromResult<List<IndexDefinition>>([]);
}
public Task DropIndexAsync(DomainId appId, DomainId schemaId, string name,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

204
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository_Dynamic.cs

@ -0,0 +1,204 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed partial class EFContentRepository<TContext>
{
public Task<IResultList<Content>> QueryAsync(App app, Schema schema, Q q, SearchScope scope,
CancellationToken ct = default)
{
return QueryAsync(app, [schema], true, q, scope, ct);
}
public Task<IResultList<Content>> QueryAsync(App app, List<Schema> schemas, Q q, SearchScope scope,
CancellationToken ct = default)
{
return QueryAsync(app, schemas, false, q, scope, ct);
}
private async Task<IResultList<Content>> QueryAsync(App app, List<Schema> schemas, bool isSingle, Q q, SearchScope scope,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/QueryAsync"))
{
var schemaIds = schemas.Select(x => x.Id).ToList();
return scope == SearchScope.All ?
await QueryAsync<EFContentCompleteEntity, EFReferenceCompleteEntity>(
app.Id,
schemaIds,
isSingle,
q,
"ContentsAll",
"ContentReferencesAll",
ct) :
await QueryAsync<EFContentPublishedEntity, EFReferencePublishedEntity>(
app.Id,
schemaIds,
isSingle,
q,
"ContentsPublished",
"ContentReferencesPublished",
ct);
}
}
private async Task<IResultList<Content>> QueryAsync<T, TReference>(
DomainId appId,
List<DomainId> schemaIds,
bool isSingle,
Q q,
string tableName,
string referenceTableName,
CancellationToken ct = default) where T : EFContentEntity where TReference : EFReferenceEntity
{
if (q.Ids is { Count: > 0 } && schemaIds.Count > 0)
{
await using var dbContext = await CreateDbContextAsync(ct);
var result =
await dbContext.Set<T>()
.Where(x => x.IndexedAppId == appId)
.Where(x => schemaIds.Contains(x.IndexedSchemaId))
.Where(x => q.Ids.Contains(x.Id))
.Where(x => !x.IsDeleted)
.QueryAsync(q, ct);
return result;
}
if (q.ScheduledFrom != null && q.ScheduledTo != null && schemaIds.Count > 0)
{
await using var dbContext = await CreateDbContextAsync(ct);
var result =
await dbContext.Set<T>()
.Where(x => x.IndexedAppId == appId)
.Where(x => schemaIds.Contains(x.IndexedSchemaId))
.Where(x => x.ScheduledAt >= q.ScheduledFrom && x.ScheduledAt <= q.ScheduledTo)
.Where(x => !x.IsDeleted)
.QueryAsync(q, ct);
return result;
}
if (q.Referencing != default && schemaIds.Count > 0)
{
await using var dbContext = await CreateDbContextAsync(ct);
var queryBuilder =
new ContentQueryBuilder(dialect, tableName)
.Where(ClrFilter.In(nameof(EFContentEntity.IndexedAppId), appId))
.Where(ClrFilter.In(nameof(EFContentEntity.IndexedSchemaId), schemaIds))
.WhereQuery(nameof(EFContentEntity.Id), CompareOperator.In, (p, d) =>
new ContentQueryBuilder(d, referenceTableName, p)
.Where(ClrFilter.Eq(nameof(EFReferenceEntity.AppId), appId))
.Where(ClrFilter.Eq(nameof(EFReferenceEntity.FromKey), DomainId.Combine(appId, q.Referencing)))
.Select(nameof(EFReferenceEntity.ToId))
)
.WhereNotDeleted(q.Query);
return await QueryAsync<T>(dbContext, queryBuilder, q, ct);
}
if (q.Reference != default && schemaIds.Count > 0)
{
await using var dbContext = await CreateDbContextAsync(ct);
var queryBuilder =
new ContentQueryBuilder(dialect, tableName)
.WhereQuery(nameof(EFContentEntity.DocumentId), CompareOperator.In, (p, d) =>
new ContentQueryBuilder(d, referenceTableName, p)
.Where(ClrFilter.Eq(nameof(EFReferenceEntity.AppId), appId))
.Where(ClrFilter.Eq(nameof(EFReferenceEntity.ToId), q.Reference))
.Select(nameof(EFReferenceEntity.FromKey))
)
.Where(ClrFilter.In(nameof(EFContentEntity.IndexedSchemaId), schemaIds))
.WhereNotDeleted(q.Query);
if (q.Query.Filter?.HasField("IsDeleted") != true)
{
queryBuilder.Where(ClrFilter.Eq(nameof(EFContentEntity.IsDeleted), false));
}
return await QueryAsync<T>(dbContext, queryBuilder, q, ct);
}
if (isSingle)
{
await using var dbContext = await CreateDbContextAsync(ct);
var queryBuilder =
new ContentQueryBuilder(dialect, tableName)
.Where(ClrFilter.Eq(nameof(EFContentEntity.IndexedAppId), appId))
.Where(ClrFilter.Eq(nameof(EFContentEntity.IndexedSchemaId), schemaIds.Single()))
.WhereNotDeleted(q.Query);
return await QueryAsync<T>(dbContext, queryBuilder, q, ct);
}
return ResultList.Empty<Content>();
}
private static async Task<IResultList<Content>> QueryAsync<T>(TContext dbContext, SqlQueryBuilder queryBuilder, Q q,
CancellationToken ct) where T : EFContentEntity
{
var result = await dbContext.QueryAsync<T>(queryBuilder, q, ct);
if (result.Count > 0 && q.Fields is { Count: > 0 })
{
foreach (var content in result)
{
content.Data.LimitFields(q.Fields);
}
}
return result;
}
public Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync(App app, Schema schema, FilterNode<ClrValue> filterNode, SearchScope scope,
CancellationToken ct = default)
{
return scope == SearchScope.All ?
QueryIdsAsync<EFContentCompleteEntity>(app.Id, schema.Id, filterNode,
"ContentsAll", ct) :
QueryIdsAsync<EFContentPublishedEntity>(app.Id, schema.Id, filterNode,
"ContentsPublished", ct);
}
private async Task<IReadOnlyList<ContentIdStatus>> QueryIdsAsync<T>(DomainId appId, DomainId schemaId, FilterNode<ClrValue> filterNode, string table,
CancellationToken ct = default) where T : EFContentEntity
{
await using var dbContext = await CreateDbContextAsync(ct);
var (sql, parameters) =
new ContentQueryBuilder(dialect, table)
.Where(ClrFilter.Eq(nameof(EFContentEntity.IndexedAppId), appId))
.Where(ClrFilter.Eq(nameof(EFContentEntity.IndexedSchemaId), schemaId))
.WhereNotDeleted(filterNode)
.Where(filterNode)
.Select(nameof(EFContentEntity.IndexedSchemaId))
.Select(nameof(EFContentEntity.Id))
.Select(nameof(EFContentEntity.Status))
.Compile();
var entities =
await dbContext.Set<T>().FromSqlRaw(sql, parameters)
.Select(x => new { SchemaId = x.IndexedSchemaId, x.Id, x.Status }).ToListAsync(ct);
return entities.Select(x => new ContentIdStatus(x.SchemaId, x.Id, x.Status)).ToList();
}
}

295
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository_SnapshotStore.cs

@ -0,0 +1,295 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed partial class EFContentRepository<TContext> : ISnapshotStore<WriteContent>, IDeleter
{
async IAsyncEnumerable<SnapshotResult<WriteContent>> ISnapshotStore<WriteContent>.ReadAllAsync(
[EnumeratorCancellation] CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities = dbContext.Set<EFContentCompleteEntity>().ToAsyncEnumerable();
await foreach (var entity in entities.WithCancellation(ct))
{
yield return new SnapshotResult<WriteContent>(entity.DocumentId, entity.ToState(), entity.Version);
}
}
async Task<SnapshotResult<WriteContent>> ISnapshotStore<WriteContent>.ReadAsync(DomainId key,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/ReadAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity = await dbContext.Set<EFContentCompleteEntity>().Where(x => x.DocumentId == key).FirstOrDefaultAsync(ct);
if (entity == null)
{
return new SnapshotResult<WriteContent>(default, null!, EtagVersion.Empty);
}
return new SnapshotResult<WriteContent>(entity.DocumentId, entity.ToState(), entity.Version);
}
}
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/DeleteAppAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
await using var dbTransaction = await dbContext.Database.BeginTransactionAsync(ct);
try
{
await dbContext.Set<EFContentCompleteEntity>().Where(x => x.IndexedAppId == app.Id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFContentPublishedEntity>().Where(x => x.IndexedAppId == app.Id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFReferenceCompleteEntity>().Where(x => x.AppId == app.Id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFReferencePublishedEntity>().Where(x => x.AppId == app.Id)
.ExecuteDeleteAsync(ct);
await dbTransaction.CommitAsync(ct);
}
catch
{
await dbTransaction.RollbackAsync(ct);
throw;
}
}
}
async Task ISnapshotStore<WriteContent>.ClearAsync(
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/ClearAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
await using var dbTransaction = await dbContext.Database.BeginTransactionAsync(ct);
try
{
await dbContext.Set<EFContentCompleteEntity>()
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFContentPublishedEntity>()
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFReferenceCompleteEntity>()
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFReferencePublishedEntity>()
.ExecuteDeleteAsync(ct);
await dbTransaction.CommitAsync(ct);
}
catch
{
await dbTransaction.RollbackAsync(ct);
throw;
}
}
}
async Task ISnapshotStore<WriteContent>.RemoveAsync(DomainId key,
CancellationToken ct)
{
using (Telemetry.Activities.StartActivity("EFContentRepository/RemoveAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
await using var dbTransaction = await dbContext.Database.BeginTransactionAsync(ct);
try
{
await dbContext.Set<EFContentCompleteEntity>().Where(x => x.DocumentId == key)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFContentPublishedEntity>().Where(x => x.DocumentId == key)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFReferenceCompleteEntity>().Where(x => x.FromKey == key)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFReferencePublishedEntity>().Where(x => x.FromKey == key)
.ExecuteDeleteAsync(ct);
await dbTransaction.CommitAsync(ct);
}
catch
{
await dbTransaction.RollbackAsync(ct);
throw;
}
}
}
async Task ISnapshotStore<WriteContent>.WriteAsync(SnapshotWriteJob<WriteContent> job,
CancellationToken ct)
{
// Some data is corrupt and might throw an exception if we do not ignore it.
if (!IsValid(job.Value))
{
return;
}
using (Telemetry.Activities.StartActivity("EFContentRepository/WriteAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
await using var dbTransaction = await dbContext.Database.BeginTransactionAsync(ct);
try
{
var appId = job.Value.AppId.Id;
await dbContext.Set<EFReferenceCompleteEntity>().Where(x => x.FromKey == job.Key)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFReferencePublishedEntity>().Where(x => x.FromKey == job.Key)
.ExecuteDeleteAsync(ct);
if (job.Value.ShouldWritePublished())
{
await UpsertVersionedPublishedAsync(dbContext, job, ct);
}
else
{
await dbContext.Set<EFContentPublishedEntity>().Where(x => x.DocumentId == job.Key)
.ExecuteDeleteAsync(ct);
}
await UpsertVersionedCompleteAsync(dbContext, job, ct);
await dbTransaction.CommitAsync(ct);
}
catch
{
await dbTransaction.RollbackAsync(ct);
throw;
}
}
}
async Task ISnapshotStore<WriteContent>.WriteManyAsync(IEnumerable<SnapshotWriteJob<WriteContent>> jobs,
CancellationToken ct)
{
var validJobs = jobs.Where(x => IsValid(x.Value)).ToList();
using (Telemetry.Activities.StartActivity("EFContentRepository/WriteManyAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
await using var dbTransaction = await dbContext.Database.BeginTransactionAsync(ct);
try
{
var keys = validJobs.Select(x => x.Key);
var writesToCompleteContents = new List<EFContentCompleteEntity>();
var writesToCompleteReferences = new List<EFReferenceCompleteEntity>();
var writesToPublishedContents = new List<EFContentPublishedEntity>();
var writesToPublishedReferences = new List<EFReferencePublishedEntity>();
foreach (var job in validJobs)
{
{
var (entity, references) = await EFContentCompleteEntity.CreateAsync(job, appProvider, ct);
writesToCompleteContents.Add(entity);
writesToCompleteReferences.AddRange(references);
}
if (job.Value.ShouldWritePublished())
{
var (entity, references) = await EFContentPublishedEntity.CreateAsync(job, appProvider, ct);
writesToPublishedContents.Add(entity);
writesToPublishedReferences.AddRange(references);
}
}
await dbContext.BulkInsertAsync(writesToCompleteContents, cancellationToken: ct);
await dbContext.BulkInsertAsync(writesToCompleteReferences, cancellationToken: ct);
await dbContext.BulkInsertAsync(writesToPublishedContents, cancellationToken: ct);
await dbContext.BulkInsertAsync(writesToPublishedReferences, cancellationToken: ct);
await dbContext.SaveChangesAsync(ct);
await dbTransaction.CommitAsync(ct);
}
catch
{
await dbTransaction.RollbackAsync(ct);
throw;
}
}
}
private async Task UpsertVersionedPublishedAsync(TContext dbContext, SnapshotWriteJob<WriteContent> job,
CancellationToken ct)
{
var (entity, references) = await EFContentPublishedEntity.CreateAsync(job, appProvider, ct);
await dbContext.AddRangeAsync(references);
await dbContext.UpsertAsync(entity, job.OldVersion, BuildUpdate<EFContentPublishedEntity>, ct);
}
private async Task UpsertVersionedCompleteAsync(TContext dbContext, SnapshotWriteJob<WriteContent> job,
CancellationToken ct)
{
var (entity, references) = await EFContentCompleteEntity.CreateAsync(job, appProvider, ct);
await dbContext.AddRangeAsync(references);
await dbContext.UpsertAsync(entity, job.OldVersion, BuildUpdate<EFContentCompleteEntity>, ct);
}
private static Expression<Func<SetPropertyCalls<T>, SetPropertyCalls<T>>> BuildUpdate<T>(EFContentEntity entity) where T : EFContentEntity
{
return b => b
.SetProperty(x => x.AppId, entity.AppId)
.SetProperty(x => x.Created, entity.Created)
.SetProperty(x => x.CreatedBy, entity.CreatedBy)
.SetProperty(x => x.Data, entity.Data)
.SetProperty(x => x.IndexedAppId, entity.IndexedAppId)
.SetProperty(x => x.IndexedSchemaId, entity.IndexedSchemaId)
.SetProperty(x => x.IsDeleted, entity.IsDeleted)
.SetProperty(x => x.LastModified, entity.LastModified)
.SetProperty(x => x.LastModifiedBy, entity.LastModifiedBy)
.SetProperty(x => x.NewData, entity.NewData)
.SetProperty(x => x.NewStatus, entity.NewStatus)
.SetProperty(x => x.ScheduledAt, entity.ScheduledAt)
.SetProperty(x => x.ScheduleJob, entity.ScheduleJob)
.SetProperty(x => x.SchemaId, entity.SchemaId)
.SetProperty(x => x.Status, entity.Status)
.SetProperty(x => x.TranslationStatus, entity.TranslationStatus)
.SetProperty(x => x.Version, entity.Version);
}
private static bool IsValid(WriteContent state)
{
// Some data is corrupt and might throw an exception during migration if we do not skip them.
return
state.AppId != null &&
state.AppId.Id != DomainId.Empty &&
state.CurrentVersion != null &&
state.SchemaId != null &&
state.SchemaId.Id != DomainId.Empty;
}
}

133
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentRepository_Streaming.cs

@ -0,0 +1,133 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.CompilerServices;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed partial class EFContentRepository<TContext>
{
public IAsyncEnumerable<DomainId> StreamIds(DomainId appId, HashSet<DomainId>? schemaIds, SearchScope scope,
CancellationToken ct = default)
{
return scope == SearchScope.All ?
StreamIds<EFContentCompleteEntity>(appId, schemaIds, ct) :
StreamIds<EFContentPublishedEntity>(appId, schemaIds, ct);
}
private async IAsyncEnumerable<DomainId> StreamIds<T>(DomainId appId, HashSet<DomainId>? schemaIds,
[EnumeratorCancellation] CancellationToken ct = default) where T : EFContentEntity
{
if (schemaIds is { Count: 0 })
{
yield break;
}
await using var dbContext = await CreateDbContextAsync(ct);
var query =
dbContext.Set<T>()
.Where(x => x.IndexedAppId == appId)
.WhereIf(x => schemaIds!.Contains(x.IndexedSchemaId), schemaIds is { Count: > 0 })
.Select(x => x.Id)
.ToAsyncEnumerable();
await foreach (var id in query.WithCancellation(ct))
{
yield return id;
}
}
public IAsyncEnumerable<Content> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds, SearchScope scope,
CancellationToken ct = default)
{
return scope == SearchScope.All ?
StreamAll<EFContentCompleteEntity>(appId, schemaIds, ct) :
StreamAll<EFContentPublishedEntity>(appId, schemaIds, ct);
}
private async IAsyncEnumerable<Content> StreamAll<T>(DomainId appId, HashSet<DomainId>? schemaIds,
[EnumeratorCancellation] CancellationToken ct = default) where T : EFContentEntity
{
if (schemaIds is { Count: 0 })
{
yield break;
}
await using var dbContext = await CreateDbContextAsync(ct);
var query =
dbContext.Set<T>()
.Where(x => x.IndexedAppId == appId)
.WhereIf(x => schemaIds!.Contains(x.IndexedSchemaId), schemaIds is { Count: > 0 })
.Select(x => x)
.ToAsyncEnumerable();
await foreach (var entity in query.WithCancellation(ct))
{
yield return entity;
}
}
public IAsyncEnumerable<Content> StreamReferencing(DomainId appId, DomainId reference, int take, SearchScope scope,
CancellationToken ct = default)
{
return scope == SearchScope.All ?
StreamReferencing<EFContentCompleteEntity, EFReferenceCompleteEntity>(appId, reference, take, ct) :
StreamReferencing<EFContentPublishedEntity, EFReferencePublishedEntity>(appId, reference, take, ct);
}
private async IAsyncEnumerable<Content> StreamReferencing<T, TReference>(DomainId appId, DomainId reference, int take,
[EnumeratorCancellation] CancellationToken ct = default) where T : EFContentEntity where TReference : EFReferenceEntity
{
await using var dbContext = await CreateDbContextAsync(ct);
var query =
dbContext.Set<T>()
.Join(dbContext.Set<TReference>(), t => t.DocumentId, r => r.FromKey, (t, r) => new { T = t, R = r })
.Where(x => x.R.ToId == reference)
.Where(x => x.R.AppId == appId)
.Where(x => x.T.IndexedAppId == appId)
.Select(x => x.T).Distinct()
.Take(take)
.ToAsyncEnumerable();
await foreach (var entity in query.WithCancellation(ct))
{
yield return entity;
}
}
public IAsyncEnumerable<Content> StreamScheduledWithoutDataAsync(Instant now, SearchScope scope,
CancellationToken ct = default)
{
return scope == SearchScope.All ?
StreamScheduledWithoutDataAsync<EFContentCompleteEntity>(now, ct) :
StreamScheduledWithoutDataAsync<EFContentPublishedEntity>(now, ct);
}
private async IAsyncEnumerable<Content> StreamScheduledWithoutDataAsync<T>(Instant now,
[EnumeratorCancellation] CancellationToken ct = default) where T : EFContentEntity
{
await using var dbContext = await CreateDbContextAsync(ct);
var query =
dbContext.Set<T>()
.Where(x => x.ScheduledAt != null && x.ScheduledAt < now)
.ToAsyncEnumerable();
await foreach (var entity in query.WithCancellation(ct))
{
yield return entity;
}
}
}

29
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFReferenceEntity.cs

@ -0,0 +1,29 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
#pragma warning disable MA0048 // File name must match type name
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed record EFReferenceCompleteEntity : EFReferenceEntity
{
}
public sealed record EFReferencePublishedEntity : EFReferenceEntity
{
}
public abstract record EFReferenceEntity
{
public DomainId AppId { get; set; }
public DomainId FromKey { get; set; }
public DomainId ToId { get; set; }
}

75
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Extensions.cs

@ -0,0 +1,75 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities.Contents;
internal static class Extensions
{
public static bool ShouldWritePublished(this WriteContent content)
{
return content.CurrentVersion.Status == Status.Published && !content.IsDeleted;
}
public static SqlQueryBuilder WhereNotDeleted(this SqlQueryBuilder builder, Query<ClrValue>? query)
{
return builder.WhereNotDeleted(query?.Filter);
}
public static SqlQueryBuilder WhereNotDeleted(this SqlQueryBuilder builder, FilterNode<ClrValue>? filter)
{
if (filter?.HasField("IsDeleted") != true)
{
builder.Where(ClrFilter.Eq(nameof(EFContentEntity.IsDeleted), false));
}
return builder;
}
public static void LimitFields(this ContentData data, IReadOnlySet<string> fields)
{
List<string>? toDelete = null;
foreach (var (key, value) in data)
{
if (!fields.Any(x => IsMatch(key, x)))
{
toDelete ??= [];
toDelete.Add(key);
}
}
if (toDelete != null)
{
foreach (var key in toDelete)
{
data.Remove(key);
}
}
static bool IsMatch(string actual, string filter)
{
const string Prefix = "data.";
var span = filter.AsSpan();
if (span.Equals(actual, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (span.Length <= Prefix.Length || !span.StartsWith(Prefix, StringComparison.Ordinal))
{
return false;
}
span = span[Prefix.Length..];
return span.Equals(actual, StringComparison.Ordinal);
}
}
}

11
backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/ContentsQueryIntegrationTests.cs → backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/QueriedStatus.cs

@ -5,10 +5,13 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.MongoDb.Domain.Contents; namespace Squidex.Domain.Apps.Entities.Contents;
[Trait("Category", "Dependencies")] public class QueriedStatus
public class ContentsQueryIntegrationTests(ContentsQueryFixture_Default fixture)
: ContentsQueryTestsBase(fixture), IClassFixture<ContentsQueryFixture_Default>
{ {
public string IndexedSchemaId { get; set; }
public string Id { get; set; }
public string Status { get; set; }
} }

121
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexerState.cs

@ -0,0 +1,121 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class EFTextIndexerState<TContext>(IDbContextFactory<TContext> dbContextFactory, SqlDialect dialect, IContentRepository contentRepository)
: ITextIndexerState, IDeleter where TContext : DbContext
{
int IDeleter.Order => -2000;
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
var (query, parameters) =
new SqlQueryBuilder(dialect, "TextState")
.Where(ClrFilter.Gt(nameof(TextContentState.UniqueContentId), new UniqueContentId(app.Id, DomainId.Empty).ToParseableString()))
.OrderAsc(nameof(TextContentState.UniqueContentId))
.OrderAsc(nameof(TextContentState.State))
.Compile();
var ids =
dbContext.Set<TextContentState>()
.FromSqlRaw(query, parameters)
.ToAsyncEnumerable()
.TakeWhile(x => x.UniqueContentId.AppId == app.Id)
.Take(int.MaxValue)
.Select(x => x.UniqueContentId);
await DeleteInBatchesAsync(ids, ct);
}
async Task IDeleter.DeleteSchemaAsync(App app, Schema schema,
CancellationToken ct)
{
var ids =
contentRepository.StreamIds(app.Id, [schema.Id], SearchScope.All, ct)
.Select(x => new UniqueContentId(app.Id, x));
await DeleteInBatchesAsync(ids, ct);
}
private async Task DeleteInBatchesAsync(IAsyncEnumerable<UniqueContentId> ids,
CancellationToken ct)
{
var dbContext = await CreateDbContextAsync(ct);
await foreach (var batch in ids.Batch(1000, ct).WithCancellation(ct))
{
await dbContext.Set<TextContentState>().Where(x => batch.Contains(x.UniqueContentId))
.ExecuteDeleteAsync(ct);
}
}
public async Task ClearAsync(
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<TextContentState>()
.ExecuteDeleteAsync(ct);
}
public async Task<Dictionary<UniqueContentId, TextContentState>> GetAsync(HashSet<UniqueContentId> ids,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
await dbContext.Set<TextContentState>().Where(x => ids.Contains(x.UniqueContentId))
.ToListAsync(ct);
return entities.ToDictionary(x => x.UniqueContentId);
}
public async Task SetAsync(List<TextContentState> updates,
CancellationToken ct = default)
{
var toDelete = new List<TextContentState>();
var toUpsert = new List<TextContentState>();
foreach (var update in updates)
{
if (update.State == TextState.Deleted)
{
toDelete.Add(update);
}
else
{
toUpsert.Add(update);
}
}
if (toDelete.Count == 0 && toUpsert.Count == 0)
{
return;
}
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.BulkDeleteAsync(toDelete, cancellationToken: ct);
await dbContext.BulkInsertOrUpdateAsync(toUpsert, cancellationToken: ct);
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

38
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/NullTextIndex.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class NullTextIndex : ITextIndex
{
public Task ClearAsync(
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task ExecuteAsync(IndexCommand[] commands,
CancellationToken ct = default)
{
return Task.CompletedTask;
}
public Task<List<DomainId>?> SearchAsync(App app, TextQuery query, SearchScope scope,
CancellationToken ct = default)
{
return Task.FromResult<List<DomainId>?>(null);
}
public Task<List<DomainId>?> SearchAsync(App app, GeoQuery query, SearchScope scope,
CancellationToken ct = default)
{
return Task.FromResult<List<DomainId>?>(null);
}
}

27
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/History/EFHistoryBuilder.cs

@ -0,0 +1,27 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.History;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
namespace Microsoft.EntityFrameworkCore;
public static class EFHistoryBuilder
{
public static void UseHistory(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.Entity<HistoryEvent>(b =>
{
b.Property(x => x.Actor).AsString();
b.Property(x => x.Id).AsString();
b.Property(x => x.OwnerId).AsString();
b.Property(x => x.Parameters).AsJsonString(jsonSerializer, jsonColumn);
b.Property(x => x.Created).AsDateTimeOffset();
});
}
}

75
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/History/EFHistoryEventRepository.cs

@ -0,0 +1,75 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.History.Repositories;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.History;
public sealed class EFHistoryEventRepository<TContext>(IDbContextFactory<TContext> dbContextFactory)
: IHistoryEventRepository, IDeleter where TContext : DbContext
{
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<HistoryEvent>().Where(x => x.OwnerId == app.Id)
.ExecuteDeleteAsync(ct);
}
public async Task ClearAsync(
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<HistoryEvent>()
.ExecuteDeleteAsync(ct);
}
public async Task<IReadOnlyList<HistoryEvent>> QueryByChannelAsync(DomainId ownerId, string? channel, int count,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var query = dbContext.Set<HistoryEvent>().Where(x => x.OwnerId == ownerId);
if (!string.IsNullOrWhiteSpace(channel))
{
query = query.Where(x => x.Channel == channel);
}
query = query
.OrderByDescending(x => x.Created)
.ThenByDescending(x => x.Version)
.Take(count);
var result = await query.ToListAsync(ct);
return result;
}
public async Task InsertManyAsync(IEnumerable<HistoryEvent> historyEvents,
CancellationToken ct = default)
{
var entities = historyEvents.ToList();
if (entities.Count == 0)
{
return;
}
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.BulkInsertOrUpdateAsync(entities, cancellationToken: ct);
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

40
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleBuilder.cs

@ -0,0 +1,40 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.States;
namespace Microsoft.EntityFrameworkCore;
public static class EFRuleBuilder
{
public static void UseRules(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<Rule, EFRuleEntity>(jsonSerializer, jsonColumn, b =>
{
b.Property(x => x.IndexedAppId).AsString();
b.Property(x => x.IndexedId).AsString();
});
builder.Entity<EFRuleEventEntity>(b =>
{
b.Property(x => x.Id).AsString();
b.Property(x => x.AppId).AsString();
b.Property(x => x.Created).AsDateTimeOffset();
b.Property(x => x.Expires).AsDateTimeOffset();
b.Property(x => x.Job).AsJsonString(jsonSerializer, jsonColumn);
b.Property(x => x.JobResult).AsString();
b.Property(x => x.LastModified).AsDateTimeOffset();
b.Property(x => x.NextAttempt).AsDateTimeOffset();
b.Property(x => x.Result).AsString();
b.Property(x => x.RuleId).AsString();
});
}
}

32
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleEntity.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations.Schema;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class EFRuleEntity : EFState<Rule>
{
[Column("AppId")]
public DomainId IndexedAppId { get; set; }
[Column("Id")]
public DomainId IndexedId { get; set; }
[Column("Deleted")]
public bool IndexedDeleted { get; set; }
public override void Prepare()
{
IndexedAppId = Document.AppId.Id;
IndexedDeleted = Document.IsDeleted;
IndexedId = Document.Id;
}
}

65
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleEventEntity.cs

@ -0,0 +1,65 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Rules;
[Table("RuleEvents")]
public sealed class EFRuleEventEntity : IRuleEventEntity
{
[Key]
public DomainId Id { get; set; }
public DomainId AppId { get; set; }
public DomainId RuleId { get; set; }
public Instant Created { get; set; }
public Instant LastModified { get; set; }
public RuleResult Result { get; set; }
public RuleJobResult JobResult { get; set; }
public RuleJob Job { get; set; }
public string? LastDump { get; set; }
public int NumCalls { get; set; }
public Instant Expires { get; set; }
public Instant? NextAttempt { get; set; }
public static EFRuleEventEntity FromJob(RuleEventWrite item)
{
var (job, nextAttempt, error) = item;
var entity = new EFRuleEventEntity { Job = job, Id = job.Id, NextAttempt = nextAttempt };
SimpleMapper.Map(job, entity);
if (nextAttempt == null)
{
entity.JobResult = RuleJobResult.Failed;
entity.LastDump = error?.ToString();
entity.LastModified = job.Created;
entity.Result = RuleResult.Failed;
}
return entity;
}
}

167
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleEventRepository.cs

@ -0,0 +1,167 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Data;
using System.Runtime.CompilerServices;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class EFRuleEventRepository<TContext>(IDbContextFactory<TContext> dbContextFactory)
: IRuleEventRepository, IDeleter where TContext : DbContext
{
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRuleEventEntity>().Where(x => x.AppId == app.Id)
.ExecuteDeleteAsync(ct);
}
public async IAsyncEnumerable<IRuleEventEntity> QueryPendingAsync(Instant now,
[EnumeratorCancellation] CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var ruleEvents =
dbContext.Set<EFRuleEventEntity>().Where(x => x.NextAttempt < now)
.ToAsyncEnumerable();
await foreach (var ruleEvent in ruleEvents.WithCancellation(ct))
{
yield return ruleEvent;
}
}
public async Task<IResultList<IRuleEventEntity>> QueryByAppAsync(DomainId appId, DomainId? ruleId = null, int skip = 0, int take = 20,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var query =
dbContext.Set<EFRuleEventEntity>()
.Where(x => x.AppId == appId)
.WhereIf(x => x.RuleId == ruleId!.Value, ruleId.HasValue);
var ruleEventEntities = await query.Skip(skip).Take(take).OrderByDescending(x => x.Created).ToListAsync(ct);
var ruleEventTotal = (long)ruleEventEntities.Count;
if (ruleEventTotal >= take || skip > 0)
{
ruleEventTotal = await query.CountAsync(ct);
}
return ResultList.Create(ruleEventTotal, ruleEventEntities);
}
public async Task<IRuleEventEntity?> FindAsync(DomainId id,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var ruleEvent =
await dbContext.Set<EFRuleEventEntity>().Where(x => x.Id == id)
.FirstOrDefaultAsync(ct);
return ruleEvent;
}
public async Task EnqueueAsync(DomainId id, Instant nextAttempt,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRuleEventEntity>()
.Where(x => x.Id == id)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.NextAttempt, nextAttempt),
ct);
}
public async Task CancelByEventAsync(DomainId id,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRuleEventEntity>()
.Where(x => x.Id == id)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.NextAttempt, (Instant?)null)
.SetProperty(x => x.JobResult, RuleJobResult.Cancelled),
ct);
}
public async Task CancelByRuleAsync(DomainId ruleId,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRuleEventEntity>()
.Where(x => x.RuleId == ruleId)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.NextAttempt, (Instant?)null)
.SetProperty(x => x.JobResult, RuleJobResult.Cancelled),
ct);
}
public async Task CancelByAppAsync(DomainId appId,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRuleEventEntity>()
.Where(x => x.AppId == appId)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.NextAttempt, (Instant?)null)
.SetProperty(x => x.JobResult, RuleJobResult.Cancelled),
ct);
}
public async Task UpdateAsync(RuleJob job, RuleJobUpdate update,
CancellationToken ct = default)
{
Guard.NotNull(job);
Guard.NotNull(update);
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRuleEventEntity>()
.Where(x => x.Id == job.Id)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.Result, update.ExecutionResult)
.SetProperty(x => x.LastDump, update.ExecutionDump)
.SetProperty(x => x.JobResult, update.JobResult)
.SetProperty(x => x.NextAttempt, update.JobNext)
.SetProperty(x => x.NumCalls, x => x.NumCalls + 1),
ct);
}
public async Task EnqueueAsync(List<RuleEventWrite> jobs,
CancellationToken ct = default)
{
var entities = jobs.Select(EFRuleEventEntity.FromJob).ToList();
if (entities.Count == 0)
{
return;
}
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.BulkInsertAsync(entities, cancellationToken: ct);
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

45
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Rules/EFRuleRepository.cs

@ -0,0 +1,45 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class EFRuleRepository<TContext>(IDbContextFactory<TContext> dbContextFactory)
: EFSnapshotStore<TContext, Rule, EFRuleEntity>(dbContextFactory), IRuleRepository, IDeleter where TContext : DbContext
{
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRuleEntity>().Where(x => x.IndexedAppId == app.Id)
.ExecuteDeleteAsync(ct);
}
public async Task<List<Rule>> QueryAllAsync(DomainId appId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFRuleRepository/QueryAllAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
await dbContext.Set<EFRuleEntity>()
.Where(x => x.IndexedAppId == appId)
.Where(x => !x.IndexedDeleted)
.ToListAsync(ct);
return entities.Select(x => x.Document).ToList();
}
}
}

26
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Schemas/EFSchemaBuilder.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.States;
namespace Microsoft.EntityFrameworkCore;
public static class EFSchemaBuilder
{
public static void UseSchema(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<Schema, EFSchemaEntity>(jsonSerializer, jsonColumn, b =>
{
b.Property(x => x.IndexedAppId).AsString();
b.Property(x => x.IndexedId).AsString();
});
}
}

36
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Schemas/EFSchemaEntity.cs

@ -0,0 +1,36 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations.Schema;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Schemas;
public sealed class EFSchemaEntity : EFState<Schema>
{
[Column("AppId")]
public DomainId IndexedAppId { get; set; }
[Column("Id")]
public DomainId IndexedId { get; set; }
[Column("Name")]
public string IndexedName { get; set; }
[Column("Deleted")]
public bool IndexedDeleted { get; set; }
public override void Prepare()
{
IndexedAppId = Document.AppId.Id;
IndexedDeleted = Document.IsDeleted;
IndexedId = Document.Id;
IndexedName = Document.Name;
}
}

119
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Schemas/EFSchemaRepository.cs

@ -0,0 +1,119 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Schemas;
public sealed class EFSchemaRepository<TContext>(IDbContextFactory<TContext> dbContextFactory)
: EFSnapshotStore<TContext, Schema, EFSchemaEntity>(dbContextFactory), ISchemaRepository, ISchemasHash, IDeleter where TContext : DbContext
{
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFSchemaEntity>().Where(x => x.IndexedAppId == app.Id)
.ExecuteDeleteAsync(ct);
}
async Task IDeleter.DeleteSchemaAsync(App app, Schema schema,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFSchemaEntity>().Where(x => x.IndexedId == schema.Id)
.ExecuteDeleteAsync(ct);
}
public async Task<List<Schema>> QueryAllAsync(DomainId appId, CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFSchemaRepository/QueryAllAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
await dbContext.Set<EFSchemaEntity>()
.Where(x => x.IndexedAppId == appId)
.Where(x => !x.IndexedDeleted)
.OrderBy(x => x.IndexedName)
.ToListAsync(ct);
return entities.Select(x => x.Document).ToList();
}
}
public async Task<Schema?> FindAsync(DomainId appId, DomainId id,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFSchemaRepository/FindAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<EFSchemaEntity>()
.Where(x => x.IndexedAppId == appId && x.IndexedId == id)
.Where(x => !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
public async Task<Schema?> FindAsync(DomainId appId, string name,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFSchemaRepository/FindAsyncByName"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<EFSchemaEntity>()
.Where(x => x.IndexedAppId == appId && x.IndexedName == name)
.Where(x => !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
public async Task<SchemasHashKey> GetCurrentHashAsync(App app,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFSchemaRepository/GetCurrentHashAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
await dbContext.Set<EFSchemaEntity>()
.Where(x => x.IndexedAppId == app.Id)
.Where(x => !x.IndexedDeleted)
.Select(x => new { Id = x.IndexedId, x.Version })
.ToListAsync(ct);
return SchemasHashKey.Create(app, entities.ToDictionary(x => x.Id, x => x.Version));
}
}
protected override Expression<Func<SetPropertyCalls<EFSchemaEntity>, SetPropertyCalls<EFSchemaEntity>>> BuildUpdate(EFSchemaEntity entity)
{
return u => u
.SetProperty(x => x.Document, entity.Document)
.SetProperty(x => x.IndexedAppId, entity.IndexedAppId)
.SetProperty(x => x.IndexedDeleted, entity.IndexedDeleted)
.SetProperty(x => x.IndexedId, entity.IndexedId)
.SetProperty(x => x.IndexedName, entity.IndexedName)
.SetProperty(x => x.Version, entity.Version);
}
}

21
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Teams/EFTeamBuilder.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.States;
namespace Microsoft.EntityFrameworkCore;
public static class EFTeamBuilder
{
public static void UseTeams(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.UseSnapshot<Team, EFTeamEntity>(jsonSerializer, jsonColumn);
}
}

39
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Teams/EFTeamEntity.cs

@ -0,0 +1,39 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations.Schema;
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Teams;
public sealed class EFTeamEntity : EFState<Team>
{
[Column("UserIds")]
public string IndexedUserIds { get; set; }
[Column("Deleted")]
public bool IndexedDeleted { get; set; }
[Column("AuthDomain")]
public string? IndexedAuthDomain { get; set; }
public override void Prepare()
{
var users = new HashSet<string>
{
Document.CreatedBy.Identifier,
};
users.AddRange(Document.Contributors.Keys);
IndexedAuthDomain = Document.AuthScheme?.Domain;
IndexedDeleted = Document.IsDeleted;
IndexedUserIds = TagsConverter.ToString(users);
}
}

82
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Teams/EFTeamRepository.cs

@ -0,0 +1,82 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Squidex.Domain.Apps.Core.Teams;
using Squidex.Domain.Apps.Entities.Teams.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Teams;
public sealed class EFTeamRepository<TContext>(IDbContextFactory<TContext> dbContextFactory)
: EFSnapshotStore<TContext, Team, EFTeamEntity>(dbContextFactory), ITeamRepository where TContext : DbContext
{
public async Task<List<Team>> QueryAllAsync(string contributorId,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFTeamRepository/QueryAllAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var formattedId = TagsConverter.FormatFilter(contributorId);
var entities =
await dbContext.Set<EFTeamEntity>()
.Where(x => x.IndexedUserIds.Contains(formattedId))
.Where(x => !x.IndexedDeleted)
.ToListAsync(ct);
return entities.Select(x => x.Document).ToList();
}
}
public async Task<Team?> FindAsync(DomainId id,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFTeamRepository/FindAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<EFTeamEntity>()
.Where(x => x.DocumentId == id)
.Where(x => !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
public async Task<Team?> FindByAuthDomainAsync(string authDomain,
CancellationToken ct = default)
{
using (Telemetry.Activities.StartActivity("EFTeamRepository/FindByAuthDomainAsync"))
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<EFTeamEntity>()
.Where(x => x.IndexedAuthDomain == authDomain)
.Where(x => !x.IndexedDeleted)
.FirstOrDefaultAsync(ct);
return entity?.Document;
}
}
protected override Expression<Func<SetPropertyCalls<EFTeamEntity>, SetPropertyCalls<EFTeamEntity>>> BuildUpdate(EFTeamEntity entity)
{
return u => u
.SetProperty(x => x.Document, entity.Document)
.SetProperty(x => x.IndexedAuthDomain, entity.IndexedAuthDomain)
.SetProperty(x => x.IndexedDeleted, entity.IndexedDeleted)
.SetProperty(x => x.IndexedUserIds, entity.IndexedUserIds)
.SetProperty(x => x.Version, entity.Version);
}
}

24
backend/src/Squidex.Data.EntityFramework/Domain/Users/EFUserFactory.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Globalization;
using Microsoft.AspNetCore.Identity;
namespace Squidex.Domain.Users;
public sealed class EFUserFactory : IUserFactory
{
public IdentityUser Create(string email)
{
return new IdentityUser { Email = email, UserName = email };
}
public bool IsId(string id)
{
return Guid.TryParse(id, CultureInfo.InvariantCulture, out _);
}
}

21
backend/src/Squidex.Data.EntityFramework/Infrastructure/Caching/EFCacheBuilder.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Caching;
namespace Microsoft.EntityFrameworkCore;
public static class EFCacheBuilder
{
public static void UseCache(this ModelBuilder builder)
{
builder.Entity<EFCacheEntity>(b =>
{
b.ToTable("Cache");
});
}
}

22
backend/src/Squidex.Data.EntityFramework/Infrastructure/Caching/EFCacheEntity.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Squidex.Infrastructure.Caching;
[Table("Cache")]
public class EFCacheEntity
{
[Key]
public string Key { get; set; }
public DateTime Expires { get; set; }
public byte[] Value { get; set; }
}

147
backend/src/Squidex.Data.EntityFramework/Infrastructure/Caching/EFDistributedCache.cs

@ -0,0 +1,147 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Squidex.Hosting;
using Squidex.Infrastructure.Timers;
namespace Squidex.Infrastructure.Caching;
public sealed class EFDistributedCache<TContext>(IDbContextFactory<TContext> dbContextFactory, TimeProvider timeProvider)
: IDistributedCache, IInitializable where TContext : DbContext
{
#pragma warning disable RECS0108 // Warns about static fields in generic types
private static readonly TimeSpan CleanupTime = TimeSpan.FromMinutes(10);
#pragma warning restore RECS0108 // Warns about static fields in generic types
private CompletionTimer? timer;
public Task InitializeAsync(
CancellationToken ct)
{
timer = new CompletionTimer(CleanupTime, CleanupAsync);
return Task.CompletedTask;
}
public Task ReleaseAsync(
CancellationToken ct)
{
return timer?.StopAsync() ?? Task.CompletedTask;
}
public byte[] Get(string key)
{
throw new NotSupportedException();
}
public void Refresh(string key)
{
throw new NotSupportedException();
}
public void Remove(string key)
{
throw new NotSupportedException();
}
public void Set(string key, byte[] value, DistributedCacheEntryOptions options)
{
throw new NotSupportedException();
}
public Task RefreshAsync(string key,
CancellationToken token = default)
{
return Task.CompletedTask;
}
public async Task CleanupAsync(
CancellationToken token)
{
var now = timeProvider.GetUtcNow().UtcDateTime;
var dbContext = await CreateDbContextAsync(token);
await dbContext.Set<EFCacheEntity>().Where(x => x.Expires < now)
.ExecuteDeleteAsync(token);
}
public async Task RemoveAsync(string key,
CancellationToken token = default)
{
await using var dbContext = await CreateDbContextAsync(token);
await dbContext.Set<EFCacheEntity>().Where(x => x.Key == key)
.ExecuteDeleteAsync(token);
}
public async Task<byte[]?> GetAsync(string key,
CancellationToken token = default)
{
await using var dbContext = await CreateDbContextAsync(token);
var now = timeProvider.GetUtcNow().UtcDateTime;
var entry =
await dbContext.Set<EFCacheEntity>()
.Where(x => x.Key == key).FirstOrDefaultAsync(token);
if (entry != null && entry.Expires > now)
{
return entry.Value;
}
return null;
}
public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options,
CancellationToken token = default)
{
await using var dbContext = await CreateDbContextAsync(token);
var expires = timeProvider.GetUtcNow().UtcDateTime;
if (options.AbsoluteExpiration.HasValue)
{
expires = options.AbsoluteExpiration.Value.UtcDateTime;
}
else if (options.AbsoluteExpirationRelativeToNow.HasValue)
{
expires += options.AbsoluteExpirationRelativeToNow.Value;
}
else if (options.SlidingExpiration.HasValue)
{
expires += options.SlidingExpiration.Value;
}
else
{
expires = DateTime.MaxValue;
}
var entity = new EFCacheEntity { Key = key, Value = value, Expires = expires };
try
{
await dbContext.Set<EFCacheEntity>().AddAsync(entity, token);
await dbContext.SaveChangesAsync(token);
}
finally
{
dbContext.Entry(entity).State = EntityState.Detached;
}
await dbContext.Set<EFCacheEntity>().Where(x => x.Key == key)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.Value, value)
.SetProperty(x => x.Expires, expires),
token);
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

143
backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs

@ -0,0 +1,143 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure.Queries;
using Squidex.Infrastructure.States;
namespace Squidex.Infrastructure;
public static class Extensions
{
public static IQueryable<T> Pagination<T>(this IQueryable<T> source, ClrQuery query)
{
if (query.Skip > 0)
{
source = source.Skip((int)query.Skip);
}
if (query.Take < long.MaxValue)
{
source = source.Take((int)query.Take);
}
return source;
}
public static IQueryable<T> WhereIf<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate, bool valid)
{
if (!valid)
{
return source;
}
return source.Where(predicate);
}
public static async Task<IResultList<T>> QueryAsync<T>(this IQueryable<T> queryable, Q q,
CancellationToken ct) where T : class
{
var query = q.Query;
var queryEntities = await queryable.Pagination(q.Query).ToListAsync(ct);
var queryTotal = (long)queryEntities.Count;
if (queryEntities.Count >= query.Take || query.Skip > 0)
{
if (q.NoTotal)
{
queryTotal = -1;
}
else
{
queryTotal = await queryable.CountAsync(ct);
}
}
if (q.Query.Random > 0)
{
queryEntities = queryEntities.TakeRandom(q.Query.Random).ToList();
}
return ResultList.Create(queryTotal, queryEntities.OfType<T>());
}
public static async Task<IResultList<T>> QueryAsync<T>(this DbContext dbContext, SqlQueryBuilder sqlQuery, Q q,
CancellationToken ct) where T : class
{
sqlQuery.Limit(q.Query);
sqlQuery.Offset(q.Query);
sqlQuery.Order(q.Query);
sqlQuery.Where(q.Query);
var (sql, parameters) = sqlQuery.Compile();
var queryEntities = await dbContext.Set<T>().FromSqlRaw(sql, parameters).ToListAsync(ct);
var queryTotal = (long)queryEntities.Count;
if (queryEntities.Count >= q.Query.Take || q.Query.Skip > 0)
{
if (q.NoTotal || q.NoSlowTotal)
{
queryTotal = -1;
}
else
{
var (countSql, countParams) = sqlQuery.Count().Compile();
queryTotal =
await dbContext.Database.SqlQueryRaw<int>(countSql, countParams)
.FirstOrDefaultAsync(ct);
}
}
if (q.Query.Random > 0)
{
queryEntities = queryEntities.TakeRandom(q.Query.Random).ToList();
}
return ResultList.Create(queryTotal, queryEntities.OfType<T>());
}
public static async Task UpsertAsync<T>(this DbContext dbContext, T entity, long oldVersion,
Func<T, Expression<Func<SetPropertyCalls<T>, SetPropertyCalls<T>>>> update,
CancellationToken ct) where T : class, IVersionedEntity<DomainId>
{
try
{
await dbContext.Set<T>().AddAsync(entity, ct);
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
var updateQuery = dbContext.Set<T>().Where(x => x.DocumentId == entity.DocumentId);
if (oldVersion > EtagVersion.Any)
{
updateQuery = updateQuery.Where(x => x.Version == oldVersion);
}
var updateCount =
await updateQuery
.ExecuteUpdateAsync(update(entity), ct);
if (updateCount != 1)
{
var currentVersions =
await dbContext.Set<T>()
.Where(x => x.DocumentId == entity.DocumentId).Select(x => x.Version)
.ToListAsync(ct);
var current = currentVersions.Count == 1 ? currentVersions[0] : EtagVersion.Empty;
throw new InconsistentStateException(current, oldVersion);
}
}
}
}

9
backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/ContentsQueryDedicatedIntegrationTests.cs → backend/src/Squidex.Data.EntityFramework/Infrastructure/IVersionedEntity.cs

@ -5,10 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
namespace Squidex.MongoDb.Domain.Contents; namespace Squidex.Infrastructure;
[Trait("Category", "Dependencies")] public interface IVersionedEntity<T>
public class ContentsQueryDedicatedIntegrationTests(ContentsQueryFixture_Dedicated fixture)
: ContentsQueryTestsBase(fixture), IClassFixture<ContentsQueryFixture_Dedicated>
{ {
T DocumentId { get; }
long Version { get; }
} }

13
backend/src/Squidex.Data.EntityFramework/Infrastructure/JsonAttribute.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure;
[AttributeUsage(AttributeTargets.Property)]
public sealed class JsonAttribute : Attribute
{
}

184
backend/src/Squidex.Data.EntityFramework/Infrastructure/JsonConversion.cs

@ -0,0 +1,184 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Infrastructure.Json;
#pragma warning disable RECS0015 // If an extension method is called as static method convert it to method syntax
namespace Squidex.Infrastructure;
public static class JsonConversion
{
public static PropertyBuilder<T> AsJsonString<T>(this PropertyBuilder<T> propertyBuilder, IJsonSerializer jsonSerializer, string? columnType)
where T : class
{
var converter = new ValueConverter<T, string>(
v => jsonSerializer.Serialize(v, false),
v => jsonSerializer.Deserialize<T>(v, null)!
);
propertyBuilder.HasConversion(converter).HasColumnType(columnType);
return propertyBuilder;
}
public static PropertyBuilder<T?> AsNullableJsonString<T>(this PropertyBuilder<T?> propertyBuilder, IJsonSerializer jsonSerializer, string? columnType)
where T : class
{
var converter = new ValueConverter<T?, string?>(
v => v != null ? jsonSerializer.Serialize(v, false) : null,
v => v != null ? jsonSerializer.Deserialize<T>(v, null) : null!
);
propertyBuilder.HasConversion(converter).HasColumnType(columnType);
return propertyBuilder;
}
public static PropertyBuilder<DomainId> AsString(this PropertyBuilder<DomainId> propertyBuilder)
{
var converter = new ValueConverter<DomainId, string>(
v => v.ToString()!,
v => DomainId.Create(v)
);
propertyBuilder.HasConversion(converter).HasMaxLength(255);
return propertyBuilder;
}
public static PropertyBuilder<DomainId?> AsString(this PropertyBuilder<DomainId?> propertyBuilder)
{
var converter = new ValueConverter<DomainId?, string?>(
v => v != null ? v.ToString()! : null,
v => v != null ? DomainId.Create(v) : null
);
propertyBuilder.HasConversion(converter).HasMaxLength(255);
return propertyBuilder;
}
public static PropertyBuilder<RefToken> AsString(this PropertyBuilder<RefToken> propertyBuilder)
{
var converter = new ValueConverter<RefToken, string>(
v => v.ToString(),
v => RefToken.Parse(v)
);
propertyBuilder.HasConversion(converter).HasMaxLength(100);
return propertyBuilder;
}
public static PropertyBuilder<NamedId<DomainId>> AsString(this PropertyBuilder<NamedId<DomainId>> propertyBuilder)
{
var converter = new ValueConverter<NamedId<DomainId>, string>(
v => v.ToString(),
v => NamedId<DomainId>.Parse(v, ParseDomainId)
);
propertyBuilder.HasConversion(converter).HasMaxLength(255);
return propertyBuilder;
}
public static PropertyBuilder<T> AsString<T>(this PropertyBuilder<T> propertyBuilder) where T : struct
{
var converter = new ValueConverter<T, string>(
v => v.ToString()!,
v => Enum.Parse<T>(v, true)
);
propertyBuilder.HasConversion(converter).HasMaxLength(100);
return propertyBuilder;
}
public static PropertyBuilder<T?> AsNullableString<T>(this PropertyBuilder<T?> propertyBuilder) where T : struct
{
var converter = new ValueConverter<T?, string?>(
v => v != null ? v.ToString() : null,
v => v != null ? Enum.Parse<T>(v, true) : null
);
propertyBuilder.HasConversion(converter).HasMaxLength(100);
return propertyBuilder;
}
public static PropertyBuilder<HashSet<string>> AsString(this PropertyBuilder<HashSet<string>> propertyBuilder)
{
var converter = new ValueConverter<HashSet<string>, string>(
v => TagsConverter.ToString(v),
v => TagsConverter.ToSet(v)
);
propertyBuilder.HasConversion(converter).HasMaxLength(1000);
return propertyBuilder;
}
public static PropertyBuilder<Status> AsString(this PropertyBuilder<Status> propertyBuilder)
{
var converter = new ValueConverter<Status, string>(
v => v.ToString()!,
v => new Status(v)
);
propertyBuilder.HasConversion(converter).HasMaxLength(100);
return propertyBuilder;
}
public static PropertyBuilder<Status?> AsNullableString(this PropertyBuilder<Status?> propertyBuilder)
{
var converter = new ValueConverter<Status?, string?>(
v => v != null ? v.ToString()! : null,
v => v != null ? new Status(v) : null
);
propertyBuilder.HasConversion(converter).HasMaxLength(100);
return propertyBuilder;
}
public static PropertyBuilder<UniqueContentId> AsString(this PropertyBuilder<UniqueContentId> propertyBuilder)
{
var converter = new ValueConverter<UniqueContentId, string>(
v => v.ToParseableString(),
v => v.ToUniqueContentId()
);
propertyBuilder.HasConversion(converter).HasMaxLength(255);
return propertyBuilder;
}
public static PropertyBuilder<Instant> AsDateTimeOffset(this PropertyBuilder<Instant> propertyBuilder)
{
var converter = new ValueConverter<Instant, DateTimeOffset>(
v => v.ToDateTimeOffset(),
v => Instant.FromDateTimeOffset(v)
);
propertyBuilder.HasConversion(converter);
return propertyBuilder;
}
public static PropertyBuilder<Instant?> AsDateTimeOffset(this PropertyBuilder<Instant?> propertyBuilder)
{
var converter = new ValueConverter<Instant?, DateTimeOffset?>(
v => v != null ? v.Value.ToDateTimeOffset() : null,
v => v != null ? Instant.FromDateTimeOffset(v.Value) : null
);
propertyBuilder.HasConversion(converter);
return propertyBuilder;
}
private static bool ParseDomainId(ReadOnlySpan<char> value, out DomainId result)
{
result = DomainId.Create(new string(value));
return true;
}
}

25
backend/src/Squidex.Data.EntityFramework/Infrastructure/Log/EFRequestBuilder.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Log;
namespace Microsoft.EntityFrameworkCore;
public static class EFRequestBuilder
{
public static void UseRequest(this ModelBuilder builder, IJsonSerializer jsonSerializer, string? jsonColumn)
{
builder.Entity<EFRequestEntity>(b =>
{
b.ToTable("Requests");
b.Property(x => x.Timestamp).AsDateTimeOffset();
b.Property(x => x.Properties).AsJsonString(jsonSerializer, jsonColumn);
});
}
}

38
backend/src/Squidex.Data.EntityFramework/Infrastructure/Log/EFRequestEntity.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Infrastructure.Log;
[Table("Requests")]
[Index(nameof(Key))]
public sealed class EFRequestEntity
{
[Key]
public int Id { get; set; }
public string Key { get; set; }
public Instant Timestamp { get; set; }
public Dictionary<string, string> Properties { get; set; }
public static EFRequestEntity FromRequest(Request request)
{
return SimpleMapper.Map(request, new EFRequestEntity());
}
public Request ToRequest()
{
return SimpleMapper.Map(this, new Request());
}
}

101
backend/src/Squidex.Data.EntityFramework/Infrastructure/Log/EFRequestLogRepository.cs

@ -0,0 +1,101 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.CompilerServices;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NodaTime;
using Squidex.Hosting;
using Squidex.Infrastructure.Timers;
namespace Squidex.Infrastructure.Log;
public sealed class EFRequestLogRepository<TContext>(IDbContextFactory<TContext> dbContextFactory, IOptions<RequestLogStoreOptions> options)
: IRequestLogRepository, IInitializable where TContext : DbContext
{
#pragma warning disable RECS0108 // Warns about static fields in generic types
private static readonly TimeSpan CleanupTime = TimeSpan.FromMinutes(10);
#pragma warning restore RECS0108 // Warns about static fields in generic types
private readonly RequestLogStoreOptions options = options.Value;
private CompletionTimer? timer;
public Task InitializeAsync(
CancellationToken ct)
{
timer = new CompletionTimer(CleanupTime, CleanupAsync);
return Task.CompletedTask;
}
public Task ReleaseAsync(
CancellationToken ct)
{
return timer?.StopAsync() ?? Task.CompletedTask;
}
private async Task CleanupAsync(
CancellationToken ct)
{
var maxAge = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromDays(options.StoreRetentionInDays));
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRequestEntity>().Where(x => x.Timestamp < maxAge)
.ExecuteDeleteAsync(ct);
}
public async Task DeleteAsync(string key,
CancellationToken ct = default)
{
Guard.NotNullOrEmpty(key);
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFRequestEntity>().Where(x => x.Key == key)
.ExecuteDeleteAsync(ct);
}
public async Task InsertManyAsync(IEnumerable<Request> items,
CancellationToken ct = default)
{
Guard.NotNull(items);
var entities = items.Select(EFRequestEntity.FromRequest).ToList();
if (entities.Count == 0)
{
return;
}
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.BulkInsertAsync(entities, cancellationToken: ct);
}
public async IAsyncEnumerable<Request> QueryAllAsync(string key, Instant fromTime, Instant toTime,
[EnumeratorCancellation] CancellationToken ct = default)
{
Guard.NotNullOrEmpty(key);
await using var dbContext = await CreateDbContextAsync(ct);
var entities =
dbContext.Set<EFRequestEntity>()
.Where(x => x.Key == key)
.Where(x => x.Timestamp >= fromTime && x.Timestamp <= toTime)
.ToAsyncEnumerable();
await foreach (var entity in entities.WithCancellation(ct))
{
yield return entity.ToRequest();
}
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

32
backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/DatabaseCreator.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Squidex.Hosting;
namespace Squidex.Infrastructure.Migrations;
public sealed class DatabaseCreator<TContext>(IDbContextFactory<TContext> dbContextFactory) : IInitializable
where TContext : DbContext
{
public int Order => -1000;
public async Task InitializeAsync(
CancellationToken ct)
{
await using var context = await dbContextFactory.CreateDbContextAsync(ct);
if (context.Database.GetService<IDatabaseCreator>() is not RelationalDatabaseCreator relationalDatabaseCreator)
{
return;
}
await relationalDatabaseCreator.EnsureCreatedAsync(ct);
}
}

25
backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/DatabaseMigrator.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Hosting;
namespace Squidex.Infrastructure.Migrations;
public sealed class DatabaseMigrator<TContext>(IDbContextFactory<TContext> dbContextFactory) : IInitializable
where TContext : DbContext
{
public int Order => -1000;
public async Task InitializeAsync(
CancellationToken ct)
{
await using var context = await dbContextFactory.CreateDbContextAsync(ct);
await context.Database.MigrateAsync(ct);
}
}

21
backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/EFMigrationBuilder.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Migrations;
namespace Microsoft.EntityFrameworkCore;
public static class EFMigrationBuilder
{
public static void UseMigration(this ModelBuilder builder)
{
builder.Entity<EFMigrationEntity>(b =>
{
b.ToTable("Migrations");
});
}
}

22
backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/EFMigrationEntity.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Squidex.Infrastructure.Migrations;
public sealed class EFMigrationEntity
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public int Id { get; set; }
public bool IsLocked { get; set; }
public int Version { get; set; }
}

84
backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/EFMigrationStatus.cs

@ -0,0 +1,84 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Hosting;
namespace Squidex.Infrastructure.Migrations;
public sealed class EFMigrationStatus<TContext>(IDbContextFactory<TContext> dbContextFactory)
: IMigrationStatus, IInitializable where TContext : DbContext
{
private const int DefaultId = 1;
public async Task InitializeAsync(
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
try
{
var newEntry = new EFMigrationEntity { Id = DefaultId };
await dbContext.Set<EFMigrationEntity>().AddAsync(newEntry, ct);
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
}
}
public async Task<int> GetVersionAsync(
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var entity =
await dbContext.Set<EFMigrationEntity>()
.Where(x => x.Id == DefaultId).FirstOrDefaultAsync(ct);
return entity?.Version ?? 0;
}
public async Task<bool> TryLockAsync(
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var updateCount =
await dbContext.Set<EFMigrationEntity>()
.Where(x => x.Id == DefaultId && !x.IsLocked)
.ExecuteUpdateAsync(x => x.SetProperty(p => p.IsLocked, true), ct);
return updateCount == 1;
}
public async Task CompleteAsync(int newVersion,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFMigrationEntity>()
.Where(x => x.Id == DefaultId)
.ExecuteUpdateAsync(x => x.SetProperty(p => p.Version, newVersion), ct);
}
public async Task UnlockAsync(
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFMigrationEntity>()
.Where(x => x.Id == DefaultId && x.IsLocked)
.ExecuteUpdateAsync(x => x.SetProperty(p => p.IsLocked, false), ct);
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

30
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/Extensions.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
namespace Squidex.Infrastructure.Queries;
public static class Extensions
{
public static void AppendLines(this StringBuilder sb, List<string> lines, string tab)
{
sb.AppendLine();
sb.Append(tab);
sb.Append(lines[0]);
foreach (var line in lines.Skip(1))
{
sb.Append(',');
sb.AppendLine();
sb.Append(tab);
sb.Append(line);
}
sb.AppendLine();
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save