mirror of https://github.com/Squidex/squidex.git
Browse Source
* 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
committed by
GitHub
833 changed files with 26555 additions and 4463 deletions
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
}); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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 _); |
|||
} |
|||
} |
|||
@ -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"); |
|||
}); |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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); |
|||
}); |
|||
} |
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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"); |
|||
}); |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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…
Reference in new issue