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 1 year 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
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
run: docker compose up -d
working-directory: tools/TestSuite
- name: Test - RUN
- name: Test - RUN on Mongo
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: mcr.microsoft.com/dotnet/sdk:8.0
@ -58,21 +66,63 @@ jobs:
volumes: ${{ github.workspace }}:/src
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
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
CONFIG__SERVER__URL=http://localhost:8083
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 & 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
- name: Test - RUN with dedicated collections
- name: Test - RUN on Mongo with dedicated collections
uses: kohlerdominik/docker-run-action@v2.0.0
with:
image: mcr.microsoft.com/dotnet/sdk:8.0
@ -82,7 +132,7 @@ jobs:
CONFIG__SERVER__URL=http://localhost:8082
WEBHOOKCATCHER__HOST__ENDPOINT=webhookcatcher
default_network: host
options: --name test3
options: --name test6
volumes: ${{ github.workspace }}:/src
run: dotnet test /src/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj --filter Category!=NotAutomated
@ -112,8 +162,14 @@ jobs:
if: failure()
uses: jwalton/gh-docker-logs@v2.2.2
with:
images: 'squidex-local,squidex/resizer'
tail: '100'
dest: './docker-logs'
- name: Test - Upload docker logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-logs
path: './docker-logs'
- name: Test - Cleanup
if: always()

38
.github/workflows/release.yml

@ -54,34 +54,6 @@ jobs:
volumes: ${{ github.workspace }}:/src
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
run: npm ci
working-directory: './tools/e2e'
@ -108,8 +80,14 @@ jobs:
if: failure()
uses: jwalton/gh-docker-logs@v2.2.2
with:
images: 'squidex-local,squidex/resizer'
tail: '100'
dest: './docker-logs'
- name: Test - Upload docker logs
if: failure()
uses: actions/upload-artifact@v4
with:
name: docker-logs
path: './docker-logs'
- name: Test - Cleanup
if: always()

2
Dockerfile

@ -28,7 +28,7 @@ RUN dotnet restore
COPY backend .
# Test Backend
RUN dotnet test --no-restore --filter Category!=Dependencies
RUN dotnet test --no-restore --filter "Category!=Dependencies & Category!=TestContainer"
# Publish
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
# MA0007: Add a comma after the last value
dotnet_diagnostic.MA0007.severity = none
dotnet_diagnostic.MA0007.severity = warning
# MA0008: Add StructLayoutAttribute
dotnet_diagnostic.MA0008.severity = none

15
backend/Squidex.sln

@ -56,6 +56,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.MongoDb", "src
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.Tests", "tests\Squidex.Data.Tests\Squidex.Data.Tests.csproj", "{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}"
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
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -284,6 +298,7 @@ Global
{23615A39-F3FB-4575-A91C-535899DFB636} = {94207AA6-4923-4183-A558-E0F8196B8CA3}
{F754F05E-02FF-47B2-AB46-BB05C7E6B29D} = {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
GlobalSection(ExtensibilityGlobals) = postSolution
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>
{
["error"] = $"Invalid JSON: {ex.Message}"
}
["error"] = $"Invalid JSON: {ex.Message}",
},
};
}
@ -85,7 +85,7 @@ public sealed class AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEn
ApiKey = action.ApiKey,
Content = delete ? null : serializer.Serialize(content, true),
ContentId = contentId,
IndexName = indexName
IndexName = indexName,
};
return (ruleDescription, ruleJob);
@ -106,7 +106,7 @@ public sealed class AlgoliaActionHandler(RuleEventFormatter formatter, IScriptEn
{
var raw = new[]
{
new JRaw(job.Content)
new JRaw(job.Content),
};
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,
QueueName = queueName!,
MessageBodyV2 = requestBody
MessageBodyV2 = requestBody,
};
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
{
AppId = contentEvent.AppId
AppId = contentEvent.AppId,
};
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
{
AppId = @event.AppId
AppId = @event.AppId,
};
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,
MaximumTags = action.MaximumTags,
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));
@ -81,7 +81,7 @@ internal sealed partial class DeepDetectActionHandler(
data = new[]
{
job.Url,
}
},
}, ct);
var body = await response.Content.ReadAsStringAsync(ct);
@ -120,7 +120,7 @@ internal sealed partial class DeepDetectActionHandler(
AssetId = asset.Id,
AppId = asset.AppId,
Actor = job.Actor,
FromRule = true
FromRule = true,
};
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?>
{
["title"] = await FormatAsync(action.Title!, @event)
["title"] = await FormatAsync(action.Title!, @event),
};
if (action.Topic != null)
@ -46,7 +46,7 @@ public sealed class DiscourseActionHandler(RuleEventFormatter formatter, IHttpCl
ApiKey = action.ApiKey,
ApiUserName = action.ApiUsername,
RequestUrl = url,
RequestBody = requestBody
RequestBody = requestBody,
};
var description =
@ -64,7 +64,7 @@ public sealed class DiscourseActionHandler(RuleEventFormatter formatter, IHttpCl
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);

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

