diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs index 69285d96d..4cfd84d2e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs @@ -843,6 +843,15 @@ namespace Squidex.Domain.Apps.Core { } } + /// + /// Looks up a localized string similar to The collation or language code that should be used to compare strings, e.g. for sorting.. + /// + public static string QueryCollation { + get { + return ResourceManager.GetString("QueryCollation", resourceCulture); + } + } + /// /// Looks up a localized string similar to Optional OData filter.. /// @@ -879,6 +888,15 @@ namespace Squidex.Domain.Apps.Core { } } + /// + /// Looks up a localized string similar to Picks N random of elements from the schema.. + /// + public static string QueryRandom { + get { + return ResourceManager.GetString("QueryRandom", resourceCulture); + } + } + /// /// Looks up a localized string similar to Optional OData full text search.. /// diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx index 244ff2e51..9df238aa6 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx @@ -378,6 +378,9 @@ The current operation. + + The collation or language code that should be used to compare strings, e.g. for sorting. + Optional OData filter. @@ -390,6 +393,9 @@ JSON query as well formatted json string. Overrides all other query parameters, except 'ids'. + + Picks N random of elements from the schema. + Optional OData full text search. diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs index 07fbf78c4..94eb3390e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/Extensions.cs @@ -86,6 +86,13 @@ public static class Extensions public static async Task> QueryContentsAsync(this IMongoCollection collection, FilterDefinition filter, ClrQuery query, Q q, CancellationToken ct) { + Collation? collation = null; + + if (query.Collation != null) + { + collation = new Collation(query.Collation); + } + if (query.Skip > 0 && !query.IsSatisfiedByIndex()) { // If we have to skip over items, we could reach the limit of the sort buffer, therefore get the ids and all filter fields only @@ -97,10 +104,15 @@ public static class Extensions projection = projection.Include(field); } + var aggregateOptions = new AggregateOptions + { + Collation = collation, + }; + if (query.Random > 0) { var ids = - await collection.Aggregate() + await collection.Aggregate(aggregateOptions) .Match(filter) .Project(projection) .QuerySort(query) @@ -118,7 +130,7 @@ public static class Extensions } var joined = - await collection.Aggregate() + await collection.Aggregate(aggregateOptions) .Match(filter) .Project(projection) .QuerySort(query) @@ -138,8 +150,13 @@ public static class Extensions return joined.Select(x => x.Joined[0]).ToList(); } + var findOptions = new FindOptions + { + Collation = collation, + }; + var result = - collection.Find(filter) + collection.Find(filter, findOptions) .QuerySort(query) .QuerySkip(query) .QueryLimit(query) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs index d63bd7a5a..9282428eb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs @@ -185,6 +185,18 @@ internal static class ContentActions Description = FieldDescriptions.QuerySearch, DefaultValue = null }, + new QueryArgument(Scalars.String) + { + Name = "collation", + Description = FieldDescriptions.QueryCollation, + DefaultValue = null + }, + new QueryArgument(Scalars.Int) + { + Name = "random", + Description = FieldDescriptions.QueryRandom, + DefaultValue = null + }, ]; public static readonly IFieldResolver Query = Resolvers.Async(async (_, fieldContext, context) => diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs b/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs index 21ef51e5f..d12555ff9 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/EdmModelExtensions.cs @@ -70,6 +70,7 @@ public static class EdmModelExtensions parser.ParseFilter(query); parser.ParseSort(query); parser.ParseRandom(query); + parser.ParseCollation(query); } return query; diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs b/backend/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs index 9729b406c..364685152 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/FilterBuilder.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) diff --git a/backend/src/Squidex.Infrastructure/Queries/OData/LimitExtensions.cs b/backend/src/Squidex.Infrastructure/Queries/OData/LimitExtensions.cs index fb3c13779..fca6bc330 100644 --- a/backend/src/Squidex.Infrastructure/Queries/OData/LimitExtensions.cs +++ b/backend/src/Squidex.Infrastructure/Queries/OData/LimitExtensions.cs @@ -47,4 +47,16 @@ public static class LimitExtensions result.Random = random; } } + + public static void ParseCollation(this ODataUriParser query, ClrQuery result) + { + var customQueries = query.CustomQueryOptions; + + var randomQuery = customQueries.FirstOrDefault(x => + string.Equals(x.Key, "collation", StringComparison.OrdinalIgnoreCase) || + string.Equals(x.Key, "collate", StringComparison.OrdinalIgnoreCase) || + string.Equals(x.Key, "$collation", StringComparison.OrdinalIgnoreCase)); + + result.Collation = randomQuery.Value; + } } diff --git a/backend/src/Squidex.Infrastructure/Queries/Query.cs b/backend/src/Squidex.Infrastructure/Queries/Query.cs index a371e89cc..bfe7dea1b 100644 --- a/backend/src/Squidex.Infrastructure/Queries/Query.cs +++ b/backend/src/Squidex.Infrastructure/Queries/Query.cs @@ -15,6 +15,8 @@ public class Query public string? FullText { get; set; } + public string? Collation { get; set; } + public long Skip { get; set; } public long Take { get; set; } = long.MaxValue; @@ -61,6 +63,12 @@ public class Query sb.Append($"FullText: '{FullText.Replace('\'', '\'')}'"); } + if (Collation != null) + { + sb.AppendIfNotEmpty("; "); + sb.Append($"Collation: '{Collation}'"); + } + if (Skip > 0) { sb.AppendIfNotEmpty("; "); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index fb349f2f0..e3945531c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -51,13 +51,13 @@ public class GraphQLQueriesTests : GraphQLTestBase } [Fact] - public async Task Should_query_contents_with_full_text() + public async Task Should_query_contents_with_full_text_and_collation() { var contentId = DomainId.NewGuid(); var content = TestContent.Create(contentId); A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), content.SchemaId.Id.ToString(), - A.That.Matches(x => x.QueryAsOdata == "?$skip=0&$search=\"Hello\"" && x.NoTotal), + A.That.Matches(x => x.QueryAsOdata == "?$skip=0&$search=\"Hello\"&$collation=tr" && x.NoTotal), A._)) .Returns(ResultList.CreateFrom(0, content)); @@ -65,7 +65,46 @@ public class GraphQLQueriesTests : GraphQLTestBase { Query = @" query { - queryMySchemaContents(search: 'Hello') { + queryMySchemaContents(search: 'Hello', collation: 'tr') { + {fields} + } + }", + Args = new + { + fields = TestContent.AllFlatFields + } + }); + + var expected = new + { + data = new + { + queryMySchemaContents = new[] + { + TestContent.FlatResponse(content) + } + } + }; + + AssertResult(expected, actual); + } + + [Fact] + public async Task Should_query_contents_with_random() + { + var contentId = DomainId.NewGuid(); + var content = TestContent.Create(contentId); + + A.CallTo(() => contentQuery.QueryAsync(MatchsContentContext(), content.SchemaId.Id.ToString(), + A.That.Matches(x => x.QueryAsOdata == "?$skip=0&$random=42" && x.NoTotal), + A._)) + .Returns(ResultList.CreateFrom(0, content)); + + var actual = await ExecuteAsync(new TestQuery + { + Query = @" + query { + queryMySchemaContents(random: 42) { {fields} } }", diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs index bbcf5fa4c..21b2b2852 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromJsonTests.cs @@ -590,6 +590,14 @@ public sealed class QueryFromJsonTests AssertQuery(json, "FullText: 'Hello'"); } + [Fact] + public void Should_parse_collation() + { + var json = new { Collation = "Collation" }; + + AssertQuery(json, "Collation: 'Collation'"); + } + [Fact] public void Should_parse_sort() { diff --git a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs index 27c7cca25..9bf3274c3 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Queries/QueryFromODataTests.cs @@ -449,6 +449,24 @@ public class QueryFromODataTests Assert.Equal(o, i); } + [Fact] + public void Should_parse_collation1() + { + var i = _Q("collation=Collation"); + var o = _C("Collation: 'Collation'"); + + Assert.Equal(o, i); + } + + [Fact] + public void Should_parse_collation2() + { + var i = _Q("$collation=Collation"); + var o = _C("Collation: 'Collation'"); + + Assert.Equal(o, i); + } + [Fact] public void Should_text_and_multiple_terms() { diff --git a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs index 4a6004155..97740863b 100644 --- a/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs +++ b/tools/TestSuite/TestSuite.ApiTests/AssetTests.cs @@ -441,7 +441,7 @@ public class AssetTests : IClassFixture { var downloaded = new MemoryStream(); - using (var assetStream = await _.Client.Assets.GetAssetContentBySlugAsync(asset_2.Id, string.Empty)) + using (var assetStream = await _.Client.Assets.GetAssetContentBySlugAsync(asset_2.Id)) { await assetStream.Stream.CopyToAsync(downloaded); } @@ -796,7 +796,7 @@ public class AssetTests : IClassFixture foreach (var asset in assets.Items) { - var content = await client.Assets.GetAssetContentBySlugAsync(asset.Id, string.Empty, deleted: true); + var content = await client.Assets.GetAssetContentBySlugAsync(asset.Id, deleted: true); await client.Assets.PostAssetAsync(id: asset.Id, file: new FileParameter(content.Stream, asset.FileName, asset.MimeType)); } diff --git a/tools/TestSuite/TestSuite.ApiTests/ContentCollationTests.cs b/tools/TestSuite/TestSuite.ApiTests/ContentCollationTests.cs new file mode 100644 index 000000000..861913cdd --- /dev/null +++ b/tools/TestSuite/TestSuite.ApiTests/ContentCollationTests.cs @@ -0,0 +1,93 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json; +using Squidex.ClientLibrary; +using TestSuite.Fixtures; +using TestSuite.Model; + +namespace TestSuite.ApiTests; + +#pragma warning disable SA1300 // Element should begin with upper-case letter +#pragma warning disable SA1507 // Code should not contain multiple blank lines in a row + +public class ContentCollationTests : IClassFixture +{ + private readonly string schemaName = $"schema-{Guid.NewGuid()}"; + + public CreatedAppFixture _ { get; } + + public ContentCollationTests(CreatedAppFixture fixture) + { + _ = fixture; + } + + public sealed class SimpleEntityData + { + public static readonly string StringField = nameof(String).ToLowerInvariant(); + + [JsonConverter(typeof(InvariantConverter))] + public string? String { get; set; } + } + + public sealed class SimpleEntity : Content + { + } + + [Fact] + public async Task Should_search_based_on_collation() + { + // STEP 0: Create schema. + var schemaRequest = new CreateSchemaDto + { + Name = schemaName, + Fields = + [ + new UpsertSchemaFieldDto + { + Name = SimpleEntityData.StringField, + Properties = new StringFieldPropertiesDto() + }, + ], + IsPublished = true + }; + + await _.Client.Schemas.PostSchemaAsync(schemaRequest); + + // STEP 1: Create content. + var contents = _.Client.Contents(schemaName); + + await contents.CreateAsync(new SimpleEntityData + { + String = "İstanbul" + }, ContentCreateOptions.AsPublish); + + await contents.CreateAsync(new SimpleEntityData + { + String = "Mersin" + }, ContentCreateOptions.AsPublish); + + await contents.CreateAsync(new SimpleEntityData + { + String = "Lüleburgaz" + }, ContentCreateOptions.AsPublish); + + + // STEP 2: Get sorted contents. + var sorted_1 = await contents.GetAsync(new ContentQuery { OrderBy = $"data/{TestEntityData.StringField}/iv asc" }); + var sortedNames_1 = sorted_1.Items.Select(x => x.Data.String).ToList(); + + Assert.Equal(new string[] { "Lüleburgaz", "Mersin", "İstanbul" }, sortedNames_1); + + + // STEP 3: Get with collation. + var sorted_2 = await contents.GetAsync(new ContentQuery { OrderBy = $"data/{TestEntityData.StringField}/iv asc", Collation = "tr" }); + var sortedNames_2 = sorted_2.Items.Select(x => x.Data.String).ToList(); + + Assert.Equal(new string[] { "İstanbul", "Lüleburgaz", "Mersin" }, sortedNames_2); + } +} diff --git a/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index b40cea3fd..1204d7fb0 100644 --- a/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -7,20 +7,20 @@ enable - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj b/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj index 3c8d60379..ac58fcf17 100644 --- a/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj +++ b/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj @@ -7,13 +7,13 @@ enable - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + all runtime; build; native; contentfiles; analyzers diff --git a/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj b/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj index 642b4d6a1..c187e95c3 100644 --- a/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj +++ b/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj @@ -7,22 +7,22 @@ enable - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + - - - + + + - - + +