Browse Source

Run tests on all servers (#1191)

* Run tests on all servers.

* Revert schema tests.

* T

* t

* Text

* Fix.

* Add migration.

* Update tests.

* Test degrees

* Update test

* Update duration.

* Improvements.

* Add searchable text field.

* Fix whitespaces.
pull/1192/head
Sebastian Stehle 1 year ago
committed by GitHub
parent
commit
facd93a80b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 17
      backend/Squidex.sln
  2. 1
      backend/src/Squidex.Data.EntityFramework/AppDbContext.cs
  3. 9
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/EFContentBuilder.cs
  4. 2
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Extensions.cs
  5. 45
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextBuilder.cs
  6. 301
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndex.cs
  7. 34
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexGeoEntity.cs
  8. 31
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexTextEntity.cs
  9. 49
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/Extensions.cs
  10. 38
      backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/NullTextIndex.cs
  11. 14
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/DatabaseCreator.cs
  12. 14
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Migrations/DatabaseMigrator.cs
  13. 56
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/QueryExtensions.cs
  14. 25
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs
  15. 6
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlQueryBuilder.cs
  16. 1533
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Migrations/20250220163956_AddFullText.Designer.cs
  17. 74
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Migrations/20250220163956_AddFullText.cs
  18. 86
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/Migrations/MySqlDbContextModelSnapshot.cs
  19. 5
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDbContextDesignTimeFactory.cs
  20. 21
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs
  21. 1534
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/Migrations/20250220164002_AddFullText.Designer.cs
  22. 68
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/Migrations/20250220164002_AddFullText.cs
  23. 87
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/Migrations/PostgresDbContextModelSnapshot.cs
  24. 5
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDbContextDesignTimeFactory.cs
  25. 26
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs
  26. 1536
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Migrations/20250220164010_AddFullText.Designer.cs
  27. 62
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Migrations/20250220164010_AddFullText.cs
  28. 86
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs
  29. 5
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDbContextDesignTimeFactory.cs
  30. 31
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs
  31. 23
      backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs
  32. 23
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  33. 2
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.sln
  34. 6
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs
  35. 5
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs
  36. 11
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  37. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  38. 89
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Tokenizer.cs
  39. 2
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  40. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  41. 22
      backend/src/Squidex/Squidex.csproj
  42. 42
      backend/tests/Squidex.Data.Tests.CodeGenerator/Squidex.Data.Tests.CodeGenerator.csproj
  43. 23
      backend/tests/Squidex.Data.Tests.CodeGenerator/Template.handlebar
  44. 17
      backend/tests/Squidex.Data.Tests.CodeGenerator/TemplateModel.cs
  45. 77
      backend/tests/Squidex.Data.Tests.CodeGenerator/TestGenerator.cs
  46. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Apps/EFAppRepositoryTests.cs
  47. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetFolderRepositorySnapshotTests.cs
  48. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetFolderRepositoryTests.cs
  49. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetRepositorySnapshotTests.cs
  50. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetRepositoryTests.cs
  51. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/EFContentRepositorySnapshotTests.cs
  52. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/EFContentRepositoryTests.cs
  53. 25
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/Text/EFTextIndexTests.cs
  54. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/Text/EFTextIndexerStateTests.cs
  55. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/History/EFHistoryEventRepositoryTests.cs
  56. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Rules/EFRuleEventRepositoryTests.cs
  57. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Rules/EFRuleRepositoryTests.cs
  58. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Schemas/EFSchemaRepositoryTests.cs
  59. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Teams/EFTeamRepositoryTests.cs
  60. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Caching/EFDistributedCacheTests.cs
  61. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Log/EFRequestLogRepositoryTests.cs
  62. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Migrations/EFMigrationStatusTests.cs
  63. 78
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs
  64. 29
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/MySqlQueryTests.cs
  65. 29
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/PostgresQueryTests.cs
  66. 29
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/SqlServerQueryTests.cs
  67. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/States/EFSnapshotStoreTests.cs
  68. 7
      backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/UsageTracking/EFUsageRepositoryTests.cs
  69. 6
      backend/tests/Squidex.Data.Tests/EntityFramework/Migrations/MySqlMigrationTests.cs
  70. 10
      backend/tests/Squidex.Data.Tests/EntityFramework/Migrations/PostgresMigrationTests.cs
  71. 5
      backend/tests/Squidex.Data.Tests/EntityFramework/Migrations/SqlServerMigrationTests.cs
  72. 18
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/ISqlFixture.cs
  73. 24
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs
  74. 18
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs
  75. 38
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs
  76. 29
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestDbContext.cs
  77. 5
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs
  78. 4
      backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/AtlasParsingTests.cs
  79. 22
      backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/AtlasTextIndexTests.cs
  80. 16
      backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexTests.cs
  81. 2
      backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexerStateTests.cs
  82. 2
      backend/tests/Squidex.Data.Tests/MongoDb/Infrastructure/EventSourcing/MongoEventConsumerProcessorIntegrationTests_Direct.cs
  83. 2
      backend/tests/Squidex.Data.Tests/MongoDb/Infrastructure/EventSourcing/MongoEventStoreParallelInsertTests.cs
  84. 2
      backend/tests/Squidex.Data.Tests/Squidex.Data.Tests.csproj
  85. 5
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs
  86. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/AzureTextIndexTests.cs
  87. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexTests.cs
  88. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexTests.cs
  89. 271
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests.cs
  90. 9
      backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs
  91. 23
      backend/tests/mssql/Dockerfile
  92. 4
      tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs
  93. 1
      tools/TestSuite/TestSuite.ApiTests/ContentQueryFixture.cs
  94. 10
      tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs
  95. 4
      tools/TestSuite/TestSuite.ApiTests/Verify/AssetFormatTests.Should_upload_audio_mp3.verified.txt
  96. 13
      tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs
  97. 15
      tools/TestSuite/docker-compose.yml
  98. 2
      tools/TestSuite/sql-server/Dockerfile

17
backend/Squidex.sln

@ -56,7 +56,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.MongoDb", "src
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.Tests", "tests\Squidex.Data.Tests\Squidex.Data.Tests.csproj", "{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Data.EntityFramework", "src\Squidex.Data.EntityFramework\Squidex.Data.EntityFramework.csproj", "{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Data.EntityFramework", "src\Squidex.Data.EntityFramework\Squidex.Data.EntityFramework.csproj", "{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Squidex.Data.Tests.CodeGenerator", "tests\Squidex.Data.Tests.CodeGenerator\Squidex.Data.Tests.CodeGenerator.csproj", "{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -276,6 +278,18 @@ Global
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|x64.Build.0 = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|x86.ActiveCfg = Release|Any CPU
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA}.Release|x86.Build.0 = Release|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Debug|x64.ActiveCfg = Debug|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Debug|x64.Build.0 = Debug|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Debug|x86.ActiveCfg = Debug|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Debug|x86.Build.0 = Debug|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Release|Any CPU.Build.0 = Release|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Release|x64.ActiveCfg = Release|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Release|x64.Build.0 = Release|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Release|x86.ActiveCfg = Release|Any CPU
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -299,6 +313,7 @@ Global
{F754F05E-02FF-47B2-AB46-BB05C7E6B29D} = {3378B841-53F8-48CC-87C1-1B30CF912BFD}
{AA2F3C32-E3C8-4DF3-A365-F25C7EC19BCD} = {3378B841-53F8-48CC-87C1-1B30CF912BFD}
{0348CFDA-4DA1-4DB5-9C6F-0D234FE3E4DA} = {3378B841-53F8-48CC-87C1-1B30CF912BFD}
{2E4CC21D-3AB9-4B8C-A13E-348AA3E2F328} = {3378B841-53F8-48CC-87C1-1B30CF912BFD}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {02F2E872-3141-44F5-BD6A-33CD84E9FE08}

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

@ -52,6 +52,7 @@ public class AppDbContext(DbContextOptions options, IJsonSerializer jsonSerializ
builder.UseSettings(jsonSerializer, jsonColumnType);
builder.UseTags(jsonSerializer, jsonColumnType);
builder.UseTeams(jsonSerializer, jsonColumnType);
builder.UseTextIndex();
builder.UseUsage();
builder.UseUsageTracking(jsonSerializer, jsonColumnType);
builder.UseYDotNet();

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

@ -6,7 +6,6 @@
// ==========================================================================
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
@ -16,14 +15,6 @@ 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");

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

@ -35,7 +35,7 @@ internal static class Extensions
public static void LimitFields(this ContentData data, IReadOnlySet<string> fields)
{
List<string>? toDelete = null;
foreach (var (key, value) in data)
foreach (var (key, _) in data)
{
if (!fields.Any(x => IsMatch(key, x)))
{

45
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextBuilder.cs

@ -0,0 +1,45 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
using Squidex.Infrastructure;
namespace Microsoft.EntityFrameworkCore;
public static class EFTextBuilder
{
public static void UseTextIndex(this ModelBuilder builder)
{
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.Entity<EFTextIndexTextEntity>(b =>
{
b.ToTable("Texts");
b.Property(x => x.Id).HasMaxLength(400);
b.Property(x => x.AppId).AsString();
b.Property(x => x.SchemaId).AsString();
b.Property(x => x.ContentId).AsString();
});
builder.Entity<EFTextIndexGeoEntity>(b =>
{
b.ToTable("Geos");
b.Property(x => x.Id).HasMaxLength(400);
b.Property(x => x.AppId).AsString();
b.Property(x => x.SchemaId).AsString();
b.Property(x => x.ContentId).AsString();
b.Property(x => x.GeoField).HasMaxLength(255);
});
}
}

301
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndex.cs

@ -0,0 +1,301 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using EFCore.BulkExtensions;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using NetTopologySuite.Geometries;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.SqlServer;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class EFTextIndex<TContext>(IDbContextFactory<TContext> dbContextFactory, SqlDialect dialect)
: ITextIndex, IInitializable, IDeleter where TContext : DbContext
{
private record struct SearchOperation
{
required public App App { get; init; }
required public List<(DomainId Id, double Score)> Results { get; init; }
required public string SearchTerms { get; init; }
required public int Take { get; set; }
required public SearchScope SearchScope { get; init; }
}
public async Task InitializeAsync(
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Database.CreateTextIndexAsync(dialect, "IDX_Text", "Texts", "Texts", ct);
await dbContext.Database.CreateGeoIndexAsync(dialect, "IDX_Geo", "Geos", "GeoObject", ct);
}
async Task IDeleter.DeleteAppAsync(App app,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFTextIndexTextEntity>().Where(x => x.AppId == app.Id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFTextIndexGeoEntity>().Where(x => x.AppId == app.Id)
.ExecuteDeleteAsync(ct);
}
async Task IDeleter.DeleteSchemaAsync(App app, Schema schema,
CancellationToken ct)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFTextIndexTextEntity>().Where(x => x.AppId == app.Id && x.SchemaId == schema.Id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFTextIndexGeoEntity>().Where(x => x.AppId == app.Id && x.SchemaId == schema.Id)
.ExecuteDeleteAsync(ct);
}
public async Task ClearAsync(CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
await dbContext.Set<EFTextIndexTextEntity>()
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFTextIndexGeoEntity>()
.ExecuteDeleteAsync(ct);
}
public async Task<List<DomainId>?> SearchAsync(App app, GeoQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
await using var dbContext = await CreateDbContextAsync(ct);
var point = new Point(query.Longitude, query.Latitude) { SRID = 4326 };
// The distance must be converted to decrees (in contrast to MongoDB, which uses radian).
var degrees = query.Radius / 111320;
var ids =
await dbContext.Set<EFTextIndexGeoEntity>()
.Where(x => x.AppId == app.Id)
.Where(x => x.SchemaId == query.SchemaId)
.Where(x => x.GeoField == query.Field)
.Where(x => x.GeoObject.Distance(point) < degrees)
.WhereScope(scope)
.Select(x => x.ContentId)
.ToListAsync(ct);
return ids;
}
public async Task<List<DomainId>?> SearchAsync(App app, TextQuery query, SearchScope scope,
CancellationToken ct = default)
{
Guard.NotNull(app);
Guard.NotNull(query);
if (string.IsNullOrWhiteSpace(query.Text))
{
return null;
}
// Use a custom tokenizer to leverage stop words from multiple languages.
var search = new SearchOperation
{
App = app,
SearchTerms = Tokenizer.Query(query.Text),
SearchScope = scope,
Results = [],
Take = query.Take,
};
await using var dbContext = await CreateDbContextAsync(ct);
if (query.RequiredSchemaIds?.Count > 0)
{
await SearchBySchemaAsync(dbContext, search, query.RequiredSchemaIds, 1, ct);
}
else if (query.PreferredSchemaId == null)
{
await SearchByAppAsync(dbContext, search, 1, ct);
}
else
{
// We cannot write queries that prefer results from the same schema, therefore make two queries.
search.Take /= 2;
// Increasing the scoring of the results from the schema by 10 percent.
await SearchBySchemaAsync(dbContext, search, Enumerable.Repeat(query.PreferredSchemaId.Value, 1), 1.1, ct);
await SearchByAppAsync(dbContext, search, 1, ct);
}
return search.Results.OrderByDescending(x => x.Score).Select(x => x.Id).Distinct().ToList();
}
private Task SearchBySchemaAsync(TContext context, SearchOperation search, IEnumerable<DomainId> schemaIds, double factor,
CancellationToken ct = default)
{
var queryBuilder =
new SqlQueryBuilder(dialect, "Texts")
.Where(ClrFilter.Eq("AppId", search.App.Id))
.Where(ClrFilter.In("SchemaId", schemaIds.ToList()))
.WhereMatch("Texts", search.SearchTerms)
.WhereScope(search.SearchScope);
return SearchAsync(context, search, queryBuilder, factor, ct);
}
private Task SearchByAppAsync(TContext context, SearchOperation search, double factor,
CancellationToken ct = default)
{
var queryBuilder =
new SqlQueryBuilder(dialect, "Texts")
.Where(ClrFilter.Eq("AppId", search.App.Id))
.WhereMatch("Texts", search.SearchTerms)
.WhereScope(search.SearchScope);
return SearchAsync(context, search, queryBuilder, factor, ct);
}
private static async Task SearchAsync(TContext context, SearchOperation search, SqlQueryBuilder queryBuilder, double factor,
CancellationToken ct)
{
var (sql, parameters) = queryBuilder.Compile();
var ids =
await context.Set<EFTextIndexTextEntity>().FromSqlRaw(sql, parameters)
.Select(x => x.ContentId)
.ToListAsync(ct);
search.Results.AddRange(ids.Select(x => (x, 1 * factor)));
}
public async Task ExecuteAsync(IndexCommand[] commands,
CancellationToken ct = default)
{
await using var dbContext = await CreateDbContextAsync(ct);
var insertsText = new List<EFTextIndexTextEntity>();
var insertsGeo = new List<EFTextIndexGeoEntity>();
foreach (var batch in commands.Batch(1000))
{
foreach (var command in batch)
{
var (appId, contentId) = command.UniqueContentId;
var id = $"{appId}_{contentId}_{command.Stage}";
switch (command)
{
case UpsertIndexEntry upsert:
if (upsert.Texts != null)
{
insertsText.Add(new EFTextIndexTextEntity
{
Id = id,
AppId = appId,
ContentId = contentId,
SchemaId = upsert.SchemaId.Id,
ServeAll = upsert.ServeAll,
ServePublished = upsert.ServePublished,
Stage = upsert.Stage,
Texts = Tokenizer.Terms(upsert.Texts),
});
}
foreach (var (field, obj) in upsert.GeoObjects.OrEmpty())
{
obj.SRID = 4326;
if (!obj.IsValid)
{
continue;
}
var entity = new EFTextIndexGeoEntity
{
Id = id,
AppId = appId,
ContentId = contentId,
GeoField = field,
GeoObject = obj,
SchemaId = upsert.SchemaId.Id,
ServeAll = upsert.ServeAll,
ServePublished = upsert.ServePublished,
Stage = upsert.Stage,
};
// We can only check the validatity by inserting them one by one.
if (dialect is SqlServerDialect)
{
try
{
await dbContext.Set<EFTextIndexGeoEntity>().AddAsync(entity, ct);
await dbContext.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.InnerException is SqlException { Number: 8023 })
{
// Geo object is not valid.
dbContext.Entry(entity).State = EntityState.Detached;
}
}
else
{
insertsGeo.Add(entity);
}
}
break;
case DeleteIndexEntry:
await dbContext.Set<EFTextIndexTextEntity>()
.Where(x => x.Id == id)
.ExecuteDeleteAsync(ct);
await dbContext.Set<EFTextIndexGeoEntity>()
.Where(x => x.Id == id)
.ExecuteDeleteAsync(ct);
break;
case UpdateIndexEntry update:
await dbContext.Set<EFTextIndexTextEntity>()
.Where(x => x.Id == id)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.ServeAll, update.ServeAll)
.SetProperty(x => x.ServePublished, update.ServePublished),
ct);
await dbContext.Set<EFTextIndexGeoEntity>()
.Where(x => x.Id == id)
.ExecuteUpdateAsync(u => u
.SetProperty(x => x.ServeAll, update.ServeAll)
.SetProperty(x => x.ServePublished, update.ServePublished),
ct);
break;
}
}
}
await dbContext.BulkInsertOrUpdateAsync(insertsText, cancellationToken: ct);
await dbContext.BulkInsertOrUpdateAsync(insertsGeo, cancellationToken: ct);
}
private Task<TContext> CreateDbContextAsync(CancellationToken ct)
{
return dbContextFactory.CreateDbContextAsync(ct);
}
}

34
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexGeoEntity.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using NetTopologySuite.Geometries;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class EFTextIndexGeoEntity
{
[Key]
required public string Id { get; set; }
public DomainId AppId { get; set; }
public DomainId SchemaId { get; set; }
public DomainId ContentId { get; set; }
public byte Stage { get; set; }
public bool ServeAll { get; set; }
public bool ServePublished { get; set; }
public string GeoField { get; set; }
public Geometry GeoObject { get; set; }
}

31
backend/src/Squidex.Data.EntityFramework/Domain/Apps/Entities/Contents/Text/EFTextIndexTextEntity.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public sealed class EFTextIndexTextEntity
{
[Key]
required public string Id { get; set; }
public DomainId AppId { get; set; }
public DomainId SchemaId { get; set; }
public DomainId ContentId { get; set; }
public byte Stage { get; set; }
public bool ServeAll { get; set; }
public bool ServePublished { get; set; }
public string Texts { get; set; }
}

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

@ -0,0 +1,49 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Queries;
namespace Squidex.Domain.Apps.Entities.Contents.Text;
internal static class Extensions
{
public static SqlQueryBuilder WhereScope(this SqlQueryBuilder queryBuilder, SearchScope scope)
{
if (scope == SearchScope.All)
{
return queryBuilder.Where(ClrFilter.Eq("ServeAll", true));
}
else
{
return queryBuilder.Where(ClrFilter.Eq("ServePublished", true));
}
}
public static IQueryable<EFTextIndexTextEntity> WhereScope(this IQueryable<EFTextIndexTextEntity> query, SearchScope scope)
{
if (scope == SearchScope.All)
{
return query.Where(x => x.ServeAll);
}
else
{
return query.Where(x => x.ServePublished);
}
}
public static IQueryable<EFTextIndexGeoEntity> WhereScope(this IQueryable<EFTextIndexGeoEntity> query, SearchScope scope)
{
if (scope == SearchScope.All)
{
return query.Where(x => x.ServeAll);
}
else
{
return query.Where(x => x.ServePublished);
}
}
}

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

@ -1,38 +0,0 @@
// ==========================================================================
// 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);
}
}

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

@ -10,19 +10,29 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Squidex.Hosting;
#pragma warning disable RECS0108 // Warns about static fields in generic types
namespace Squidex.Infrastructure.Migrations;
public sealed class DatabaseCreator<TContext>(IDbContextFactory<TContext> dbContextFactory) : IInitializable
where TContext : DbContext
{
private static readonly TimeSpan WaitTime = TimeSpan.FromSeconds(30);
public int Order => -1000;
public async Task InitializeAsync(
CancellationToken ct)
{
await using var context = await dbContextFactory.CreateDbContextAsync(ct);
await using var dbContext = await dbContextFactory.CreateDbContextAsync(ct);
using var cts = new CancellationTokenSource(WaitTime);
while (!await dbContext.Database.CanConnectAsync(cts.Token))
{
await Task.Delay(100, cts.Token);
}
if (context.Database.GetService<IDatabaseCreator>() is not RelationalDatabaseCreator relationalDatabaseCreator)
if (dbContext.Database.GetService<IDatabaseCreator>() is not RelationalDatabaseCreator relationalDatabaseCreator)
{
return;
}

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

@ -8,18 +8,28 @@
using Microsoft.EntityFrameworkCore;
using Squidex.Hosting;
#pragma warning disable RECS0108 // Warns about static fields in generic types
namespace Squidex.Infrastructure.Migrations;
public sealed class DatabaseMigrator<TContext>(IDbContextFactory<TContext> dbContextFactory) : IInitializable
where TContext : DbContext
{
private static readonly TimeSpan WaitTime = TimeSpan.FromSeconds(30);
public int Order => -1000;
public async Task InitializeAsync(
CancellationToken ct)
{
await using var context = await dbContextFactory.CreateDbContextAsync(ct);
await using var dbContext = await dbContextFactory.CreateDbContextAsync(ct);
using var cts = new CancellationTokenSource(WaitTime);
while (!await dbContext.Database.CanConnectAsync(cts.Token))
{
await Task.Delay(100, cts.Token);
}
await context.Database.MigrateAsync(ct);
await dbContext.Database.MigrateAsync(ct);
}
}

56
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/QueryExtensions.cs

@ -0,0 +1,56 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace Squidex.Infrastructure.Queries;
public static class QueryExtensions
{
public static async Task CreateGeoIndexAsync(this DatabaseFacade database, SqlDialect dialect, string name, string table, string column,
CancellationToken ct = default)
{
var sql = dialect.GeoIndex(name, table, column);
try
{
await database.ExecuteSqlRawAsync(sql, ct);
}
catch (Exception ex) when (dialect.IsDuplicateIndexException(ex, name))
{
// NOOP
}
}
public static async Task CreateTextIndexAsync(this DatabaseFacade database, SqlDialect dialect, string name, string table, string column,
CancellationToken ct = default)
{
var prepareSql = dialect.TextIndexPrepare(name);
if (!string.IsNullOrWhiteSpace(prepareSql))
{
try
{
await database.ExecuteSqlRawAsync(prepareSql, ct);
}
catch (Exception ex) when (dialect.IsDuplicateIndexException(ex, name))
{
// NOOP
}
}
var sql = dialect.TextIndex(name, table, column);
try
{
await database.ExecuteSqlRawAsync(sql, ct);
}
catch (Exception ex) when (dialect.IsDuplicateIndexException(ex, name))
{
// NOOP
}
}
}

25
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlDialect.cs

@ -14,6 +14,11 @@ public class SqlDialect
{
private const string Tab = " ";
public virtual bool IsDuplicateIndexException(Exception exception, string name)
{
return false;
}
public virtual string BuildSelectStatement(SqlQuery request)
{
var sb = new StringBuilder("SELECT");
@ -47,6 +52,21 @@ public class SqlDialect
return sb.ToString();
}
public virtual string GeoIndex(string name, string table, string field)
{
throw new NotSupportedException();
}
public virtual string TextIndex(string name, string table, string field)
{
throw new NotSupportedException();
}
public virtual string TextIndexPrepare(string name)
{
return string.Empty;
}
public virtual string FormatLimitOffset(long limit, long offset, bool hasOrder)
{
var hasLimit = limit > 0 && limit < long.MaxValue;
@ -119,6 +139,11 @@ public class SqlDialect
return $"{FormatField(path, isJson)} {FormatOperator(op, ClrValue.Null)} ({query})";
}
public virtual string WhereMatch(PropertyPath path, string query, SqlParams queryParameters)
{
throw new NotSupportedException();
}
protected virtual string FormatValues(CompareOperator op, ClrValue value, SqlParams queryParameters)
{
if (!value.IsList && value.ValueType == ClrValueType.Null)

6
backend/src/Squidex.Data.EntityFramework/Infrastructure/Queries/SqlQueryBuilder.cs

@ -35,6 +35,12 @@ public class SqlQueryBuilder(SqlDialect dialect, string table, SqlParams? parame
return this;
}
public SqlQueryBuilder WhereMatch(PropertyPath path, string query)
{
sqlQuery.Where.Add(dialect.WhereMatch(Visit(path), query, sqlParameters));
return this;
}
public SqlQueryBuilder Order(PropertyPath path, SortOrder order)
{
sqlQuery.Order.Add(dialect.OrderBy(Visit(path), order, IsJsonPath(path)));

1533
backend/src/Squidex.Data.EntityFramework/Providers/MySql/Migrations/20250220163956_AddFullText.Designer.cs

File diff suppressed because it is too large

74
backend/src/Squidex.Data.EntityFramework/Providers/MySql/Migrations/20250220163956_AddFullText.cs

@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;
#nullable disable
namespace Squidex.Providers.MySql.Migrations
{
/// <inheritdoc />
public partial class AddFullText : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Geos",
columns: table => new
{
Id = table.Column<string>(type: "varchar(400)", maxLength: 400, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AppId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SchemaId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContentId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Stage = table.Column<byte>(type: "tinyint unsigned", nullable: false),
ServeAll = table.Column<bool>(type: "tinyint(1)", nullable: false),
ServePublished = table.Column<bool>(type: "tinyint(1)", nullable: false),
GeoField = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
GeoObject = table.Column<Geometry>(type: "geometry", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Geos", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Texts",
columns: table => new
{
Id = table.Column<string>(type: "varchar(400)", maxLength: 400, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AppId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
SchemaId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ContentId = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Stage = table.Column<byte>(type: "tinyint unsigned", nullable: false),
ServeAll = table.Column<bool>(type: "tinyint(1)", nullable: false),
ServePublished = table.Column<bool>(type: "tinyint(1)", nullable: false),
Texts = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_Texts", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Geos");
migrationBuilder.DropTable(
name: "Texts");
}
}
}

86
backend/src/Squidex.Data.EntityFramework/Providers/MySql/Migrations/MySqlDbContextModelSnapshot.cs

@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NetTopologySuite.Geometries;
using Squidex.Providers.MySql;
#nullable disable
@ -17,7 +18,7 @@ namespace Squidex.Providers.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.12")
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -763,6 +764,89 @@ namespace Squidex.Providers.MySql.Migrations
b.ToTable("ContentReferencesPublished", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexGeoEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("varchar(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("GeoField")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<Geometry>("GeoObject")
.IsRequired()
.HasColumnType("geometry");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<bool>("ServeAll")
.HasColumnType("tinyint(1)");
b.Property<bool>("ServePublished")
.HasColumnType("tinyint(1)");
b.Property<byte>("Stage")
.HasColumnType("tinyint unsigned");
b.HasKey("Id");
b.ToTable("Geos", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("varchar(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<bool>("ServeAll")
.HasColumnType("tinyint(1)");
b.Property<bool>("ServePublished")
.HasColumnType("tinyint(1)");
b.Property<byte>("Stage")
.HasColumnType("tinyint unsigned");
b.Property<string>("Texts")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("Texts", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b =>
{
b.Property<string>("UniqueContentId")

5
backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDbContextDesignTimeFactory.cs

@ -19,7 +19,10 @@ public sealed class MySqlDbContextDesignTimeFactory : IDesignTimeDbContextFactor
const string ConnectionString = "Server=localhost;Port=33060;Database=test;User=mysql;Password=mysql";
var builder = new DbContextOptionsBuilder<MySqlDbContext>()
.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString));
.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString), options =>
{
options.UseNetTopologySuite();
});
return new MySqlDbContext(builder.Options, new SystemJsonSerializer(JsonSerializerOptions.Default));
}

21
backend/src/Squidex.Data.EntityFramework/Providers/MySql/MySqlDialect.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using MySqlConnector;
using Squidex.Infrastructure.Queries;
namespace Squidex.Providers.MySql;
@ -17,6 +18,21 @@ public sealed class MySqlDialect : SqlDialect
{
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is MySqlException ex && ex.Number == 1061;
}
public override string GeoIndex(string name, string table, string field)
{
return $"CREATE SPATIAL INDEX {name} ON {FormatTable(table)} ({FormatField(field, false)});";
}
public override string TextIndex(string name, string table, string field)
{
return $"CREATE FULLTEXT INDEX {name} ON {FormatTable(table)} ({FormatField(field, false)});";
}
public override string FormatLimitOffset(long limit, long offset, bool hasOrder)
{
var hasLimit = limit > 0 && limit < long.MaxValue;
@ -70,6 +86,11 @@ public sealed class MySqlDialect : SqlDialect
return base.OrderBy(path, order, isJson);
}
public override string WhereMatch(PropertyPath path, string query, SqlParams queryParameters)
{
return $"MATCH({FormatField(path, false)}) AGAINST({queryParameters.AddPositional(query)})";
}
public override string Where(PropertyPath path, CompareOperator op, ClrValue value, SqlParams queryParameters, bool isJson)
{
if (isJson)

1534
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/Migrations/20250220164002_AddFullText.Designer.cs

File diff suppressed because it is too large

68
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/Migrations/20250220164002_AddFullText.cs

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;
#nullable disable
namespace Squidex.Providers.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddFullText : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:postgis", ",,");
migrationBuilder.CreateTable(
name: "Geos",
columns: table => new
{
Id = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
AppId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
SchemaId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ContentId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Stage = table.Column<byte>(type: "smallint", nullable: false),
ServeAll = table.Column<bool>(type: "boolean", nullable: false),
ServePublished = table.Column<bool>(type: "boolean", nullable: false),
GeoField = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
GeoObject = table.Column<Geometry>(type: "geometry", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Geos", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Texts",
columns: table => new
{
Id = table.Column<string>(type: "character varying(400)", maxLength: 400, nullable: false),
AppId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
SchemaId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ContentId = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Stage = table.Column<byte>(type: "smallint", nullable: false),
ServeAll = table.Column<bool>(type: "boolean", nullable: false),
ServePublished = table.Column<bool>(type: "boolean", nullable: false),
Texts = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Texts", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Geos");
migrationBuilder.DropTable(
name: "Texts");
migrationBuilder.AlterDatabase()
.OldAnnotation("Npgsql:PostgresExtension:postgis", ",,");
}
}
}

87
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/Migrations/PostgresDbContextModelSnapshot.cs

@ -3,6 +3,7 @@ using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NetTopologySuite.Geometries;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Squidex.Providers.Postgres;
@ -17,9 +18,10 @@ namespace Squidex.Providers.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.12")
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
@ -763,6 +765,89 @@ namespace Squidex.Providers.Postgres.Migrations
b.ToTable("ContentReferencesPublished", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexGeoEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("GeoField")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Geometry>("GeoObject")
.IsRequired()
.HasColumnType("geometry");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("ServeAll")
.HasColumnType("boolean");
b.Property<bool>("ServePublished")
.HasColumnType("boolean");
b.Property<byte>("Stage")
.HasColumnType("smallint");
b.HasKey("Id");
b.ToTable("Geos", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("ServeAll")
.HasColumnType("boolean");
b.Property<bool>("ServePublished")
.HasColumnType("boolean");
b.Property<byte>("Stage")
.HasColumnType("smallint");
b.Property<string>("Texts")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Texts", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b =>
{
b.Property<string>("UniqueContentId")

5
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDbContextDesignTimeFactory.cs

@ -19,7 +19,10 @@ public sealed class PostgresDbContextDesignTimeFactory : IDesignTimeDbContextFac
const string ConnectionString = "Server=localhost;Port=54320;Database=test;User=postgres;Password=postgres";
var builder = new DbContextOptionsBuilder<PostgresDbContext>()
.UseNpgsql(ConnectionString);
.UseNpgsql(ConnectionString, options =>
{
options.UseNetTopologySuite();
});
return new PostgresDbContext(builder.Options, new SystemJsonSerializer(JsonSerializerOptions.Default));
}

26
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/PostgresDialect.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Npgsql;
using Squidex.Infrastructure.Queries;
namespace Squidex.Providers.Postgres;
@ -17,6 +18,21 @@ public class PostgresDialect : SqlDialect
{
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is PostgresException ex && ex.SqlState == "42P07";
}
public override string GeoIndex(string name, string table, string field)
{
return $"CREATE INDEX {name} ON {FormatTable(table)} USING GIST ({FormatField(field, false)});";
}
public override string TextIndex(string name, string table, string field)
{
return $"CREATE INDEX {name} ON {FormatTable(table)} USING GIN (to_tsvector('simple', {FormatField(field, false)}));";
}
protected override string FormatTable(string tableName)
{
return $"\"{tableName}\"";
@ -35,6 +51,16 @@ public class PostgresDialect : SqlDialect
return base.OrderBy(path, order, isJson);
}
public override string WhereMatch(PropertyPath path, string query, SqlParams queryParameters)
{
if (query.Contains(' ', StringComparison.OrdinalIgnoreCase))
{
query = query.Replace(" ", " & ", StringComparison.Ordinal);
}
return $"to_tsvector('simple', {FormatField(path, false)}) @@ to_tsquery({queryParameters.AddPositional(query)})";
}
public override string Where(PropertyPath path, CompareOperator op, ClrValue value, SqlParams queryParameters, bool isJson)
{
if (isJson)

1536
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Migrations/20250220164010_AddFullText.Designer.cs

File diff suppressed because it is too large

62
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Migrations/20250220164010_AddFullText.cs

@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NetTopologySuite.Geometries;
#nullable disable
namespace Squidex.Providers.SqlServer.Migrations
{
/// <inheritdoc />
public partial class AddFullText : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Geos",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
AppId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
SchemaId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
ContentId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
Stage = table.Column<byte>(type: "tinyint", nullable: false),
ServeAll = table.Column<bool>(type: "bit", nullable: false),
ServePublished = table.Column<bool>(type: "bit", nullable: false),
GeoField = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
GeoObject = table.Column<Geometry>(type: "geography", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Geos", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Texts",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(400)", maxLength: 400, nullable: false),
AppId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
SchemaId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
ContentId = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
Stage = table.Column<byte>(type: "tinyint", nullable: false),
ServeAll = table.Column<bool>(type: "bit", nullable: false),
ServePublished = table.Column<bool>(type: "bit", nullable: false),
Texts = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Texts", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Geos");
migrationBuilder.DropTable(
name: "Texts");
}
}
}

86
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/Migrations/SqlServerDbContextModelSnapshot.cs

@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NetTopologySuite.Geometries;
using Squidex.Providers.SqlServer;
#nullable disable
@ -17,7 +18,7 @@ namespace Squidex.Providers.SqlServer.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.12")
.HasAnnotation("ProductVersion", "8.0.13")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
@ -766,6 +767,89 @@ namespace Squidex.Providers.SqlServer.Migrations
b.ToTable("ContentReferencesPublished", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexGeoEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("GeoField")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<Geometry>("GeoObject")
.IsRequired()
.HasColumnType("geography");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("ServeAll")
.HasColumnType("bit");
b.Property<bool>("ServePublished")
.HasColumnType("bit");
b.Property<byte>("Stage")
.HasColumnType("tinyint");
b.HasKey("Id");
b.ToTable("Geos", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.EFTextIndexTextEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(400)
.HasColumnType("nvarchar(400)");
b.Property<string>("AppId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("ContentId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("SchemaId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<bool>("ServeAll")
.HasColumnType("bit");
b.Property<bool>("ServePublished")
.HasColumnType("bit");
b.Property<byte>("Stage")
.HasColumnType("tinyint");
b.Property<string>("Texts")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("Texts", (string)null);
});
modelBuilder.Entity("Squidex.Domain.Apps.Entities.Contents.Text.State.TextContentState", b =>
{
b.Property<string>("UniqueContentId")

5
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDbContextDesignTimeFactory.cs

@ -19,7 +19,10 @@ public sealed class SqlServerDbContextDesignTimeFactory : IDesignTimeDbContextFa
const string ConnectionString = "Server=localhost;Port=14330;Database=test;User=sa;Password=sqlserver";
var builder = new DbContextOptionsBuilder<SqlServerDbContext>()
.UseSqlServer(ConnectionString);
.UseSqlServer(ConnectionString, options =>
{
options.UseNetTopologySuite();
});
return new SqlServerDbContext(builder.Options, new SystemJsonSerializer(JsonSerializerOptions.Default));
}

31
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/SqlServerDialect.cs

@ -6,6 +6,7 @@
// ==========================================================================
using Squidex.Infrastructure.Queries;
using SqlException = Microsoft.Data.SqlClient.SqlException;
namespace Squidex.Providers.SqlServer;
@ -17,6 +18,26 @@ public sealed class SqlServerDialect : SqlDialect
{
}
public override bool IsDuplicateIndexException(Exception exception, string name)
{
return exception is SqlException ex && ex.Number is 1913 or 7642 or 7652;
}
public override string GeoIndex(string name, string table, string field)
{
return $"CREATE SPATIAL INDEX {name} ON {FormatTable(table)} ({FormatField(field, false)}) USING GEOGRAPHY_GRID;";
}
public override string TextIndex(string name, string table, string field)
{
return $"CREATE FULLTEXT INDEX ON {FormatTable(table)} ({FormatField(field, false)}) KEY INDEX PK_{table} ON {name} WITH CHANGE_TRACKING AUTO;";
}
public override string TextIndexPrepare(string name)
{
return $"CREATE FULLTEXT CATALOG {name};";
}
public override string FormatLimitOffset(long limit, long offset, bool hasOrder)
{
var hasLimit = limit > 0 && limit < long.MaxValue;
@ -52,6 +73,16 @@ public sealed class SqlServerDialect : SqlDialect
return base.OrderBy(path, order, isJson);
}
public override string WhereMatch(PropertyPath path, string query, SqlParams queryParameters)
{
if (query.Contains(' ', StringComparison.OrdinalIgnoreCase))
{
query = $"\"{query}\"";
}
return $"CONTAINS({FormatField(path, false)}, {queryParameters.AddPositional(query)})";
}
public override string Where(PropertyPath path, CompareOperator op, ClrValue value, SqlParams queryParameters, bool isJson)
{
if (isJson)

23
backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs

@ -11,7 +11,6 @@ using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Squidex.AI;
using Squidex.Assets.TusAdapter;
using Squidex.Domain.Apps.Core.Apps;
@ -71,9 +70,10 @@ public static class ServiceExtensions
ServerVersion.AutoDetect(connectionString);
builder.ConfigureWarnings(w => w.Ignore(CoreEventId.CollectionWithoutComparer));
builder.UseMySql(connectionString, version, mysql =>
builder.UseMySql(connectionString, version, options =>
{
mysql.UseMicrosoftJson(MySqlCommonJsonChangeTrackingOptions.FullHierarchyOptimizedSemantically);
options.UseNetTopologySuite();
options.UseMicrosoftJson(MySqlCommonJsonChangeTrackingOptions.FullHierarchyOptimizedSemantically);
});
});
@ -86,7 +86,10 @@ public static class ServiceExtensions
services.AddDbContextFactory<PostgresDbContext>(builder =>
{
builder.ConfigureWarnings(w => w.Ignore(CoreEventId.CollectionWithoutComparer));
builder.UseNpgsql(connectionString);
builder.UseNpgsql(connectionString, options =>
{
options.UseNetTopologySuite();
});
});
services.AddSingleton(typeof(ISnapshotStore<>), typeof(PostgresSnapshotStore<>));
@ -98,13 +101,16 @@ public static class ServiceExtensions
services.AddDbContextFactory<SqlServerDbContext>(builder =>
{
builder.ConfigureWarnings(w => w.Ignore(CoreEventId.CollectionWithoutComparer));
builder.UseSqlServer(connectionString);
builder.UseSqlServer(connectionString, options =>
{
options.UseNetTopologySuite();
});
});
services.AddSingleton(typeof(ISnapshotStore<>), typeof(SqlServerSnapshotStore<>));
services.AddSingleton(SqlServerDialect.Instance);
services.AddSquidexEntityFramework<SqlServerDbContext>(config);
}
},
});
}
@ -176,14 +182,15 @@ public static class ServiceExtensions
services.AddSingletonAs<EFTextIndexerState<TContext>>()
.As<ITextIndexerState>().As<IDeleter>();
services.AddSingletonAs<EFTextIndex<TContext>>()
.As<ITextIndex>().As<IDeleter>();
services.AddSingletonAs<EFUsageRepository<TContext>>()
.As<IUsageRepository>();
services.AddSingletonAs<EFUserFactory>()
.As<IUserFactory>();
services.TryAddSingleton<ITextIndex, NullTextIndex>();
services.AddEntityFrameworkAssetKeyValueStore<TContext, TusMetadata>();
}

23
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -16,7 +16,6 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="EFCore.BulkExtensions" Version="8.1.2" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.179">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -26,19 +25,23 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.12" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.13" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite" Version="8.0.12" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.12" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="8.0.11" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.Json.Microsoft" Version="8.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.NetTopologySuite" Version="8.0.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="6.32.0" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="6.32.0" />
<PackageReference Include="Squidex.Hosting" Version="6.32.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="6.32.0" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="6.33.0" />
<PackageReference Include="Squidex.EFCore.BulkExtensions" Version="8.1.6" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="6.33.0" />
<PackageReference Include="Squidex.Hosting" Version="6.33.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="6.33.0" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

2
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.sln

@ -19,6 +19,6 @@ Global
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6AB9E82C-07C3-4D4E-9CB2-AB42853A1CCB}
SolutionGuid = {15141572-3832-432F-8D94-EDB1160B525E}
EndGlobalSection
EndGlobal

6
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndex.cs

@ -82,7 +82,7 @@ public sealed class MongoTextIndex(IMongoDatabase database, string shardKey) : M
}
private Task SearchBySchemaAsync(SearchOperation search, IEnumerable<DomainId> schemaIds, double factor,
CancellationToken ct = default)
CancellationToken ct)
{
var filter =
Filter.And(
@ -95,7 +95,7 @@ public sealed class MongoTextIndex(IMongoDatabase database, string shardKey) : M
}
private Task SearchByAppAsync(SearchOperation search, double factor,
CancellationToken ct = default)
CancellationToken ct)
{
var filter =
Filter.And(
@ -107,7 +107,7 @@ public sealed class MongoTextIndex(IMongoDatabase database, string shardKey) : M
}
private async Task SearchAsync(SearchOperation search, FilterDefinition<MongoTextIndexEntity<List<MongoTextIndexEntityText>>> filter, double factor,
CancellationToken ct = default)
CancellationToken ct)
{
var byText =
await GetCollection(search.SearchScope).Find(filter).Limit(search.Take)

5
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/MongoTextIndexBase.cs

@ -102,13 +102,16 @@ public abstract class MongoTextIndexBase<T>(IMongoDatabase database, string shar
Guard.NotNull(app);
Guard.NotNull(query);
// The distance must be converted to radian (in contrast to MongoDB, which uses degrees).
var radian = query.Radius / 6378100;
// Use the filter in the correct order to leverage the index in the best way.
var findFilter =
Filter.And(
Filter.Eq(x => x.AppId, app.Id),
Filter.Eq(x => x.SchemaId, query.SchemaId),
Filter.Eq(x => x.GeoField, query.Field),
Filter.GeoWithinCenterSphere(x => x.GeoObject, query.Longitude, query.Latitude, query.Radius / 6378100),
Filter.GeoWithinCenterSphere(x => x.GeoObject, query.Longitude, query.Latitude, radian),
FilterByScope(scope));
var byGeo =

11
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -16,7 +16,6 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.179">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -25,11 +24,11 @@
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="1.5.0" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.30.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="6.32.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="6.32.0" />
<PackageReference Include="Squidex.Hosting" Version="6.32.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="6.32.0" />
<PackageReference Include="Squidex.AI.Mongo" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="6.33.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="6.33.0" />
<PackageReference Include="Squidex.Hosting" Version="6.33.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="6.33.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

4
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -28,8 +28,8 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="NJsonSchema" Version="11.0.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="6.32.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.32.0" />
<PackageReference Include="Squidex.AI" Version="6.33.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.33.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

89
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Text/Tokenizer.cs → backend/src/Squidex.Domain.Apps.Entities/Contents/Text/Tokenizer.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.Reflection;
using System.Text;
using Lucene.Net.Analysis;
using Lucene.Net.Analysis.Core;
using Lucene.Net.Analysis.Standard;
@ -76,29 +77,65 @@ public static class Tokenizer
return Word(textReader, textLanguage);
}
public static string Terms(Dictionary<string, string> source)
{
Guard.NotNull(source);
var sb = DefaultPools.StringBuilder.Get();
try
{
foreach (var (language, text) in source)
{
var stopWords = GetStopWords(language);
Tokenize(new StringReader(text), sb, stopWords);
}
return sb.ToString();
}
finally
{
DefaultPools.StringBuilder.Return(sb);
}
}
public static string Terms(string query, string language)
{
var stopWords =
string.Equals(language, InvariantPartitioning.Key, StringComparison.OrdinalIgnoreCase) ?
null :
StopWords.GetValueOrDefault(language) ??
StopWords["en"];
var stopWords = GetStopWords(language);
return Tokenize(new StringReader(query), stopWords);
}
private static string Word(TextReader reader, string language)
{
var stopWords =
string.Equals(language, InvariantPartitioning.Key, StringComparison.OrdinalIgnoreCase) ?
var stopWords = GetStopWords(language);
return Tokenize(reader, stopWords);
}
private static CharArraySet? GetStopWords(string language)
{
return string.Equals(language, InvariantPartitioning.Key, StringComparison.OrdinalIgnoreCase) ?
null :
StopWords.GetValueOrDefault(language) ??
StopWords["en"];
return Tokenize(reader, stopWords);
}
private static string Tokenize(TextReader reader, CharArraySet? stopWords)
{
var sb = DefaultPools.StringBuilder.Get();
try
{
Tokenize(reader, sb, stopWords);
return sb.ToString();
}
finally
{
DefaultPools.StringBuilder.Return(sb);
}
}
private static void Tokenize(TextReader reader, StringBuilder sb, CharArraySet? stopWords)
{
var tokenizer = new StandardTokenizer(Version, reader);
var tokenStream = (TokenStream)new StandardFilter(Version, tokenizer);
@ -111,34 +148,24 @@ public static class Tokenizer
tokenStream = new StopFilter(Version, tokenStream, stopWords);
}
var sb = DefaultPools.StringBuilder.Get();
try
{
// Actually not idea what this is doing, but it seems to work.
var attribute = tokenStream.AddAttribute<ICharTermAttribute>();
// Actually not idea what this is doing, but it seems to work.
var attribute = tokenStream.AddAttribute<ICharTermAttribute>();
tokenStream.Reset();
tokenStream.Reset();
using (tokenStream)
using (tokenStream)
{
while (tokenStream.IncrementToken())
{
while (tokenStream.IncrementToken())
{
var text = attribute.ToString();
var text = attribute.ToString();
if (sb.Length > 0)
{
sb.Append(' ');
}
sb.Append(text);
if (sb.Length > 0)
{
sb.Append(' ');
}
}
return sb.ToString();
}
finally
{
DefaultPools.StringBuilder.Return(sb);
sb.Append(text);
}
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -27,6 +27,7 @@
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="GraphQL" Version="8.2.1" />
<PackageReference Include="GraphQL.DataLoader" Version="8.2.1" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.179">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -41,7 +42,6 @@
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="taglib-sharp-netstandard2.0" Version="2.1.0" />
<PackageReference Include="tusdotnet" Version="2.8.0" />
</ItemGroup>
<ItemGroup>

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="6.32.0" />
<PackageReference Include="Squidex.Caching" Version="6.32.0" />
<PackageReference Include="Squidex.Events" Version="6.32.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="6.32.0" />
<PackageReference Include="Squidex.Log" Version="6.32.0" />
<PackageReference Include="Squidex.Messaging" Version="6.32.0" />
<PackageReference Include="Squidex.Text" Version="6.32.0" />
<PackageReference Include="Squidex.Assets" Version="6.33.0" />
<PackageReference Include="Squidex.Caching" Version="6.33.0" />
<PackageReference Include="Squidex.Events" Version="6.33.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="6.33.0" />
<PackageReference Include="Squidex.Log" Version="6.33.0" />
<PackageReference Include="Squidex.Messaging" Version="6.33.0" />
<PackageReference Include="Squidex.Text" Version="6.33.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

22
backend/src/Squidex/Squidex.csproj

@ -62,18 +62,18 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.4.1" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.S3" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="6.32.0" />
<PackageReference Include="Squidex.Assets.Azure" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.S3" Version="6.33.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="6.33.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="20.1.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="6.32.0" />
<PackageReference Include="Squidex.Hosting" Version="6.32.0" />
<PackageReference Include="Squidex.Messaging.All" Version="6.32.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.32.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="6.33.0" />
<PackageReference Include="Squidex.Hosting" Version="6.33.0" />
<PackageReference Include="Squidex.Messaging.All" Version="6.33.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.33.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.4.3" />
<PackageReference Include="YDotNet.Native" Version="0.4.3" />

42
backend/tests/Squidex.Data.Tests.CodeGenerator/Squidex.Data.Tests.CodeGenerator.csproj

@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<IsRoslynComponent>true</IsRoslynComponent>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<RootNamespace>Squidex</RootNamespace>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<None Remove="Template.handlebar" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Handlebars.Net" Version="2.1.6" PrivateAssets="all" GeneratePathProperty="true" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.179">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker Include="$(PKGHandlebars_Net)\lib\netstandard2.0\Handlebars.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Template.handlebar" />
</ItemGroup>
</Project>

23
backend/tests/Squidex.Data.Tests.CodeGenerator/Template.handlebar

@ -0,0 +1,23 @@
#pragma warning disable
// Auto-generated code
using Squidex.EntityFramework.TestHelpers;
namespace {{classNamespace}};
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class Postgres{{className}}(PostgresFixture fixture) : {{baseName}}<TestDbContextPostgres>(fixture)
{
}
[Trait("Category", "TestContainer")]
[Collection("MySql")]
public class MySql{{className}}(MySqlFixture fixture) : {{baseName}}<TestDbContextMySql>(fixture)
{
}
[Trait("Category", "TestContainer")]
[Collection("SqlServer")]
public class SqlServer{{className}}(SqlServerFixture fixture) : {{baseName}}<TestDbContextSqlServer>(fixture)
{
}

17
backend/tests/Squidex.Data.Tests.CodeGenerator/TemplateModel.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex;
internal sealed class TemplateModel
{
public string ClassNamespace { get; set; }
public string ClassName { get; set; }
public string BaseName { get; set; }
}

77
backend/tests/Squidex.Data.Tests.CodeGenerator/TestGenerator.cs

@ -0,0 +1,77 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics;
using System.Text;
using HandlebarsDotNet;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Squidex.Data.Tests.CodeGenerator;
[Generator]
public class TestGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var templateStream = typeof(TestGenerator).Assembly.GetManifestResourceStream("Squidex.Template.handlebar")!;
var templateText = new StreamReader(templateStream).ReadToEnd();
var template = Handlebars.Compile(templateText);
static TemplateModel? Transform(GeneratorSyntaxContext ctx)
{
var classSyntax = (ClassDeclarationSyntax)ctx.Node;
var className = classSyntax.Identifier.Text;
if (!className.StartsWith("EF", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!classSyntax.Modifiers.Any(x => x.IsKind(SyntaxKind.AbstractKeyword)))
{
return null;
}
if (classSyntax.TypeParameterList == null ||
classSyntax.TypeParameterList.Parameters.Count != 1 ||
classSyntax.TypeParameterList.Parameters[0].Identifier.Text != "TContext")
{
return null;
}
var namespaceDeclaration =
classSyntax.Ancestors()
.OfType<BaseNamespaceDeclarationSyntax>().First();
return new TemplateModel
{
BaseName = classSyntax.Identifier.Text,
ClassName = classSyntax.Identifier.Text.Substring(2),
ClassNamespace = namespaceDeclaration.Name.ToString(),
};
}
var fieldDeclarations = context.SyntaxProvider.CreateSyntaxProvider(
static (node, _) =>
{
return node is ClassDeclarationSyntax;
},
static (ctx, _) => Transform(ctx))
.Where(x => x != null);
context.RegisterSourceOutput(fieldDeclarations, (context, model) =>
{
var source = template(model);
context.AddSource($"{model!.BaseName}_Tests.cs", SourceText.From(source, Encoding.UTF8));
});
}
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Apps/EFAppRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Apps;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFAppRepositoryTests(PostgresFixture fixture) : AppRepositoryTests
public abstract class EFAppRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : AppRepositoryTests where TContext : DbContext
{
protected override Task<IAppRepository> CreateSutAsync()
{
var sut = new EFAppRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFAppRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<IAppRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetFolderRepositorySnapshotTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.EntityFramework.TestHelpers;
@ -13,13 +14,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Assets;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFAssetFolderRepositorySnapshotTests(PostgresFixture fixture) : AssetFolderSnapshotStoreTests
public abstract class EFAssetFolderRepositorySnapshotTests<TContext>(ISqlFixture<TContext> fixture) : AssetFolderSnapshotStoreTests where TContext : DbContext
{
protected override Task<ISnapshotStore<AssetFolder>> CreateSutAsync()
{
var sut = new EFAssetFolderRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFAssetFolderRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<ISnapshotStore<AssetFolder>>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetFolderRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Assets;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFAssetFolderRepositoryTests(PostgresFixture fixture) : AssetFolderRepositoryTests
public abstract class EFAssetFolderRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : AssetFolderRepositoryTests where TContext : DbContext
{
protected override Task<IAssetFolderRepository> CreateSutAsync()
{
var sut = new EFAssetFolderRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFAssetFolderRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<IAssetFolderRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetRepositorySnapshotTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.EntityFramework.TestHelpers;
@ -13,13 +14,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Assets;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFAssetRepositorySnapshotTests(PostgresFixture fixture) : AssetSnapshotStoreTests
public abstract class EFAssetRepositorySnapshotTests<TContext>(ISqlFixture<TContext> fixture) : AssetSnapshotStoreTests where TContext : DbContext
{
protected override Task<ISnapshotStore<Asset>> CreateSutAsync()
{
var sut = new EFAssetRepository<TestDbContextPostgres>(fixture.DbContextFactory, fixture.Dialect);
var sut = new EFAssetRepository<TContext>(fixture.DbContextFactory, fixture.Dialect);
return Task.FromResult<ISnapshotStore<Asset>>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Assets/EFAssetRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Assets;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFAssetRepositoryTests(PostgresFixture fixture) : AssetRepositoryTests
public abstract class EFAssetRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : AssetRepositoryTests where TContext : DbContext
{
protected override Task<IAssetRepository> CreateSutAsync()
{
var sut = new EFAssetRepository<TestDbContextPostgres>(fixture.DbContextFactory, fixture.Dialect);
var sut = new EFAssetRepository<TContext>(fixture.DbContextFactory, fixture.Dialect);
return Task.FromResult<IAssetRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/EFContentRepositorySnapshotTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.EntityFramework.TestHelpers;
@ -13,13 +14,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Contents;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFContentRepositorySnapshotTests(PostgresFixture fixture) : ContentSnapshotStoreTests
public abstract class EFContentRepositorySnapshotTests<TContext>(ISqlFixture<TContext> fixture) : ContentSnapshotStoreTests where TContext : DbContext
{
protected override Task<ISnapshotStore<WriteContent>> CreateSutAsync()
{
var sut = new EFContentRepository<TestDbContextPostgres>(fixture.DbContextFactory, Context.AppProvider, fixture.Dialect);
var sut = new EFContentRepository<TContext>(fixture.DbContextFactory, Context.AppProvider, fixture.Dialect);
return Task.FromResult<ISnapshotStore<WriteContent>>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/EFContentRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Contents;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFContentRepositoryTests(PostgresFixture fixture) : ContentRepositoryTests
public abstract class EFContentRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : ContentRepositoryTests where TContext : DbContext
{
protected override Task<IContentRepository> CreateSutAsync()
{
var sut = new EFContentRepository<TestDbContextPostgres>(fixture.DbContextFactory, AppProvider, fixture.Dialect);
var sut = new EFContentRepository<TContext>(fixture.DbContextFactory, AppProvider, fixture.Dialect);
return Task.FromResult<IContentRepository>(sut);
}

25
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/Text/EFTextIndexTests.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.EntityFramework.TestHelpers;
namespace Squidex.EntityFramework.Domain.Contents.Text;
public abstract class EFTextIndexTests<TContext>(ISqlFixture<TContext> fixture) : TextIndexerTests where TContext : DbContext
{
public override bool SupportsQuerySyntax => false;
public override async Task<ITextIndex> CreateSutAsync()
{
var sut = new EFTextIndex<TContext>(fixture.DbContextFactory, fixture.Dialect);
await sut.InitializeAsync(default);
return sut;
}
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Contents/Text/EFTextIndexerStateTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Contents.Text.State;
@ -13,13 +14,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Contents.Text;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public sealed class EFTextIndexerStateTests(PostgresFixture fixture) : TextIndexerStateTests
public abstract class EFTextIndexerStateTests<TContext>(ISqlFixture<TContext> fixture) : TextIndexerStateTests where TContext : DbContext
{
protected override Task<ITextIndexerState> CreateSutAsync(IContentRepository contentRepository)
{
var sut = new EFTextIndexerState<TestDbContextPostgres>(fixture.DbContextFactory, fixture.Dialect, contentRepository);
var sut = new EFTextIndexerState<TContext>(fixture.DbContextFactory, fixture.Dialect, contentRepository);
return Task.FromResult<ITextIndexerState>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/History/EFHistoryEventRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Apps.Entities.History.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.History;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFHistoryEventRepositoryTests(PostgresFixture fixture) : HistoryEventRepositoryTests
public abstract class EFHistoryEventRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : HistoryEventRepositoryTests where TContext : DbContext
{
protected override Task<IHistoryEventRepository> CreateSutAsync()
{
var sut = new EFHistoryEventRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFHistoryEventRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<IHistoryEventRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Rules/EFRuleEventRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Rules;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFRuleEventRepositoryTests(PostgresFixture fixture) : RuleEventRepositoryTests
public abstract class EFRuleEventRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : RuleEventRepositoryTests where TContext : DbContext
{
protected override Task<IRuleEventRepository> CreateSutAsync()
{
var sut = new EFRuleEventRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFRuleEventRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<IRuleEventRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Rules/EFRuleRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Rules;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFRuleRepositoryTests(PostgresFixture fixture) : RuleRepositoryTests
public abstract class EFRuleRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : RuleRepositoryTests where TContext : DbContext
{
protected override Task<IRuleRepository> CreateSutAsync()
{
var sut = new EFRuleRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFRuleRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<IRuleRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Schemas/EFSchemaRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Schemas;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFSchemaRepositoryTests(PostgresFixture fixture) : SchemaRepositoryTests
public abstract class EFSchemaRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : SchemaRepositoryTests where TContext : DbContext
{
protected override Task<ISchemaRepository> CreateSutAsync()
{
var sut = new EFSchemaRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFSchemaRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<ISchemaRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Domain/Teams/EFTeamRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Domain.Apps.Entities.Teams;
using Squidex.Domain.Apps.Entities.Teams.Repositories;
using Squidex.EntityFramework.TestHelpers;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Domain.Teams;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFTeamRepositoryTests(PostgresFixture fixture) : TeamRepositoryTests
public abstract class EFTeamRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : TeamRepositoryTests where TContext : DbContext
{
protected override Task<ITeamRepository> CreateSutAsync()
{
var sut = new EFTeamRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFTeamRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<ITeamRepository>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Caching/EFDistributedCacheTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.Caching;
@ -12,13 +13,11 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Infrastructure.Caching;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFDistributedCacheTests(PostgresFixture fixture) : DistributedCacheTests
public abstract class EFDistributedCacheTests<TContext>(ISqlFixture<TContext> fixture) : DistributedCacheTests where TContext : DbContext
{
protected override Task<IDistributedCache> CreateSutAsync(TimeProvider timeProvider)
{
var sut = new EFDistributedCache<TestDbContextPostgres>(fixture.DbContextFactory, timeProvider);
var sut = new EFDistributedCache<TContext>(fixture.DbContextFactory, timeProvider);
return Task.FromResult<IDistributedCache>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Log/EFRequestLogRepositoryTests.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.Log;
@ -12,14 +13,12 @@ using Squidex.Shared;
namespace Squidex.EntityFramework.Infrastructure.Log;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFRequestLogRepositoryTests(PostgresFixture fixture) : RequestLogRepositoryTests
public abstract class EFRequestLogRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : RequestLogRepositoryTests where TContext : DbContext
{
protected override Task<IRequestLogRepository> CreateSutAsync()
{
var sut =
new EFRequestLogRepository<TestDbContextPostgres>(
new EFRequestLogRepository<TContext>(
fixture.DbContextFactory, Options.Create(new RequestLogStoreOptions()));
return Task.FromResult<IRequestLogRepository>(sut);

7
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Migrations/EFMigrationStatusTests.cs

@ -5,19 +5,18 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.Migrations;
using Squidex.Shared;
namespace Squidex.EntityFramework.Infrastructure.Migrations;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFMigrationStatusTests(PostgresFixture fixture) : MigrationStatusTests
public abstract class EFMigrationStatusTests<TContext>(ISqlFixture<TContext> fixture) : MigrationStatusTests where TContext : DbContext
{
protected override async Task<IMigrationStatus> CreateSutAsync()
{
var sut = new EFMigrationStatus<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFMigrationStatus<TContext>(fixture.DbContextFactory);
await sut.InitializeAsync(default);

78
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/SqlQueryTests.cs → backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/EFQueryTests.cs

@ -6,16 +6,19 @@
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using MongoDB.Driver;
using NetTopologySuite.Geometries;
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.Queries;
namespace Squidex.EntityFramework.Infrastructure.Queries;
public abstract class SqlQueryTests<TContext> where TContext : DbContext
public abstract class EFQueryTests<TContext>(ISqlFixture<TContext> fixture) where TContext : DbContext
{
protected abstract Task<TContext> CreateDbContextAsync();
protected abstract SqlDialect CreateDialect();
protected Task<TContext> CreateDbContextAsync()
{
return fixture.DbContextFactory.CreateDbContextAsync();
}
private class TestSqlBuilder(SqlDialect dialect, string table) : SqlQueryBuilder(dialect, table)
{
@ -29,6 +32,9 @@ public abstract class SqlQueryTests<TContext> where TContext : DbContext
{
var dbContext = await CreateDbContextAsync();
await dbContext.Database.CreateGeoIndexAsync(fixture.Dialect, "IDX_GEO", "TestEntity", "Point");
await dbContext.Database.CreateTextIndexAsync(fixture.Dialect, "IDX_Text", "TestEntity", "FullText");
var set = dbContext.Set<TestEntity>();
if (await set.AnyAsync())
{
@ -66,6 +72,7 @@ public abstract class SqlQueryTests<TContext> where TContext : DbContext
BooleanOrNull = i > 10 ? true : null,
Number = i,
NumberOrNull = i > 10 ? null : i,
FullText = "hello world",
Text = $"Prefix{i}Suffix",
Json = new TestJson
{
@ -77,6 +84,7 @@ public abstract class SqlQueryTests<TContext> where TContext : DbContext
Array = [0, i],
Text = $"Prefix{i}Suffix",
},
Point = new Point(i * 2, i * 2) { SRID = 4326 },
});
}
@ -465,7 +473,7 @@ public abstract class SqlQueryTests<TContext> where TContext : DbContext
public async Task Should_query_count()
{
var builder =
new TestSqlBuilder(CreateDialect(), "TestEntity")
new TestSqlBuilder(fixture.Dialect, "TestEntity")
.Count();
var (sql, parameters) = builder.Compile();
@ -476,6 +484,47 @@ public abstract class SqlQueryTests<TContext> where TContext : DbContext
Assert.Equal(20, dbResult);
}
[Fact]
public async Task Should_query_by_distance()
{
var point = new Point(4, 4) { SRID = 4326 };
var dbContext = await CreateAndPrepareDbContextAsync();
var dbResult = await dbContext.Set<TestEntity>().Where(x => x.Point.Distance(point) < 1).ToListAsync();
Assert.Single(dbResult);
}
[Fact]
public async Task Should_query_full_text()
{
var builder =
new TestSqlBuilder(fixture.Dialect, "TestEntity")
.WhereMatch("FullText", "hello");
var (sql, parameters) = builder.Compile();
var dbContext = await CreateAndPrepareDbContextAsync();
var dbResult = await PollAsync(dbContext, sql, parameters, 20);
Assert.Equal(20, dbResult.Count);
}
[Fact]
public async Task Should_query_full_text_with_space()
{
var builder =
new TestSqlBuilder(fixture.Dialect, "TestEntity")
.WhereMatch("FullText", "hello world");
var (sql, parameters) = builder.Compile();
var dbContext = await CreateAndPrepareDbContextAsync();
var dbResult = await PollAsync(dbContext, sql, parameters, 0);
Assert.Empty(dbResult);
}
private static long[] Range(int from, int to)
{
var result = new List<long>();
@ -500,7 +549,7 @@ public abstract class SqlQueryTests<TContext> where TContext : DbContext
private async Task<List<long>> QueryAsync(ClrQuery query)
{
var builder =
new TestSqlBuilder(CreateDialect(), "TestEntity")
new TestSqlBuilder(fixture.Dialect, "TestEntity")
.Limit(query)
.Offset(query)
.Order(query)
@ -513,4 +562,21 @@ public abstract class SqlQueryTests<TContext> where TContext : DbContext
return dbResult.Select(x => x.Number).ToList();
}
private static async Task<List<TestEntity>> PollAsync(TContext dbContext, string sql, object[] parameters, int expectedCount)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(120));
while (!cts.IsCancellationRequested)
{
var dbResult = await dbContext.Set<TestEntity>().FromSqlRaw(sql, parameters).ToListAsync(default);
if (dbResult.Count == expectedCount)
{
return dbResult;
}
await Task.Delay(50, default);
}
return [];
}
}

29
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/MySqlQueryTests.cs

@ -1,29 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.MySql;
namespace Squidex.EntityFramework.Infrastructure.Queries;
[Trait("Category", "TestContainer")]
[Collection("MySql")]
public class MySqlQueryTests(MySqlFixture fixture) : SqlQueryTests<TestDbContext>
{
protected override async Task<TestDbContext> CreateDbContextAsync()
{
var context = await fixture.DbContextFactory.CreateDbContextAsync();
return context;
}
protected override SqlDialect CreateDialect()
{
return MySqlDialect.Instance;
}
}

29
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/PostgresQueryTests.cs

@ -1,29 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres;
namespace Squidex.EntityFramework.Infrastructure.Queries;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class PostgresQueryTests(PostgresFixture fixture) : SqlQueryTests<TestDbContext>
{
protected override async Task<TestDbContext> CreateDbContextAsync()
{
var context = await fixture.DbContextFactory.CreateDbContextAsync();
return context;
}
protected override SqlDialect CreateDialect()
{
return PostgresDialect.Instance;
}
}

29
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/Queries/SqlServerQueryTests.cs

@ -1,29 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.SqlServer;
namespace Squidex.EntityFramework.Infrastructure.Queries;
[Trait("Category", "TestContainer")]
[Collection("SqlServer")]
public class SqlServerQueryTests(SqlServerFixture fixture) : SqlQueryTests<TestDbContext>
{
protected override async Task<TestDbContext> CreateDbContextAsync()
{
var context = await fixture.DbContextFactory.CreateDbContextAsync();
return context;
}
protected override SqlDialect CreateDialect()
{
return SqlServerDialect.Instance;
}
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/States/EFSnapshotStoreTests.cs

@ -5,19 +5,18 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.States;
using Squidex.Shared;
namespace Squidex.EntityFramework.Infrastructure.States;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFSnapshotStoreTests(PostgresFixture fixture) : SnapshotStoreTests
public abstract class EFSnapshotStoreTests<TContext>(ISqlFixture<TContext> fixture) : SnapshotStoreTests where TContext : DbContext
{
protected override Task<ISnapshotStore<SnapshotValue>> CreateSutAsync()
{
var sut = new EFSnapshotStore<TestDbContextPostgres, SnapshotValue, EFState<SnapshotValue>>(fixture.DbContextFactory);
var sut = new EFSnapshotStore<TContext, SnapshotValue, EFState<SnapshotValue>>(fixture.DbContextFactory);
return Task.FromResult<ISnapshotStore<SnapshotValue>>(sut);
}

7
backend/tests/Squidex.Data.Tests/EntityFramework/Infrastructure/UsageTracking/EFUsageRepositoryTests.cs

@ -5,19 +5,18 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.EntityFramework.TestHelpers;
using Squidex.Infrastructure.UsageTracking;
using Squidex.Shared;
namespace Squidex.EntityFramework.Infrastructure.UsageTracking;
[Trait("Category", "TestContainer")]
[Collection("Postgres")]
public class EFUsageRepositoryTests(PostgresFixture fixture) : UsageRepositoryTests
public abstract class EFUsageRepositoryTests<TContext>(ISqlFixture<TContext> fixture) : UsageRepositoryTests where TContext : DbContext
{
protected override Task<IUsageRepository> CreateSutAsync()
{
var sut = new EFUsageRepository<TestDbContextPostgres>(fixture.DbContextFactory);
var sut = new EFUsageRepository<TContext>(fixture.DbContextFactory);
return Task.FromResult<IUsageRepository>(sut);
}

6
backend/tests/Squidex.Data.Tests/EntityFramework/Migrations/MySqlMigrationTests.cs

@ -10,7 +10,6 @@ using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Infrastructure.Migrations;
using Squidex.Providers.MySql;
using Squidex.Providers.Postgres;
using Testcontainers.MySql;
namespace Squidex.EntityFramework.Migrations;
@ -39,9 +38,10 @@ public class MySqlMigrationTests : IAsyncLifetime
{
var connectionString = mysql.GetConnectionString();
b.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), mysql =>
b.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), options =>
{
mysql.UseMicrosoftJson(MySqlCommonJsonChangeTrackingOptions.FullHierarchyOptimizedSemantically);
options.UseNetTopologySuite();
options.UseMicrosoftJson(MySqlCommonJsonChangeTrackingOptions.FullHierarchyOptimizedSemantically);
});
})
.AddSingleton(TestUtils.DefaultSerializer)

10
backend/tests/Squidex.Data.Tests/EntityFramework/Migrations/PostgresMigrationTests.cs

@ -17,7 +17,10 @@ namespace Squidex.EntityFramework.Migrations;
[Trait("Category", "TestContainer")]
public class PostgresMigrationTests : IAsyncLifetime
{
private readonly PostgreSqlContainer postgreSql = new PostgreSqlBuilder().Build();
private readonly PostgreSqlContainer postgreSql =
new PostgreSqlBuilder()
.WithImage("postgis/postgis")
.Build();
public async Task InitializeAsync()
{
@ -36,7 +39,10 @@ public class PostgresMigrationTests : IAsyncLifetime
new ServiceCollection()
.AddDbContextFactory<PostgresDbContext>(b =>
{
b.UseNpgsql(postgreSql.GetConnectionString());
b.UseNpgsql(postgreSql.GetConnectionString(), options =>
{
options.UseNetTopologySuite();
});
})
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<DatabaseMigrator<PostgresDbContext>>()

5
backend/tests/Squidex.Data.Tests/EntityFramework/Migrations/SqlServerMigrationTests.cs

@ -37,7 +37,10 @@ public class SqlServerMigrationTests : IAsyncLifetime
new ServiceCollection()
.AddDbContextFactory<SqlServerDbContext>(b =>
{
b.UseSqlServer(sqlServer.GetConnectionString());
b.UseSqlServer(sqlServer.GetConnectionString(), options =>
{
options.UseNetTopologySuite();
});
})
.AddSingleton(TestUtils.DefaultSerializer)
.AddSingleton<DatabaseMigrator<SqlServerDbContext>>()

18
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/ISqlFixture.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure.Queries;
namespace Squidex.EntityFramework.TestHelpers;
public interface ISqlFixture<TContext> where TContext : DbContext
{
SqlDialect Dialect { get; }
IDbContextFactory<TContext> DbContextFactory { get; }
}

24
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs

@ -6,11 +6,12 @@
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.MySql;
using Testcontainers.MySql;
#pragma warning disable MA0048 // File name must match type name
@ -22,18 +23,21 @@ public sealed class MySqlFixtureCollection : ICollectionFixture<MySqlFixture>
{
}
public sealed class MySqlFixture : IAsyncLifetime
public sealed class MySqlFixture : IAsyncLifetime, ISqlFixture<TestDbContextMySql>
{
private readonly MySqlContainer mysql =
new MySqlBuilder()
.WithReuse(true)
.WithLabel("reuse-id", "squidex-mysql")
.WithCommand("--log-bin-trust-function-creators=1", "--local-infile=1")
.Build();
private IServiceProvider services;
public IDbContextFactory<TestDbContextMySql> DbContextFactory => services.GetRequiredService<IDbContextFactory<TestDbContextMySql>>();
public SqlDialect Dialect => MySqlDialect.Instance;
public async Task InitializeAsync()
{
await mysql.StartAsync();
@ -42,22 +46,18 @@ public sealed class MySqlFixture : IAsyncLifetime
new ServiceCollection()
.AddDbContextFactory<TestDbContextMySql>(b =>
{
var connectionString = mysql.GetConnectionString();
var connectionString = $"{mysql.GetConnectionString()};AllowLoadLocalInfile=true";
b.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), mysql =>
b.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), options =>
{
mysql.UseMicrosoftJson(MySqlCommonJsonChangeTrackingOptions.FullHierarchyOptimizedSemantically);
options.UseNetTopologySuite();
options.UseMicrosoftJson(MySqlCommonJsonChangeTrackingOptions.FullHierarchyOptimizedSemantically);
});
})
.AddSingletonAs<DatabaseCreator<TestDbContextMySql>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.BuildServiceProvider();
var factory = services.GetRequiredService<IDbContextFactory<TestDbContextMySql>>();
var context = await factory.CreateDbContextAsync();
var creator = (RelationalDatabaseCreator)context.Database.GetService<IDatabaseCreator>();
await creator.EnsureCreatedAsync();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())
{
await service.InitializeAsync(default);

18
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs

@ -6,11 +6,10 @@
// ==========================================================================
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.Postgres;
using Testcontainers.PostgreSql;
@ -25,10 +24,11 @@ public sealed class PostgresFixtureCollection : ICollectionFixture<PostgresFixtu
{
}
public sealed class PostgresFixture : IAsyncLifetime
public sealed class PostgresFixture : IAsyncLifetime, ISqlFixture<TestDbContextPostgres>
{
private readonly PostgreSqlContainer postgreSql =
new PostgreSqlBuilder()
.WithImage("postgis/postgis")
.WithReuse(true)
.WithLabel("reuse-id", "squidex-postgres")
.Build();
@ -47,17 +47,15 @@ public sealed class PostgresFixture : IAsyncLifetime
new ServiceCollection()
.AddDbContextFactory<TestDbContextPostgres>(b =>
{
b.UseNpgsql(postgreSql.GetConnectionString());
b.UseNpgsql(postgreSql.GetConnectionString(), options =>
{
options.UseNetTopologySuite();
});
})
.AddSingletonAs<DatabaseCreator<TestDbContextPostgres>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.BuildServiceProvider();
var factory = services.GetRequiredService<IDbContextFactory<TestDbContextPostgres>>();
var context = await factory.CreateDbContextAsync();
var creator = (RelationalDatabaseCreator)context.Database.GetService<IDatabaseCreator>();
await creator.EnsureCreatedAsync();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())
{
await service.InitializeAsync(default);

38
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs

@ -5,12 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Hosting;
using Squidex.Infrastructure.Migrations;
using Squidex.Infrastructure.Queries;
using Squidex.Providers.SqlServer;
using Testcontainers.MsSql;
#pragma warning disable MA0048 // File name must match type name
@ -22,37 +24,39 @@ public sealed class SqlServerFixtureCollection : ICollectionFixture<SqlServerFix
{
}
public sealed class SqlServerFixture : IAsyncLifetime
public sealed class SqlServerFixture : IAsyncLifetime, ISqlFixture<TestDbContextSqlServer>
{
private readonly MsSqlContainer sqlServer =
new MsSqlBuilder()
.WithImage("vibs2006/sql_server_fts")
.WithReuse(true)
.WithLabel("reuse-id", "squidex-mssql")
.Build();
private IServiceProvider services;
public IDbContextFactory<TestDbContexSqlServer> DbContextFactory => services.GetRequiredService<IDbContextFactory<TestDbContexSqlServer>>();
public IDbContextFactory<TestDbContextSqlServer> DbContextFactory => services.GetRequiredService<IDbContextFactory<TestDbContextSqlServer>>();
public SqlDialect Dialect => SqlServerDialect.Instance;
public async Task InitializeAsync()
{
await sqlServer.StartAsync();
await sqlServer.ExecScriptAsync($"create database squidex;");
services =
new ServiceCollection()
.AddDbContextFactory<TestDbContexSqlServer>(b =>
.AddDbContextFactory<TestDbContextSqlServer>(b =>
{
b.UseSqlServer(sqlServer.GetConnectionString());
b.UseSqlServer(GetConnectionString(), options =>
{
options.UseNetTopologySuite();
});
})
.AddSingletonAs<DatabaseCreator<TestDbContextSqlServer>>().Done()
.AddSingleton(TestUtils.DefaultSerializer)
.BuildServiceProvider();
var factory = services.GetRequiredService<IDbContextFactory<TestDbContexSqlServer>>();
var context = await factory.CreateDbContextAsync();
var creator = (RelationalDatabaseCreator)context.Database.GetService<IDatabaseCreator>();
await creator.EnsureCreatedAsync();
foreach (var service in services.GetRequiredService<IEnumerable<IInitializable>>())
{
await service.InitializeAsync(default);
@ -68,4 +72,14 @@ public sealed class SqlServerFixture : IAsyncLifetime
await sqlServer.StopAsync();
}
private string GetConnectionString()
{
var builder = new SqlConnectionStringBuilder(sqlServer.GetConnectionString())
{
InitialCatalog = "squidex",
};
return builder.ConnectionString;
}
}

29
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestDbContext.cs

@ -5,6 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using EFCore.BulkExtensions.SqlAdapters;
using EFCore.BulkExtensions.SqlAdapters.MySql;
using EFCore.BulkExtensions.SqlAdapters.PostgreSql;
using EFCore.BulkExtensions.SqlAdapters.SqlServer;
using Microsoft.EntityFrameworkCore;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json;
@ -19,6 +23,8 @@ namespace Squidex.EntityFramework.TestHelpers;
public class TestDbContextMySql(DbContextOptions options, IJsonSerializer jsonSerializer)
: TestDbContext(options, jsonSerializer)
{
public static readonly IDbServer Server = new MySqlDbServer();
protected override string? JsonColumnType()
{
return "json";
@ -28,20 +34,41 @@ public class TestDbContextMySql(DbContextOptions options, IJsonSerializer jsonSe
public class TestDbContextPostgres(DbContextOptions options, IJsonSerializer jsonSerializer)
: TestDbContext(options, jsonSerializer)
{
public static readonly IDbServer Server = new PostgreSqlDbServer();
protected override string? JsonColumnType()
{
return "jsonb";
}
}
public class TestDbContexSqlServer(DbContextOptions options, IJsonSerializer jsonSerializer)
public class TestDbContextSqlServer(DbContextOptions options, IJsonSerializer jsonSerializer)
: TestDbContext(options, jsonSerializer)
{
public static readonly IDbServer Server = new SqlServerDbServer();
}
public class TestDbContext(DbContextOptions options, IJsonSerializer jsonSerializer)
: AppDbContext(options, jsonSerializer)
{
static TestDbContext()
{
SqlAdaptersMapping.Provider = context =>
{
switch (context)
{
case TestDbContextMySql:
return TestDbContextMySql.Server;
case TestDbContextPostgres:
return TestDbContextPostgres.Server;
case TestDbContextSqlServer:
return TestDbContextSqlServer.Server;
}
throw new ArgumentException("Not supported.", nameof(context));
};
}
protected override void OnModelCreating(ModelBuilder builder)
{
builder.UseSnapshot<SnapshotValue, EFState<SnapshotValue>>(jsonSerializer, JsonColumnType());

5
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/TestEntity.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using NetTopologySuite.Geometries;
#pragma warning disable MA0048 // File name must match type name
@ -22,10 +23,14 @@ public class TestEntity
public string Text { get; set; }
public string FullText { get; set; }
public bool Boolean { get; set; }
public bool? BooleanOrNull { get; set; }
public Point Point { get; set; }
public TestJson Json { get; set; }
}

4
backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/AtlasParsingTests.cs

@ -395,12 +395,12 @@ public class AtlasParsingTests
Assert.Equal(expected, actual);
}
private static object CreateQuery(object query)
private static string CreateQuery(object query)
{
return JsonSerializer.Serialize(query, JsonSerializerOptions);
}
private static object ParseQuery(string query)
private static string ParseQuery(string query)
{
var luceneQuery = QueryParser.Parse(query);

22
backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/AtlasTextIndexTests.cs

@ -5,21 +5,17 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1300 // Element should begin with upper-case letter
using Squidex.Domain.Apps.Entities.Contents.Text;
namespace Squidex.MongoDb.Domain.Contents.Text;
[Trait("Category", "Dependencies")]
public class AtlasTextIndexTests(AtlasTextIndexFixture fixture) : TextIndexerTestsBase, IClassFixture<AtlasTextIndexFixture>
public class AtlasTextIndexTests(AtlasTextIndexFixture fixture) : TextIndexerTests, IClassFixture<AtlasTextIndexFixture>
{
public override bool SupportsQuerySyntax => true;
public override bool SupportsGeo => true;
public override int WaitAfterUpdate => 2000;
public AtlasTextIndexFixture _ { get; } = fixture;
public override Task<ITextIndex> CreateSutAsync()
@ -30,26 +26,26 @@ public class AtlasTextIndexTests(AtlasTextIndexFixture fixture) : TextIndexerTes
[Fact]
public async Task Should_retrieve_english_stopword_only_for_german_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids2, text: "und");
await SearchText(expected: Ids2, text: "und");
}
[Fact]
public async Task Should_retrieve_german_stopword_only_for_english_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids1, text: "and");
await SearchText(expected: Ids1, text: "and");
}
[Fact]
public async Task Should_index_cjk_content_and_retrieve()
{
await CreateTextAsync(ids1[0], "zh", "東京大学");
await CreateTextAsync(Ids1[0], "zh", "東京大学");
await SearchText(expected: ids1, text: "東京");
await SearchText(expected: Ids1, text: "東京");
}
}

16
backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexTests.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1300 // Element should begin with upper-case letter
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.MongoDb.TestHelpers;
@ -14,7 +12,7 @@ namespace Squidex.MongoDb.Domain.Contents.Text;
[Trait("Category", "Dependencies")]
[Collection("Mongo")]
public class MongoTextIndexTests(MongoFixture fixture) : TextIndexerTestsBase
public class MongoTextIndexTests(MongoFixture fixture) : TextIndexerTests
{
public override bool SupportsQuerySyntax => false;
@ -31,18 +29,18 @@ public class MongoTextIndexTests(MongoFixture fixture) : TextIndexerTestsBase
[Fact]
public async Task Should_retrieve_all_stopwords_for_english_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids1, text: "de:and");
await SearchText(expected: Ids1, text: "de:and");
}
[Fact]
public async Task Should_retrieve_all_stopwords_for_german_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids2, text: "en:und");
await SearchText(expected: Ids2, text: "en:und");
}
}

2
backend/tests/Squidex.Data.Tests/MongoDb/Domain/Contents/Text/MongoTextIndexerStateTests.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1300 // Element should begin with upper-case letter
using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Entities.Contents.Text;
using Squidex.Domain.Apps.Entities.Contents.Text.State;

2
backend/tests/Squidex.Data.Tests/MongoDb/Infrastructure/EventSourcing/MongoEventConsumerProcessorIntegrationTests_Direct.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1300 // Element should begin with upper-case letter
using Squidex.Events;
namespace Squidex.MongoDb.Infrastructure.EventSourcing;

2
backend/tests/Squidex.Data.Tests/MongoDb/Infrastructure/EventSourcing/MongoEventStoreParallelInsertTests.cs

@ -13,8 +13,6 @@ using Squidex.Infrastructure.EventSourcing.Consume;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.TestHelpers;
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace Squidex.MongoDb.Infrastructure.EventSourcing;
[Trait("Category", "Dependencies")]

2
backend/tests/Squidex.Data.Tests/Squidex.Data.Tests.csproj

@ -7,11 +7,13 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NeutralLanguage>en</NeutralLanguage>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Squidex.Data.EntityFramework\Squidex.Data.EntityFramework.csproj" />
<ProjectReference Include="..\..\src\Squidex.Data.MongoDb\Squidex.Data.MongoDb.csproj" />
<ProjectReference Include="..\..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
<ProjectReference Include="..\Squidex.Data.Tests.CodeGenerator\Squidex.Data.Tests.CodeGenerator.csproj" OutputItemType="Analyzer" SetTargetFramework="TargetFramework=netstandard2.0" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Squidex.Domain.Apps.Core.Tests\Squidex.Domain.Apps.Core.Tests.csproj" />
<ProjectReference Include="..\Squidex.Domain.Apps.Entities.Tests\Squidex.Domain.Apps.Entities.Tests.csproj" />
<ProjectReference Include="..\Squidex.Infrastructure.Tests\Squidex.Infrastructure.Tests.csproj" />

5
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs

@ -35,7 +35,6 @@ public class FileTagAssetMetadataSourceTests : GivenContext
await sut.EnhanceAsync(command, default);
Assert.Equal(AssetType.Image, command.Type);
Assert.Equal(272, command.Metadata.GetInt32(KnownMetadataKeys.PixelWidth));
Assert.Equal(170, command.Metadata.GetInt32(KnownMetadataKeys.PixelHeight));
}
@ -59,8 +58,7 @@ public class FileTagAssetMetadataSourceTests : GivenContext
await sut.EnhanceAsync(command, default);
Assert.Equal(AssetType.Audio, command.Type);
Assert.Equal(JsonValue.Create("00:00:28.2708750"), command.Metadata[KnownMetadataKeys.Duration]);
Assert.Equal(JsonValue.Create("00:00:27.7447500"), command.Metadata[KnownMetadataKeys.Duration]);
Assert.Equal(JsonValue.Create(2L), command.Metadata[KnownMetadataKeys.AudioChannels]);
Assert.Equal(JsonValue.Create(128L), command.Metadata[KnownMetadataKeys.AudioBitrate]);
Assert.Equal(JsonValue.Create(44100L), command.Metadata[KnownMetadataKeys.AudioSampleRate]);
@ -74,7 +72,6 @@ public class FileTagAssetMetadataSourceTests : GivenContext
await sut.EnhanceAsync(command, default);
Assert.Equal(AssetType.Video, command.Type);
Assert.Equal(JsonValue.Create("00:00:05.3120000"), command.Metadata[KnownMetadataKeys.Duration]);
Assert.Equal(JsonValue.Create(2L), command.Metadata[KnownMetadataKeys.AudioChannels]);
Assert.Equal(JsonValue.Create(384L), command.Metadata[KnownMetadataKeys.AudioBitrate]);

22
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/AzureTextIndexTests.cs

@ -5,17 +5,13 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace Squidex.Domain.Apps.Entities.Contents.Text;
[Trait("Category", "Dependencies")]
public class AzureTextIndexTests(AzureTextIndexFixture fixture) : TextIndexerTestsBase, IClassFixture<AzureTextIndexFixture>
public class AzureTextIndexTests(AzureTextIndexFixture fixture) : TextIndexerTests, IClassFixture<AzureTextIndexFixture>
{
public override bool SupportsGeo => true;
public override int WaitAfterUpdate => 2000;
public AzureTextIndexFixture _ { get; } = fixture;
public override Task<ITextIndex> CreateSutAsync()
@ -26,26 +22,26 @@ public class AzureTextIndexTests(AzureTextIndexFixture fixture) : TextIndexerTes
[Fact]
public async Task Should_retrieve_english_stopword_only_for_german_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids2, text: "und");
await SearchText(expected: Ids2, text: "und");
}
[Fact]
public async Task Should_retrieve_german_stopword_only_for_english_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids1, text: "and");
await SearchText(expected: Ids1, text: "and");
}
[Fact]
public async Task Should_index_cjk_content_and_retrieve()
{
await CreateTextAsync(ids1[0], "zh", "東京大学");
await CreateTextAsync(Ids1[0], "zh", "東京大学");
await SearchText(expected: ids1, text: "東京");
await SearchText(expected: Ids1, text: "東京");
}
}

22
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/ElasticSearchTextIndexTests.cs

@ -5,17 +5,13 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace Squidex.Domain.Apps.Entities.Contents.Text;
[Trait("Category", "Dependencies")]
public class ElasticSearchTextIndexTests(ElasticSearchTextIndexFixture fixture) : TextIndexerTestsBase, IClassFixture<ElasticSearchTextIndexFixture>
public class ElasticSearchTextIndexTests(ElasticSearchTextIndexFixture fixture) : TextIndexerTests, IClassFixture<ElasticSearchTextIndexFixture>
{
public override bool SupportsGeo => true;
public override int WaitAfterUpdate => 2000;
public ElasticSearchTextIndexFixture _ { get; } = fixture;
public override Task<ITextIndex> CreateSutAsync()
@ -26,26 +22,26 @@ public class ElasticSearchTextIndexTests(ElasticSearchTextIndexFixture fixture)
[Fact]
public async Task Should_retrieve_english_stopword_only_for_german_query()
{
await CreateTextAsync(ids1[0], "de", "and y");
await CreateTextAsync(ids2[0], "en", "and y");
await CreateTextAsync(Ids1[0], "de", "and y");
await CreateTextAsync(Ids2[0], "en", "and y");
await SearchText(expected: ids2, text: "und");
await SearchText(expected: Ids2, text: "und");
}
[Fact]
public async Task Should_retrieve_german_stopword_only_for_english_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids1, text: "and");
await SearchText(expected: Ids1, text: "and");
}
[Fact]
public async Task Should_index_cjk_content_and_retrieve()
{
await CreateTextAsync(ids1[0], "zh", "東京大学");
await CreateTextAsync(Ids1[0], "zh", "東京大学");
await SearchText(expected: ids1, text: "東京");
await SearchText(expected: Ids1, text: "東京");
}
}

22
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/OpenSearchTextIndexTests.cs

@ -5,17 +5,13 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace Squidex.Domain.Apps.Entities.Contents.Text;
[Trait("Category", "Dependencies")]
public class OpenSearchTextIndexTests(OpenSearchTextIndexFixture fixture) : TextIndexerTestsBase, IClassFixture<OpenSearchTextIndexFixture>
public class OpenSearchTextIndexTests(OpenSearchTextIndexFixture fixture) : TextIndexerTests, IClassFixture<OpenSearchTextIndexFixture>
{
public override bool SupportsGeo => true;
public override int WaitAfterUpdate => 2000;
public OpenSearchTextIndexFixture _ { get; } = fixture;
public override Task<ITextIndex> CreateSutAsync()
@ -26,26 +22,26 @@ public class OpenSearchTextIndexTests(OpenSearchTextIndexFixture fixture) : Text
[Fact]
public async Task Should_retrieve_english_stopword_only_for_german_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids2, text: "und");
await SearchText(expected: Ids2, text: "und");
}
[Fact]
public async Task Should_retrieve_german_stopword_only_for_english_query()
{
await CreateTextAsync(ids1[0], "de", "and und");
await CreateTextAsync(ids2[0], "en", "and und");
await CreateTextAsync(Ids1[0], "de", "and und");
await CreateTextAsync(Ids2[0], "en", "and und");
await SearchText(expected: ids1, text: "and");
await SearchText(expected: Ids1, text: "and");
}
[Fact]
public async Task Should_index_cjk_content_and_retrieve()
{
await CreateTextAsync(ids1[0], "zh", "東京大学");
await CreateTextAsync(Ids1[0], "zh", "東京大学");
await SearchText(expected: ids1, text: "東京");
await SearchText(expected: Ids1, text: "東京");
}
}

271
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTestsBase.cs → backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Text/TextIndexerTests.cs

@ -15,22 +15,22 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Json.Objects;
#pragma warning disable SA1401 // Fields should be private
#pragma warning disable CA1859 // Use concrete types when possible for improved performance
namespace Squidex.Domain.Apps.Entities.Contents.Text;
public abstract class TextIndexerTestsBase : GivenContext
public abstract class TextIndexerTests : GivenContext
{
private TextIndexingProcess? process;
protected readonly List<DomainId> ids1 = [DomainId.NewGuid()];
protected readonly List<DomainId> ids2 = [DomainId.NewGuid()];
protected List<DomainId> Ids1 { get; } = [DomainId.NewGuid()];
protected List<DomainId> Ids2 { get; } = [DomainId.NewGuid()];
public virtual bool SupportsQuerySyntax => true;
public virtual bool SupportsGeo => false;
public virtual int WaitAfterUpdate => 0;
public virtual TimeSpan WaitTime => TimeSpan.FromSeconds(30);
public abstract Task<ITextIndex> CreateSutAsync();
@ -50,9 +50,9 @@ public abstract class TextIndexerTestsBase : GivenContext
return;
}
await CreateTextAsync(ids1[0], "iv", "Hello");
await CreateTextAsync(Ids1[0], "iv", "Hello");
await SearchText(expected: ids1, text: "helo~");
await SearchText(expected: Ids1, text: "helo~");
}
[Fact]
@ -63,9 +63,9 @@ public abstract class TextIndexerTestsBase : GivenContext
return;
}
await CreateTextAsync(ids1[0], "en", "City");
await CreateTextAsync(Ids1[0], "en", "City");
await SearchText(expected: ids1, text: "en:city");
await SearchText(expected: Ids1, text: "en:city");
}
[Fact]
@ -79,13 +79,13 @@ public abstract class TextIndexerTestsBase : GivenContext
var field = Guid.NewGuid().ToString();
// Within search radius
await CreateGeoAsync(ids1[0], field, 51.343391192211506, 12.401476788622826);
await CreateGeoAsync(Ids1[0], field, 51.343391192211506, 12.401476788622826);
// Outside of search radius
await CreateGeoAsync(ids2[0], field, 51.30765141427311, 12.379631713912486);
await CreateGeoAsync(Ids2[0], field, 51.30765141427311, 12.379631713912486);
// Within search radius and correct field.
await SearchGeo(expected: ids1, $"{field}.iv", 51.34641682574934, 12.401965298137707);
await SearchGeo(expected: Ids1, $"{field}.iv", 51.34641682574934, 12.401965298137707);
// Within search radius but incorrect field.
await SearchGeo(expected: null, "other.iv", 51.48596429889613, 12.102629469505713);
@ -102,13 +102,13 @@ public abstract class TextIndexerTestsBase : GivenContext
var field = Guid.NewGuid().ToString();
// Within search radius
await CreateGeoJsonAsync(ids1[0], field, 51.343391192211506, 12.401476788622826);
await CreateGeoJsonAsync(Ids1[0], field, 51.343391192211506, 12.401476788622826);
// Outside of search radius
await CreateGeoJsonAsync(ids2[0], field, 51.30765141427311, 12.379631713912486);
await CreateGeoJsonAsync(Ids2[0], field, 51.30765141427311, 12.379631713912486);
// Within search radius and correct field.
await SearchGeo(expected: ids1, $"{field}.iv", 51.34641682574934, 12.401965298137707);
await SearchGeo(expected: Ids1, $"{field}.iv", 51.34641682574934, 12.401965298137707);
// Within search radius but incorrect field.
await SearchGeo(expected: null, "other.iv", 51.48596429889613, 12.102629469505713);
@ -117,19 +117,19 @@ public abstract class TextIndexerTestsBase : GivenContext
[Fact]
public async Task Should_search_by_app()
{
await CreateTextAsync(ids1[0], "iv", "Hello");
await CreateTextAsync(Ids1[0], "iv", "Hello");
await SearchByAppText(expected: ids1, text: "helo~");
await SearchByAppText(expected: Ids1, text: "hello");
}
[Fact]
public async Task Should_index_invariant_content_and_retrieve()
{
await CreateTextAsync(ids1[0], "iv", "Hello");
await CreateTextAsync(ids2[0], "iv", "World");
await CreateTextAsync(Ids1[0], "iv", "Hello");
await CreateTextAsync(Ids2[0], "iv", "World");
await SearchText(expected: ids1, text: "Hello");
await SearchText(expected: ids2, text: "World");
await SearchText(expected: Ids1, text: "Hello");
await SearchText(expected: Ids2, text: "World");
await SearchText(expected: null, text: "Hello", SearchScope.Published);
await SearchText(expected: null, text: "World", SearchScope.Published);
@ -138,201 +138,201 @@ public abstract class TextIndexerTestsBase : GivenContext
[Fact]
public async Task Should_update_draft_only()
{
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
await UpdateTextAsync(ids1[0], "iv", "V2");
await UpdateTextAsync(Ids1[0], "iv", "Version2");
await SearchText(expected: null, text: "V1", target: SearchScope.All);
await SearchText(expected: null, text: "V1", target: SearchScope.Published);
await SearchText(expected: null, text: "Version1", target: SearchScope.All);
await SearchText(expected: null, text: "Version1", target: SearchScope.Published);
await SearchText(expected: ids1, text: "V2", target: SearchScope.All);
await SearchText(expected: null, text: "V2", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.All);
await SearchText(expected: null, text: "Version2", target: SearchScope.Published);
}
[Fact]
public async Task Should_update_draft_only_multiple_times()
{
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
await UpdateTextAsync(ids1[0], "iv", "V2");
await UpdateTextAsync(ids1[0], "iv", "V3");
await UpdateTextAsync(Ids1[0], "iv", "Version2");
await UpdateTextAsync(Ids1[0], "iv", "Version3");
await SearchText(expected: null, text: "V2", target: SearchScope.All);
await SearchText(expected: null, text: "V2", target: SearchScope.Published);
await SearchText(expected: null, text: "Version2", target: SearchScope.All);
await SearchText(expected: null, text: "Version2", target: SearchScope.Published);
await SearchText(expected: ids1, text: "V3", target: SearchScope.All);
await SearchText(expected: null, text: "V3", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version3", target: SearchScope.All);
await SearchText(expected: null, text: "Version3", target: SearchScope.Published);
}
[Fact]
public async Task Should_also_serve_published_after_publish()
{
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
await PublishAsync(ids1[0]);
await PublishAsync(Ids1[0]);
await SearchText(expected: ids1, text: "V1", target: SearchScope.All);
await SearchText(expected: ids1, text: "V1", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.Published);
}
[Fact]
public async Task Should_also_update_published_content()
{
// Create initial content.
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
// Publish the content.
await PublishAsync(ids1[0]);
await PublishAsync(Ids1[0]);
// Update the published content once.
await UpdateTextAsync(ids1[0], "iv", "V2");
await UpdateTextAsync(Ids1[0], "iv", "Version2");
await SearchText(expected: null, text: "V1", target: SearchScope.All);
await SearchText(expected: null, text: "V1", target: SearchScope.Published);
await SearchText(expected: null, text: "Version1", target: SearchScope.All);
await SearchText(expected: null, text: "Version1", target: SearchScope.Published);
await SearchText(expected: ids1, text: "V2", target: SearchScope.All);
await SearchText(expected: ids1, text: "V2", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.Published);
}
[Fact]
public async Task Should_also_update_published_content_multiple_times()
{
// Create initial content.
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
// Publish the content.
await PublishAsync(ids1[0]);
await PublishAsync(Ids1[0]);
// Update the published content twice.
await UpdateTextAsync(ids1[0], "iv", "V2");
await UpdateTextAsync(ids1[0], "iv", "V3");
await UpdateTextAsync(Ids1[0], "iv", "Version2");
await UpdateTextAsync(Ids1[0], "iv", "Version3");
await SearchText(expected: null, text: "V2", target: SearchScope.All);
await SearchText(expected: null, text: "V2", target: SearchScope.Published);
await SearchText(expected: null, text: "Version2", target: SearchScope.All);
await SearchText(expected: null, text: "Version2", target: SearchScope.Published);
await SearchText(expected: ids1, text: "V3", target: SearchScope.All);
await SearchText(expected: ids1, text: "V3", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version3", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version3", target: SearchScope.Published);
}
[Fact]
public async Task Should_simulate_new_version()
{
// Create initial content.
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
// Publish the content.
await PublishAsync(ids1[0]);
await PublishAsync(Ids1[0]);
await SearchText(expected: ids1, text: "V1", target: SearchScope.All);
await SearchText(expected: ids1, text: "V1", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.Published);
// Create a new version, the value is still the same as old version.
await CreateDraftAsync(ids1[0]);
await CreateDraftAsync(Ids1[0]);
await SearchText(expected: ids1, text: "V1", target: SearchScope.All);
await SearchText(expected: ids1, text: "V1", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.Published);
// Make an update, this updates the new version only.
await UpdateTextAsync(ids1[0], "iv", "V2");
await UpdateTextAsync(Ids1[0], "iv", "Version2");
await SearchText(expected: null, text: "V1", target: SearchScope.All);
await SearchText(expected: ids1, text: "V1", target: SearchScope.Published);
await SearchText(expected: null, text: "Version1", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.Published);
await SearchText(expected: ids1, text: "V2", target: SearchScope.All);
await SearchText(expected: null, text: "V2", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.All);
await SearchText(expected: null, text: "Version2", target: SearchScope.Published);
// Publish the new version to get rid of the "V1" version.
await PublishAsync(ids1[0]);
// Publish the new version to get rid of the "Version1" version.
await PublishAsync(Ids1[0]);
await SearchText(expected: null, text: "V1", target: SearchScope.All);
await SearchText(expected: null, text: "V1", target: SearchScope.Published);
await SearchText(expected: null, text: "Version1", target: SearchScope.All);
await SearchText(expected: null, text: "Version1", target: SearchScope.Published);
await SearchText(expected: ids1, text: "V2", target: SearchScope.All);
await SearchText(expected: ids1, text: "V2", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.Published);
// Unpublish the version
await UnpublishAsync(ids1[0]);
await UnpublishAsync(Ids1[0]);
await SearchText(expected: ids1, text: "V2", target: SearchScope.All);
await SearchText(expected: null, text: "V2", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.All);
await SearchText(expected: null, text: "Version2", target: SearchScope.Published);
}
[Fact]
public async Task Should_simulate_new_version_with_migration()
{
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
// Publish the content.
await PublishAsync(ids1[0]);
await PublishAsync(Ids1[0]);
await SearchText(expected: ids1, text: "V1", target: SearchScope.All);
await SearchText(expected: ids1, text: "V1", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.Published);
// Create a new version, his updates the new version also.
await CreateDraftWithTextAsync(ids1[0], "iv", "V2");
await CreateDraftWithTextAsync(Ids1[0], "iv", "Version2");
await SearchText(expected: null, text: "V1", target: SearchScope.All);
await SearchText(expected: ids1, text: "V1", target: SearchScope.Published);
await SearchText(expected: null, text: "Version1", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.Published);
await SearchText(expected: ids1, text: "V2", target: SearchScope.All);
await SearchText(expected: null, text: "V2", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version2", target: SearchScope.All);
await SearchText(expected: null, text: "Version2", target: SearchScope.Published);
}
[Fact]
public async Task Should_simulate_content_reversion()
{
await CreateTextAsync(ids1[0], "iv", "V1");
await CreateTextAsync(Ids1[0], "iv", "Version1");
// Publish the content.
await PublishAsync(ids1[0]);
await PublishAsync(Ids1[0]);
// Create a new version, the value is still the same as old version.
await CreateDraftAsync(ids1[0]);
await CreateDraftAsync(Ids1[0]);
// Make an update, this updates the new version only.
await UpdateTextAsync(ids1[0], "iv", "V2");
await UpdateTextAsync(Ids1[0], "iv", "Version2");
// Make an update, this updates the new version only.
await DeleteDraftAsync(ids1[0]);
await DeleteDraftAsync(Ids1[0]);
await SearchText(expected: ids1, text: "V1", target: SearchScope.All);
await SearchText(expected: ids1, text: "V1", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version1", target: SearchScope.Published);
await SearchText(expected: null, text: "V2", target: SearchScope.All);
await SearchText(expected: null, text: "V2", target: SearchScope.Published);
await SearchText(expected: null, text: "Version2", target: SearchScope.All);
await SearchText(expected: null, text: "Version2", target: SearchScope.Published);
// Make an update, this updates the current version only.
await UpdateTextAsync(ids1[0], "iv", "V3");
await UpdateTextAsync(Ids1[0], "iv", "Version3");
await SearchText(expected: ids1, text: "V3", target: SearchScope.All);
await SearchText(expected: ids1, text: "V3", target: SearchScope.Published);
await SearchText(expected: Ids1, text: "Version3", target: SearchScope.All);
await SearchText(expected: Ids1, text: "Version3", target: SearchScope.Published);
}
[Fact]
public async Task Should_delete_documents_from_index()
{
await CreateTextAsync(ids1[0], "iv", "V1_1");
await CreateTextAsync(ids2[0], "iv", "V2_1");
await CreateTextAsync(Ids1[0], "iv", "Version1_1");
await CreateTextAsync(Ids2[0], "iv", "Version2_1");
await SearchText(expected: ids1, text: "V1_1");
await SearchText(expected: ids2, text: "V2_1");
await SearchText(expected: Ids1, text: "Version1_1");
await SearchText(expected: Ids2, text: "Version2_1");
await DeleteAsync(ids1[0]);
await DeleteAsync(Ids1[0]);
await SearchText(expected: null, text: "V1_1");
await SearchText(expected: ids2, text: "V2_1");
await SearchText(expected: null, text: "Version1_1");
await SearchText(expected: Ids2, text: "Version2_1");
}
[Fact]
public async Task Should_index_invalid_geodata()
{
await CreateGeoAsync(ids1[0], "field", 144.34, -200);
await CreateGeoAsync(Ids1[0], "field", 144.34, -200);
}
[Fact]
public async Task Should_index_invalid_geojsondata()
{
await CreateGeoJsonAsync(ids1[0], "field", 144.34, -200);
await CreateGeoJsonAsync(Ids1[0], "field", 144.34, -200);
}
protected Task CreateTextAsync(DomainId id, string language, string text)
@ -404,8 +404,6 @@ public abstract class TextIndexerTestsBase : GivenContext
contentEvent.SchemaId = SchemaId;
await sut.On(Enumerable.Repeat(Envelope.Create<IEvent>(contentEvent), 1));
await Task.Delay(WaitAfterUpdate, default);
}
private static ContentData TextData(string language, string text)
@ -444,17 +442,9 @@ public abstract class TextIndexerTestsBase : GivenContext
SchemaId = default,
};
var sut = await GetProcessAsync();
var actual = await SearchAsync(i => i.SearchAsync(App, query, target, default), x => IsExpected(x, expected));
var actual = await sut.TextIndex.SearchAsync(App, query, target, default);
if (expected != null)
{
actual.Should().BeEquivalentTo(expected.ToHashSet());
}
else
{
actual.Should().BeEmpty();
}
AssertIds(actual, expected);
}
protected async Task SearchText(List<DomainId>? expected, string text, SearchScope target = SearchScope.All)
@ -464,17 +454,9 @@ public abstract class TextIndexerTestsBase : GivenContext
RequiredSchemaIds = [SchemaId.Id],
};
var sut = await GetProcessAsync();
var actual = await SearchAsync(i => i.SearchAsync(App, query, target, default), x => IsExpected(x, expected));
var actual = await sut.TextIndex.SearchAsync(App, query, target, default);
if (expected != null)
{
actual.Should().BeEquivalentTo(expected.ToHashSet());
}
else
{
actual.Should().BeEmpty();
}
AssertIds(actual, expected);
}
protected async Task SearchByAppText(List<DomainId>? expected, string text, SearchScope target = SearchScope.All)
@ -484,17 +466,42 @@ public abstract class TextIndexerTestsBase : GivenContext
PreferredSchemaId = Schema.Id,
};
var actual = await SearchAsync(i => i.SearchAsync(App, query, target, default), x => IsExpected(x, expected));
AssertIds(actual, expected);
}
private async Task<T?> SearchAsync<T>(Func<ITextIndex, Task<T>> query, Predicate<T> isValid)
{
var sut = await GetProcessAsync();
var actual = await sut.TextIndex.SearchAsync(App, query, target, default);
if (expected != null)
using var cts = new CancellationTokenSource(WaitTime);
while (!cts.IsCancellationRequested)
{
actual.Should().BeEquivalentTo(expected.ToHashSet());
var actual = await query(sut.TextIndex);
if (isValid(actual))
{
return actual;
}
await Task.Delay(100, default);
}
else
{
return default;
}
private static bool IsExpected(List<DomainId>? actual, List<DomainId>? expected)
{
return expected != null ?
actual != null && actual.SetEquals(expected) :
actual == null || actual.Count == 0;
}
private static object AssertIds(List<DomainId>? actual, List<DomainId>? expected)
{
return expected != null ?
actual.Should().BeEquivalentTo(expected) :
actual.Should().BeEmpty();
}
}
private async Task<TextIndexingProcess> GetProcessAsync()

9
backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs

@ -41,13 +41,14 @@ public class QueryFromODataTests
new FilterField(FilterSchema.Any, "properties"),
};
var filterSchema = new FilterSchema(FilterSchemaType.Object)
var queryModel = new QueryModel
{
Fields = fields.ToReadonlyList(),
Schema = new FilterSchema(FilterSchemaType.Object)
{
Fields = fields.ToReadonlyList(),
},
};
var queryModel = new QueryModel { Schema = filterSchema };
EdmModel = queryModel.ConvertToEdm("Squidex", "Content");
}

23
backend/tests/mssql/Dockerfile

@ -0,0 +1,23 @@
# Base OS layer: Latest Ubuntu LTS
FROM --platform=linux/amd64 ubuntu:focal
# Install prerequistes since it is needed to get repo config for SQL server
RUN export DEBIAN_FRONTEND=noninteractive && \
apt-get update && \
apt-get install -yq curl apt-transport-https gnupg && \
# Get official Microsoft repository configuration
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add - && \
curl https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb --output packages-microsoft-prod.deb && dpkg -i packages-microsoft-prod.deb && \
curl https://packages.microsoft.com/config/ubuntu/20.04/mssql-server-2022.list | tee /etc/apt/sources.list.d/mssql-server.list && \
apt-get update && \
# Install SQL Server from apt
apt-get install -y mssql-server && \
# Install optional packages
apt-get install -y mssql-server-fts && \
ACCEPT_EULA=Y apt-get install -y mssql-tools && \
# Cleanup the Dockerfile
apt-get clean && \
rm -rf /var/lib/apt/lists
# Run SQL Server process
CMD /opt/mssql/bin/sqlservr

4
tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs

@ -168,9 +168,9 @@ public class AssetFormatTests(CreatedAppFixture fixture) : IClassFixture<Created
// Should parse audio metadata.
Assert.False(asset.IsImage);
Assert.Equal("00:00:28.2708750", asset.Metadata["duration"]);
Assert.Equal(128L, asset.Metadata["audioBitrate"]);
Assert.Equal("00:00:27.7447500", asset.Metadata["duration"]);
Assert.Equal(2L, asset.Metadata["audioChannels"]);
Assert.Equal(128L, asset.Metadata["audioBitrate"]);
Assert.Equal(44100L, asset.Metadata["audioSampleRate"]);
Assert.Equal(AssetType.Audio, asset.Type);

1
tools/TestSuite/TestSuite.ApiTests/ContentQueryFixture.cs

@ -42,6 +42,7 @@ public sealed class ContentQueryFixture : TestSchemaFixtureBase
index + 100,
index,
oldFormat: index % 2 == 1),
Searchable = $"text{index}",
Localized = new Dictionary<string, string?>
{
["en"] = index.ToString(CultureInfo.InvariantCulture)

10
tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs

@ -377,10 +377,9 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "MongoOnly")]
public async Task Should_query_by_full_text_with_odata()
{
var q = new ContentQuery { Search = "2" };
var q = new ContentQuery { Search = "text2" };
var items = await _.Contents.PollAsync(q, x => true);
@ -388,14 +387,13 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "MongoOnly")]
public async Task Should_query_by_full_text_with_json()
{
var q = new ContentQuery
{
JsonQuery = new
{
fullText = "2"
fullText = "text2"
}
};
@ -405,7 +403,6 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "MongoOnly")]
public async Task Should_query_by_near_location_with_odata()
{
var q = new ContentQuery { Filter = "geo.distance(data/geo/iv, geography'POINT(103 3)') lt 1000" };
@ -416,7 +413,6 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "MongoOnly")]
public async Task Should_query_by_near_location_with_json()
{
var q = new ContentQuery
@ -443,7 +439,6 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "MongoOnly")]
public async Task Should_query_by_near_geoson_location_with_odata()
{
var q = new ContentQuery { Filter = "geo.distance(data/geo/iv, geography'POINT(104 4)') lt 1000" };
@ -503,7 +498,6 @@ public class ContentQueryTests(ContentQueryFixture fixture) : IClassFixture<Cont
}
[Fact]
[Trait("Category", "MongoOnly")]
public async Task Should_query_by_near_geoson_location_with_json()
{
var q = new ContentQuery

4
tools/TestSuite/TestSuite.ApiTests/Verify/AssetFormatTests.Should_upload_audio_mp3.verified.txt

@ -7,13 +7,13 @@
Slug: sampleaudio-0.4mb.mp3,
MimeType: audio/mp3,
FileType: mp3,
MetadataText: 00:00:28.2708750, 433.5 kB,
MetadataText: 00:00:27.7447500, 433.5 kB,
Metadata: {
audioBitrate: 128,
audioChannels: 2,
audioSampleRate: 44100,
description: MPEG Version 1 Audio, Layer 3,
duration: 00:00:28.2708750
duration: 00:00:27.7447500
},
Tags: [
type/mp3

13
tools/TestSuite/TestSuite.Shared/Model/TestEntity.cs

@ -42,6 +42,14 @@ public sealed class TestEntity : Content<TestEntityData>
}
},
new UpsertSchemaFieldDto
{
Name = TestEntityData.SearchableField,
Properties = new StringFieldPropertiesDto
{
IsRequired = false
}
},
new UpsertSchemaFieldDto
{
Name = TestEntityData.GeoField,
Properties = new GeolocationFieldPropertiesDto
@ -123,6 +131,8 @@ public sealed class TestEntityData
{
public static readonly string LocalizedField = nameof(Localized).ToLowerInvariant();
public static readonly string SearchableField = nameof(Searchable).ToLowerInvariant();
public static readonly string StringField = nameof(String).ToLowerInvariant();
public static readonly string NumberField = nameof(Number).ToLowerInvariant();
@ -144,6 +154,9 @@ public sealed class TestEntityData
[JsonConverter(typeof(InvariantConverter))]
public string? String { get; set; }
[JsonConverter(typeof(InvariantConverter))]
public string? Searchable { get; set; }
[JsonConverter(typeof(InvariantConverter))]
public JToken? Json { get; set; }

15
tools/TestSuite/docker-compose.yml

@ -7,7 +7,7 @@ services:
- internal
db_postgres:
image: "postgres"
image: "postgis/postgis"
ports:
- "5432:5432"
environment:
@ -41,11 +41,12 @@ services:
retries: 10
db_sqlserver:
image: squidex/mssql-dev
image: squidex/mssql-dev:2
ports:
- "1433:1433"
environment:
- SA_PASSWORD=1q2w3e!R
- SA_USER=none
- ACCEPT_EULA=Y
networks:
- internal
@ -54,6 +55,16 @@ services:
interval: 10s
retries: 10
db_sqlserver_init:
image: mcr.microsoft.com/mssql-tools
entrypoint: >
/opt/mssql-tools/bin/sqlcmd -S db_sqlserver -U sa -P 1q2w3e!R -d master -Q 'CREATE DATABASE ''squidex'''
networks:
- internal
depends_on:
db_sqlserver:
condition: service_healthy
# Base configuration
squidex_mongo1:
extends:

2
tools/TestSuite/sql-server/Dockerfile

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04
FROM vibs2006/sql_server_fts
# Switch to root to perform installation
USER root

Loading…
Cancel
Save