@ -54,7 +54,7 @@ public sealed class ElasticSearchActionHandler(RuleEventFormatter formatter, ISc
ServerHost = action.Host.ToString(),
ServerUser = action.Username,
ServerPassword = action.Password,
ContentId = contentId
ContentId = contentId,
};
if (delete)
@ -88,8 +88,8 @@ public sealed class ElasticSearchActionHandler(RuleEventFormatter formatter, ISc
{
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))!,
MessageTo = (await FormatAsync(action.MessageTo, @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}";
@ -58,7 +58,7 @@ public sealed class EmailActionHandler(RuleEventFormatter formatter) : RuleActio
smtpMessage.Body = new TextPart(TextFormat.Html)
{
Text = job.MessageBody
Text = job.MessageBody,
};
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,
FastlyApiKey = action.ApiKey,
FastlyServiceID = action.ServiceId
FastlyServiceID = action.ServiceId,
};
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),
Schema = action.Schema,
PartitionKey = await FormatAsync(action.PartitionKey, @event),
PartitionCount = action.PartitionCount
PartitionCount = action.PartitionCount,
};
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",
content = await FormatAsync(action.Content, @event),
canonicalUrl = await FormatAsync(action.CanonicalUrl, @event),
tags = await ParseTagsAsync(@event, action)
tags = await ParseTagsAsync(@event, action),
};
ruleJob.RequestBody = ToJson(requestBody);
@ -108,7 +108,7 @@ public sealed class MediumActionHandler(RuleEventFormatter formatter, IHttpClien
{
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}");

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

@ -41,7 +41,7 @@ public sealed class NotificationActionHandler(RuleEventFormatter formatter, ICol
CommentId = DomainId.NewGuid(),
CommentsId = DomainId.Create(user.Id),
FromRule = true,
Text = (await FormatAsync(action.Text, @event))!
Text = (await FormatAsync(action.Text, @event))!,
};
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(),
ServerUser = action.Username,
ServerPassword = action.Password,
ContentId = contentId
ContentId = contentId,
};
if (delete)
@ -88,8 +88,8 @@ public sealed class OpenSearchActionHandler(RuleEventFormatter formatter, IScrip
{
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")
{
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);

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.
var vars = new EventScriptVars
{
Event = @event
Event = @event,
};
return scriptEngine.Evaluate(vars, expression);

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

@ -72,5 +72,5 @@ public enum ActionTypeEnum
{
Broadcast,
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!,
MethodName = action.MethodName,
MethodPayload = requestBody!,
Targets = target.Split("\n")
Targets = target.Split("\n"),
};
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
{
RequestUrl = action.WebhookUrl.ToString(),
RequestBody = ToJson(body)
RequestBody = ToJson(body),
};
return (Description, ruleJob);
@ -37,7 +37,7 @@ public sealed class SlackActionHandler(RuleEventFormatter formatter, IHttpClient
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);

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))!,
AccessToken = action.AccessToken,
AccessSecret = action.AccessSecret
AccessSecret = action.AccessSecret,
};
return (Description, ruleJob);
@ -43,7 +43,7 @@ public sealed class TweetActionHandler(RuleEventFormatter formatter, IOptions<Tw
var request = new Dictionary<string, object>
{
["status"] = job.Text
["status"] = job.Text,
};
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",
ServerKey = action.ApiKey,
ContentId = contentId
ContentId = contentId,
};
if (delete)
@ -75,8 +75,8 @@ public sealed class TypesenseActionHandler(RuleEventFormatter formatter, IHttpCl
{
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")
{
Content = new StringContent(job.Content, Encoding.UTF8, "application/json")
Content = new StringContent(job.Content, Encoding.UTF8, "application/json"),
};
}
else

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

@ -58,5 +58,5 @@ public enum WebhookMethod
PUT,
GET,
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,
RequestBody = requestBody!,
RequestBodyType = action.PayloadType,
Headers = await ParseHeadersAsync(action.Headers, @event)
Headers = await ParseHeadersAsync(action.Headers, @event),
};
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 =
[
VisualFeatureTypes.Categories,
VisualFeatureTypes.Description,
VisualFeatureTypes.Color
VisualFeatureTypes.Color,
];
public int Order => int.MaxValue;
@ -41,7 +41,7 @@ public sealed class AzureMetadataSource : IAssetMetadataSource
{
client = new ComputerVisionClient(new ApiKeyServiceClientCredentials(options.Value.ApiKey))
{
Endpoint = options.Value.Endpoint
Endpoint = options.Value.Endpoint,
};
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.
data["referencing"] = new ContentFieldData
{
["iv"] = JsonValue.Array(content.Id)
["iv"] = JsonValue.Array(content.Id),
};
await UpdateReferencing(context, newReferenced, data, ct);
@ -90,7 +90,7 @@ public sealed class DoubleLinkedContentMiddleware(IContentLoader contentLoader)
DoNotScript = true,
DoNotValidate = true,
Data = data,
ExpectedVersion = reference.Version
ExpectedVersion = reference.Version,
}, 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)
{
["iv"] = ("iv", LexicalAnalyzerName.StandardLucene.ToString()),
["zh"] = ("zh", LexicalAnalyzerName.ZhHansLucene.ToString())
["zh"] = ("zh", LexicalAnalyzerName.ZhHansLucene.ToString()),
};
static AzureIndexDefinition()
@ -78,44 +78,44 @@ public static class AzureIndexDefinition
{
new SimpleField("docId", SearchFieldDataType.String)
{
IsKey = true
IsKey = true,
},
new SimpleField("appId", SearchFieldDataType.String)
{
IsFilterable = true
IsFilterable = true,
},
new SimpleField("appName", SearchFieldDataType.String)
{
IsFilterable = false
IsFilterable = false,
},
new SimpleField("contentId", SearchFieldDataType.String)
{
IsFilterable = false
IsFilterable = false,
},
new SimpleField("schemaId", SearchFieldDataType.String)
{
IsFilterable = true
IsFilterable = true,
},
new SimpleField("schemaName", SearchFieldDataType.String)
{
IsFilterable = false
IsFilterable = false,
},
new SimpleField("serveAll", SearchFieldDataType.Boolean)
{
IsFilterable = true
IsFilterable = true,
},
new SimpleField("servePublished", SearchFieldDataType.Boolean)
{
IsFilterable = true
IsFilterable = true,
},
new SimpleField("geoObject", SearchFieldDataType.GeographyPoint)
{
IsFilterable = true
IsFilterable = true,
},
new SimpleField("geoField", SearchFieldDataType.String)
{
IsFilterable = true
}
IsFilterable = true,
},
};
foreach (var (field, analyzer) in FieldAnalyzers.Values)
@ -125,13 +125,13 @@ public static class AzureIndexDefinition
{
IsFilterable = false,
IsFacetable = false,
AnalyzerName = analyzer
AnalyzerName = analyzer,
});
}
var index = new SearchIndex(indexName)
{
Fields = fields
Fields = fields,
};
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
{
Filter = filter
Filter = filter,
};
searchOptions.Select.Add("contentId");

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

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

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

@ -43,7 +43,7 @@ public static class CommandFactory
geoObject = new
{
lat = point.Coordinate.X,
lon = point.Coordinate.Y
lon = point.Coordinate.Y,
};
break;
}
@ -57,8 +57,8 @@ public static class CommandFactory
index = new
{
_id = upsert.ToDocId(),
_index = indexName
}
_index = indexName,
},
});
var texts = new Dictionary<string, string>();
@ -91,7 +91,7 @@ public static class CommandFactory
servePublished = upsert.ServePublished,
texts,
geoField,
geoObject
geoObject,
});
}
}
@ -103,8 +103,8 @@ public static class CommandFactory
update = new
{
_id = update.ToDocId(),
_index = indexName
}
_index = indexName,
},
});
args.Add(new
@ -112,8 +112,8 @@ public static class CommandFactory
doc = new
{
serveAll = update.ServeAll,
servePublished = update.ServePublished
}
servePublished = update.ServePublished,
},
});
}
@ -124,8 +124,8 @@ public static class CommandFactory
delete = new
{
_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",
["sv"] = "swedish",
["tr"] = "turkish",
["th"] = "thai"
["th"] = "thai",
};
static ElasticSearchIndexDefinition()
@ -103,9 +103,9 @@ public static class ElasticSearchIndexDefinition
{
["geoObject"] = new
{
type = "geo_point"
}
}
type = "geo_point",
},
},
};
foreach (var (key, analyzer) in FieldAnalyzers)
@ -113,7 +113,7 @@ public static class ElasticSearchIndexDefinition
query.properties[GetFieldPath(key)] = new
{
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>
{
["schemaId.keyword"] = query.SchemaId.ToString()
}
["schemaId.keyword"] = query.SchemaId.ToString(),
},
},
new
{
term = new Dictionary<string, string>
{
["geoField.keyword"] = query.Field
}
["geoField.keyword"] = query.Field,
},
},
new
{
term = new Dictionary<string, string>
{
[serveField] = "true"
}
[serveField] = "true",
},
},
new
{
@ -95,19 +95,19 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
geoObject = new
{
lat = query.Latitude,
lon = query.Longitude
lon = query.Longitude,
},
distance = $"{query.Radius}m"
}
}
}
}
distance = $"{query.Radius}m",
},
},
},
},
},
_source = new[]
{
"contentId"
"contentId",
},
size = query.Take
size = query.Take,
};
return await SearchAsync(elasticQuery, ct);
@ -140,32 +140,32 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
{
term = new Dictionary<string, object>
{
["appId.keyword"] = app.Id.ToString()
}
["appId.keyword"] = app.Id.ToString(),
},
},
new
{
term = new Dictionary<string, string>
{
[serveField] = "true"
}
}
[serveField] = "true",
},
},
},
must = new
{
query_string = new
{
query = parsed.Text
}
query = parsed.Text,
},
},
should = new List<object>()
}
should = new List<object>(),
},
},
_source = new[]
{
"contentId"
"contentId",
},
size = query.Take
size = query.Take,
};
if (query.RequiredSchemaIds?.Count > 0)
@ -174,8 +174,8 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
{
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);
@ -186,8 +186,8 @@ public sealed partial class ElasticSearchTextIndex(IElasticSearchClient elasticC
{
terms = new Dictionary<string, object?>
{
["schemaId.keyword"] = query.PreferredSchemaId.ToString()
}
["schemaId.keyword"] = query.PreferredSchemaId.ToString(),
},
};
elasticQuery.query.@bool.should.Add(bySchema);

28
backend/src/Migrations/MigrationPath.cs

@ -7,10 +7,10 @@
using Microsoft.Extensions.DependencyInjection;
using Migrations.Migrations;
using Migrations.Migrations.Backup;
using Migrations.Migrations.MongoDb;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Migrations;
using Squidex.Migrations.Backup;
namespace Migrations;
@ -35,25 +35,25 @@ public sealed class MigrationPath(IServiceProvider serviceProvider) : IMigration
// Version 06: Convert Event store. Must always be executed first.
if (version < 6)
{
yield return serviceProvider.GetRequiredService<ConvertEventStore>();
yield return serviceProvider.GetService<ConvertEventStore>();
}
// Version 22: Integrate Domain Id.
if (version < 22)
{
yield return serviceProvider.GetRequiredService<AddAppIdToEventStream>();
yield return serviceProvider.GetService<AddAppIdToEventStream>();
}
// Version 07: Introduces AppId for backups.
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.
if (version < 5)
{
yield return serviceProvider.GetRequiredService<RebuildSnapshots>();
yield return serviceProvider.GetService<RebuildSnapshots>();
}
else
{
@ -68,9 +68,9 @@ public sealed class MigrationPath(IServiceProvider serviceProvider) : IMigration
// Version 25: Introduce full deletion.
if (version < 25)
{
yield return serviceProvider.GetRequiredService<RebuildApps>();
yield return serviceProvider.GetRequiredService<RebuildSchemas>();
yield return serviceProvider.GetRequiredService<RebuildRules>();
yield return serviceProvider.GetService<RebuildApps>();
yield return serviceProvider.GetService<RebuildSchemas>();
yield return serviceProvider.GetService<RebuildRules>();
}
// Version 18: Rebuild assets.
@ -91,7 +91,7 @@ public sealed class MigrationPath(IServiceProvider serviceProvider) : IMigration
// Version 23: Fix parent id.
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.
if (version < 25)
{
yield return serviceProvider.GetRequiredService<RebuildContents>();
yield return serviceProvider.GetService<RebuildContents>();
}
// Version 16: Introduce file name slugs for assets.
if (version < 16)
{
yield return serviceProvider.GetRequiredService<CreateAssetSlugs>();
yield return serviceProvider.GetService<CreateAssetSlugs>();
}
}
// Version 13: Json refactoring.
if (version < 13)
{
yield return serviceProvider.GetRequiredService<ConvertRuleEventsJson>();
yield return serviceProvider.GetService<ConvertRuleEventsJson>();
}
// Version 26: New rule statistics using normal usage collection.
if (version < 26)
{
yield return serviceProvider.GetRequiredService<CopyRuleStatistics>();
yield return serviceProvider.GetService<CopyRuleStatistics>();
}
// Version 27: General jobs state.
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)
{
IsUpsert = true
IsUpsert = true,
});
}

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

@ -21,7 +21,7 @@ public sealed class ConvertDocumentIds(IMongoDatabase databaseDefault, IMongoDat
{
None,
Assets,
Contents
Contents,
}
public override string ToString()
@ -109,7 +109,7 @@ public sealed class ConvertDocumentIds(IMongoDatabase databaseDefault, IMongoDat
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_Rules",
"States_Schemas"
"States_Schemas",
}.Select(x => database.GetCollection<BsonDocument>(x));
var update = Update.Rename("State", "Doc");

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

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

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

@ -12,5 +12,5 @@ public enum AppContributorPermission
{
Owner,
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)
{
Message = Message
}
}.ToReadonlyList()
Message = Message,
},
}.ToReadonlyList(),
};
var newEvent = new AppSettingsUpdated
{
Settings = newSettings
Settings = newSettings,
};
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
{
Settings = state.Settings
Settings = state.Settings,
};
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)
{
Message = Message
}
Message = Message,
},
}.ToReadonlyList(),
Editors = state.Settings.Editors
Editors = state.Settings.Editors,
};
var newEvent = new AppSettingsUpdated
{
Settings = newSettings
Settings = newSettings,
};
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
{
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,
IsHidden = eventField.IsHidden,
IsDisabled = eventField.IsDisabled
IsDisabled = eventField.IsDisabled,
};
if (field is ArrayField arrayField && eventField.Nested?.Length > 0)
@ -62,7 +62,7 @@ public sealed class SchemaCreated : SchemaEvent, IMigrated<IEvent>
{
IsLocked = nestedEventField.IsLocked,
IsHidden = nestedEventField.IsHidden,
IsDisabled = nestedEventField.IsDisabled
IsDisabled = nestedEventField.IsDisabled,
};
arrayFields.Add(nestedField);
@ -82,7 +82,7 @@ public sealed class SchemaCreated : SchemaEvent, IMigrated<IEvent>
SchemaType.Singleton :
SchemaType.Default,
IsPublished = Publish,
FieldCollection = FieldCollection<RootField>.Create(fields.ToArray())
FieldCollection = FieldCollection<RootField>.Create(fields.ToArray()),
};
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.
// ==========================================================================
namespace Squidex.MongoDb.Domain.Contents;
namespace Squidex.Domain.Apps.Entities.Contents;
[Trait("Category", "Dependencies")]
public class ContentsQueryIntegrationTests(ContentsQueryFixture_Default fixture)
: ContentsQueryTestsBase(fixture), IClassFixture<ContentsQueryFixture_Default>
public class QueriedStatus
{
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.
// ==========================================================================
namespace Squidex.MongoDb.Domain.Contents;
namespace Squidex.Infrastructure;
[Trait("Category", "Dependencies")]
public class ContentsQueryDedicatedIntegrationTests(ContentsQueryFixture_Dedicated fixture)
: ContentsQueryTestsBase(fixture), IClassFixture<ContentsQueryFixture_Dedicated>
public interface IVersionedEntity<T>
{
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