mirror of https://github.com/Squidex/squidex.git
Browse Source
* Azure cognitive search. * Azure search finalized. * More fixes. * Remove api key. * Document configuration options. * Reverts the schema-id field. * Geosearch tests. * Elastic geo search support. * Delete old file. * Better support for query syntax in elastic. * Temp * More fixes. * Code simplified. * Add settings to json file. * Fix testspull/793/head
committed by
GitHub
49 changed files with 2390 additions and 556 deletions
@ -0,0 +1,140 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Elasticsearch.Net; |
|||
|
|||
namespace Squidex.Extensions.Text.ElasticSearch |
|||
{ |
|||
public static class ElasticSearchIndexDefinition |
|||
{ |
|||
private static readonly Dictionary<string, string> FieldPaths; |
|||
private static readonly Dictionary<string, string> FieldAnalyzers = new Dictionary<string, string> |
|||
{ |
|||
["ar"] = "arabic", |
|||
["hy"] = "armenian", |
|||
["eu"] = "basque", |
|||
["bn"] = "bengali", |
|||
["br"] = "brazilian", |
|||
["bg"] = "bulgarian", |
|||
["ca"] = "catalan", |
|||
["zh"] = "cjk", |
|||
["ja"] = "cjk", |
|||
["ko"] = "cjk", |
|||
["cs"] = "czech", |
|||
["da"] = "danish", |
|||
["nl"] = "dutch", |
|||
["en"] = "english", |
|||
["fi"] = "finnish", |
|||
["fr"] = "french", |
|||
["gl"] = "galician", |
|||
["de"] = "german", |
|||
["el"] = "greek", |
|||
["hi"] = "hindi", |
|||
["hu"] = "hungarian", |
|||
["id"] = "indonesian", |
|||
["ga"] = "irish", |
|||
["it"] = "italian", |
|||
["lv"] = "latvian", |
|||
["lt"] = "lithuanian", |
|||
["no"] = "norwegian", |
|||
["pt"] = "portuguese", |
|||
["ro"] = "romanian", |
|||
["ru"] = "russian", |
|||
["ku"] = "sorani", |
|||
["es"] = "spanish", |
|||
["sv"] = "swedish", |
|||
["tr"] = "turkish", |
|||
["th"] = "thai" |
|||
}; |
|||
|
|||
static ElasticSearchIndexDefinition() |
|||
{ |
|||
FieldPaths = FieldAnalyzers.ToDictionary(x => x.Key, x => $"texts.{x.Key}"); |
|||
} |
|||
|
|||
public static string GetFieldName(string key) |
|||
{ |
|||
if (FieldAnalyzers.ContainsKey(key)) |
|||
{ |
|||
return key; |
|||
} |
|||
|
|||
if (key.Length > 0) |
|||
{ |
|||
var language = key[2..]; |
|||
|
|||
if (FieldAnalyzers.ContainsKey(language)) |
|||
{ |
|||
return language; |
|||
} |
|||
} |
|||
|
|||
return "iv"; |
|||
} |
|||
|
|||
public static string GetFieldPath(string key) |
|||
{ |
|||
if (FieldPaths.TryGetValue(key, out var path)) |
|||
{ |
|||
return path; |
|||
} |
|||
|
|||
if (key.Length > 0) |
|||
{ |
|||
var language = key[2..]; |
|||
|
|||
if (FieldPaths.TryGetValue(language, out path)) |
|||
{ |
|||
return path; |
|||
} |
|||
} |
|||
|
|||
return "texts.iv"; |
|||
} |
|||
|
|||
public static async Task ApplyAsync(IElasticLowLevelClient elastic, string indexName, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var query = new |
|||
{ |
|||
properties = new Dictionary<string, object> |
|||
{ |
|||
["geoObject"] = new |
|||
{ |
|||
type ="geo_point" |
|||
} |
|||
} |
|||
}; |
|||
|
|||
foreach (var (key, analyzer) in FieldAnalyzers) |
|||
{ |
|||
query.properties[GetFieldPath(key)] = new |
|||
{ |
|||
type = "text", |
|||
analyzer |
|||
}; |
|||
} |
|||
|
|||
var result = await elastic.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(query), ctx: ct); |
|||
|
|||
if (!result.Success) |
|||
{ |
|||
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); |
|||
} |
|||
} |
|||
|
|||
private static PostData CreatePost<T>(T data) |
|||
{ |
|||
return new SerializableData<T>(data); |
|||
} |
|||
} |
|||
} |
|||
@ -1,226 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Elasticsearch.Net; |
|||
|
|||
namespace Squidex.Extensions.Text.ElasticSearch |
|||
{ |
|||
public static class ElasticSearchMapping |
|||
{ |
|||
public static async Task ApplyAsync(IElasticLowLevelClient elastic, string indexName, |
|||
CancellationToken ct = default) |
|||
{ |
|||
var query = new |
|||
{ |
|||
properties = new Dictionary<string, object> |
|||
{ |
|||
["texts.ar"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "arabic" |
|||
}, |
|||
["texts.hy"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "armenian" |
|||
}, |
|||
["texts.eu"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "basque" |
|||
}, |
|||
["texts.bn"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "bengali" |
|||
}, |
|||
["texts.br"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "brazilian" |
|||
}, |
|||
["texts.bg"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "bulgarian" |
|||
}, |
|||
["texts.ca"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "catalan" |
|||
}, |
|||
["texts.zh"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "cjk" |
|||
}, |
|||
["texts.ja"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "cjk" |
|||
}, |
|||
["texts.ko"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "cjk" |
|||
}, |
|||
["texts.cs"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "czech" |
|||
}, |
|||
["texts.da"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "danish" |
|||
}, |
|||
["texts.nl"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "dutch" |
|||
}, |
|||
["texts.en"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "english" |
|||
}, |
|||
["texts.fi"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "finnish" |
|||
}, |
|||
["texts.fr"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "french" |
|||
}, |
|||
["texts.gl"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "galician" |
|||
}, |
|||
["texts.de"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "german" |
|||
}, |
|||
["texts.el"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "greek" |
|||
}, |
|||
["texts.hi"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "hindi" |
|||
}, |
|||
["texts.hu"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "hungarian" |
|||
}, |
|||
["texts.id"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "indonesian" |
|||
}, |
|||
["texts.ga"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "irish" |
|||
}, |
|||
["texts.it"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "italian" |
|||
}, |
|||
["texts.lv"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "latvian" |
|||
}, |
|||
["texts.lt"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "lithuanian" |
|||
}, |
|||
["texts.nb"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "norwegian" |
|||
}, |
|||
["texts.nn"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "norwegian" |
|||
}, |
|||
["texts.no"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "norwegian" |
|||
}, |
|||
["texts.pt"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "portuguese" |
|||
}, |
|||
["texts.ro"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "romanian" |
|||
}, |
|||
["texts.ru"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "russian" |
|||
}, |
|||
["texts.ku"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "sorani" |
|||
}, |
|||
["texts.es"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "spanish" |
|||
}, |
|||
["texts.sv"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "swedish" |
|||
}, |
|||
["texts.tr"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "turkish" |
|||
}, |
|||
["texts.th"] = new |
|||
{ |
|||
type = "text", |
|||
analyzer = "thai" |
|||
} |
|||
} |
|||
}; |
|||
|
|||
var result = await elastic.Indices.PutMappingAsync<StringResponse>(indexName, CreatePost(query), ctx: ct); |
|||
|
|||
if (!result.Success) |
|||
{ |
|||
throw new InvalidOperationException($"Failed with ${result.Body}", result.OriginalException); |
|||
} |
|||
} |
|||
|
|||
private static PostData CreatePost<T>(T data) |
|||
{ |
|||
return new SerializableData<T>(data); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,210 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Net; |
|||
using System.Net.Http; |
|||
using System.Net.Http.Json; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Hosting.Configuration; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Text |
|||
{ |
|||
public static class AtlasIndexDefinition |
|||
{ |
|||
private static readonly Dictionary<string, string> FieldPaths = new Dictionary<string, string>(); |
|||
private static readonly Dictionary<string, string> FieldAnalyzers = new Dictionary<string, string> |
|||
{ |
|||
["iv"] = "lucene.standard", |
|||
["ar"] = "lucene.arabic", |
|||
["hy"] = "lucene.armenian", |
|||
["eu"] = "lucene.basque", |
|||
["bn"] = "lucene.bengali", |
|||
["br"] = "lucene.brazilian", |
|||
["bg"] = "lucene.bulgarian", |
|||
["ca"] = "lucene.catalan", |
|||
["ko"] = "lucene.cjk", |
|||
["da"] = "lucene.danish", |
|||
["nl"] = "lucene.dutch", |
|||
["en"] = "lucene.english", |
|||
["fi"] = "lucene.finnish", |
|||
["fr"] = "lucene.french", |
|||
["gl"] = "lucene.galician", |
|||
["de"] = "lucene.german", |
|||
["el"] = "lucene.greek", |
|||
["hi"] = "lucene.hindi", |
|||
["hu"] = "lucene.hungarian", |
|||
["id"] = "lucene.indonesian", |
|||
["ga"] = "lucene.irish", |
|||
["it"] = "lucene.italian", |
|||
["jp"] = "lucene.japanese", |
|||
["lv"] = "lucene.latvian", |
|||
["no"] = "lucene.norwegian", |
|||
["fa"] = "lucene.persian", |
|||
["pt"] = "lucene.portuguese", |
|||
["ro"] = "lucene.romanian", |
|||
["ru"] = "lucene.russian", |
|||
["zh"] = "lucene.smartcn", |
|||
["es"] = "lucene.spanish", |
|||
["sv"] = "lucene.swedish", |
|||
["th"] = "lucene.thai", |
|||
["tr"] = "lucene.turkish", |
|||
["uk"] = "lucene.ukrainian" |
|||
}; |
|||
|
|||
public sealed class ErrorResponse |
|||
{ |
|||
public string Detail { get; set; } |
|||
|
|||
public string ErrorCode { get; set; } |
|||
} |
|||
|
|||
static AtlasIndexDefinition() |
|||
{ |
|||
FieldPaths = FieldAnalyzers.ToDictionary(x => x.Key, x => $"t.{x.Key}"); |
|||
} |
|||
|
|||
public static string GetFieldName(string key) |
|||
{ |
|||
if (FieldAnalyzers.ContainsKey(key)) |
|||
{ |
|||
return key; |
|||
} |
|||
|
|||
if (key.Length > 0) |
|||
{ |
|||
var language = key[2..]; |
|||
|
|||
if (FieldAnalyzers.ContainsKey(language)) |
|||
{ |
|||
return language; |
|||
} |
|||
} |
|||
|
|||
return "iv"; |
|||
} |
|||
|
|||
public static string GetFieldPath(string key) |
|||
{ |
|||
if (FieldPaths.TryGetValue(key, out var path)) |
|||
{ |
|||
return path; |
|||
} |
|||
|
|||
if (key.Length > 0) |
|||
{ |
|||
var language = key[2..]; |
|||
|
|||
if (FieldPaths.TryGetValue(language, out path)) |
|||
{ |
|||
return path; |
|||
} |
|||
} |
|||
|
|||
return "t.iv"; |
|||
} |
|||
|
|||
public static async Task<string> CreateIndexAsync(AtlasOptions options, |
|||
string database, |
|||
string collectionName, |
|||
CancellationToken ct) |
|||
{ |
|||
var (index, name) = Create(database, collectionName); |
|||
|
|||
using (var httpClient = new HttpClient(new HttpClientHandler |
|||
{ |
|||
Credentials = new NetworkCredential(options.PublicKey, options.PrivateKey, "cloud.mongodb.com") |
|||
})) |
|||
{ |
|||
var url = $"https://cloud.mongodb.com/api/atlas/v1.0/groups/{options.GroupId}/clusters/{options.ClusterName}/fts/indexes"; |
|||
|
|||
var result = await httpClient.PostAsJsonAsync(url, index, ct); |
|||
|
|||
if (result.IsSuccessStatusCode) |
|||
{ |
|||
return name; |
|||
} |
|||
|
|||
var error = await result.Content.ReadFromJsonAsync<ErrorResponse>(cancellationToken: ct); |
|||
|
|||
if (error?.ErrorCode != "ATLAS_FTS_DUPLICATE_INDEX") |
|||
{ |
|||
var message = new ConfigurationError($"Creating index failed with {result.StatusCode}: {error?.Detail}"); |
|||
|
|||
throw new ConfigurationException(message); |
|||
} |
|||
} |
|||
|
|||
return name; |
|||
} |
|||
|
|||
public static (object, string) Create(string database, string collectionName) |
|||
{ |
|||
var name = $"{database}_{collectionName}_text".ToLowerInvariant(); |
|||
|
|||
var texts = new |
|||
{ |
|||
type = "document", |
|||
fields = new Dictionary<string, object>(), |
|||
dynamic = false |
|||
}; |
|||
|
|||
var index = new |
|||
{ |
|||
collectionName, |
|||
database, |
|||
name, |
|||
mappings = new |
|||
{ |
|||
dynamic = false, |
|||
fields = new Dictionary<string, object> |
|||
{ |
|||
["_ai"] = new |
|||
{ |
|||
type = "string", |
|||
analyzer = "lucene.keyword" |
|||
}, |
|||
["_si"] = new |
|||
{ |
|||
type = "string", |
|||
analyzer = "lucene.keyword" |
|||
}, |
|||
["_ci"] = new |
|||
{ |
|||
type = "string", |
|||
analyzer = "lucene.keyword" |
|||
}, |
|||
["fa"] = new |
|||
{ |
|||
type = "boolean" |
|||
}, |
|||
["fp"] = new |
|||
{ |
|||
type = "boolean" |
|||
}, |
|||
["t"] = texts |
|||
} |
|||
} |
|||
}; |
|||
|
|||
foreach (var (field, analyzer) in FieldAnalyzers) |
|||
{ |
|||
texts.fields[field] = new |
|||
{ |
|||
type = "string", |
|||
analyzer, |
|||
searchAnalyzer = analyzer, |
|||
store = false |
|||
}; |
|||
} |
|||
|
|||
return (index, name); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Text |
|||
{ |
|||
public sealed class AtlasOptions |
|||
{ |
|||
public string GroupId { get; set; } |
|||
|
|||
public string ClusterName { get; set; } |
|||
|
|||
public string PublicKey { get; set; } |
|||
|
|||
public string PrivateKey { get; set; } |
|||
|
|||
public bool FullTextEnabled { get; set; } |
|||
|
|||
public bool IsConfigured() |
|||
{ |
|||
return |
|||
!string.IsNullOrWhiteSpace(GroupId) && |
|||
!string.IsNullOrWhiteSpace(ClusterName) && |
|||
!string.IsNullOrWhiteSpace(PublicKey) && |
|||
!string.IsNullOrWhiteSpace(PrivateKey); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,164 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using Lucene.Net.Analysis.Standard; |
|||
using Lucene.Net.Analysis.Util; |
|||
using Lucene.Net.Util; |
|||
using Microsoft.Extensions.Options; |
|||
using MongoDB.Bson; |
|||
using MongoDB.Driver; |
|||
using Squidex.Domain.Apps.Entities.Apps; |
|||
using Squidex.Domain.Apps.Entities.Contents; |
|||
using Squidex.Domain.Apps.Entities.Contents.Text; |
|||
using Squidex.Infrastructure; |
|||
using LuceneQueryAnalyzer = Lucene.Net.QueryParsers.Classic.QueryParser; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Text |
|||
{ |
|||
public sealed class AtlasTextIndex : MongoTextIndexBase<Dictionary<string, string>> |
|||
{ |
|||
private static readonly LuceneQueryVisitor QueryVisitor = new LuceneQueryVisitor(AtlasIndexDefinition.GetFieldPath); |
|||
private static readonly LuceneQueryAnalyzer QueryParser = |
|||
new LuceneQueryAnalyzer(LuceneVersion.LUCENE_48, "*", |
|||
new StandardAnalyzer(LuceneVersion.LUCENE_48, CharArraySet.EMPTY_SET)); |
|||
private readonly AtlasOptions options; |
|||
private string index; |
|||
|
|||
public AtlasTextIndex(IMongoDatabase database, IOptions<AtlasOptions> options, bool setup = false) |
|||
: base(database, setup) |
|||
{ |
|||
this.options = options.Value; |
|||
} |
|||
|
|||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoTextIndexEntity<Dictionary<string, string>>> collection, |
|||
CancellationToken ct) |
|||
{ |
|||
await base.SetupCollectionAsync(collection, ct); |
|||
|
|||
index = await AtlasIndexDefinition.CreateIndexAsync(options, |
|||
Database.DatabaseNamespace.DatabaseName, CollectionName(), ct); |
|||
} |
|||
|
|||
protected override Dictionary<string, string> BuildTexts(Dictionary<string, string> source) |
|||
{ |
|||
var texts = new Dictionary<string, string>(); |
|||
|
|||
foreach (var (key, value) in source) |
|||
{ |
|||
var text = value; |
|||
|
|||
var languageCode = AtlasIndexDefinition.GetFieldName(key); |
|||
|
|||
if (texts.TryGetValue(languageCode, out var existing)) |
|||
{ |
|||
text = $"{existing} {value}"; |
|||
} |
|||
|
|||
texts[languageCode] = text; |
|||
} |
|||
|
|||
return texts; |
|||
} |
|||
|
|||
public override async Task<List<DomainId>?> SearchAsync(IAppEntity app, TextQuery query, SearchScope scope, |
|||
CancellationToken ct = default) |
|||
{ |
|||
Guard.NotNull(app, nameof(app)); |
|||
Guard.NotNull(query, nameof(query)); |
|||
|
|||
var (search, take) = query; |
|||
|
|||
if (string.IsNullOrWhiteSpace(search)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
var luceneQuery = QueryParser.Parse(search); |
|||
|
|||
var serveField = scope == SearchScope.All ? "fa" : "fp"; |
|||
|
|||
var compound = new BsonDocument |
|||
{ |
|||
["must"] = new BsonArray |
|||
{ |
|||
QueryVisitor.Visit(luceneQuery) |
|||
}, |
|||
["filter"] = new BsonArray |
|||
{ |
|||
new BsonDocument |
|||
{ |
|||
["text"] = new BsonDocument |
|||
{ |
|||
["path"] = "_ai", |
|||
["query"] = app.Id.ToString() |
|||
} |
|||
}, |
|||
new BsonDocument |
|||
{ |
|||
["equals"] = new BsonDocument |
|||
{ |
|||
["path"] = serveField, |
|||
["value"] = true |
|||
} |
|||
} |
|||
} |
|||
}; |
|||
|
|||
if (query.PreferredSchemaId != null) |
|||
{ |
|||
compound["should"] = new BsonArray |
|||
{ |
|||
new BsonDocument |
|||
{ |
|||
["text"] = new BsonDocument |
|||
{ |
|||
["path"] = "_si", |
|||
["query"] = query.PreferredSchemaId.Value.ToString() |
|||
} |
|||
} |
|||
}; |
|||
} |
|||
else if (query.RequiredSchemaIds?.Count > 0) |
|||
{ |
|||
compound["should"] = new BsonArray(query.RequiredSchemaIds.Select(x => |
|||
new BsonDocument |
|||
{ |
|||
["text"] = new BsonDocument |
|||
{ |
|||
["path"] = "_si", |
|||
["query"] = x.ToString() |
|||
} |
|||
})); |
|||
|
|||
compound["minimumShouldMatch"] = 1; |
|||
} |
|||
|
|||
var searchQuery = new BsonDocument |
|||
{ |
|||
["compound"] = compound, |
|||
}; |
|||
|
|||
if (index != null) |
|||
{ |
|||
searchQuery["index"] = index; |
|||
} |
|||
|
|||
var results = |
|||
await Collection.Aggregate().Search(searchQuery).Limit(take) |
|||
.Project<MongoTextResult>( |
|||
Projection.Include(x => x.ContentId) |
|||
) |
|||
.ToListAsync(ct); |
|||
|
|||
return results.Select(x => x.ContentId).ToList(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,329 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Globalization; |
|||
using System.Linq; |
|||
using System.Text; |
|||
using Lucene.Net.Index; |
|||
using Lucene.Net.Search; |
|||
using Lucene.Net.Util; |
|||
using MongoDB.Bson; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Text |
|||
{ |
|||
public sealed class LuceneQueryVisitor |
|||
{ |
|||
private readonly Func<string, string>? fieldConverter; |
|||
|
|||
public LuceneQueryVisitor(Func<string, string>? fieldConverter = null) |
|||
{ |
|||
this.fieldConverter = fieldConverter; |
|||
} |
|||
|
|||
public BsonDocument Visit(Query query) |
|||
{ |
|||
switch (query) |
|||
{ |
|||
case BooleanQuery booleanQuery: |
|||
return VisitBoolean(booleanQuery); |
|||
case TermQuery termQuery: |
|||
return VisitTerm(termQuery); |
|||
case PhraseQuery phraseQuery: |
|||
return VisitPhrase(phraseQuery); |
|||
case WildcardQuery wildcardQuery: |
|||
return VisitWilcard(wildcardQuery); |
|||
case PrefixQuery prefixQuery: |
|||
return VisitPrefix(prefixQuery); |
|||
case FuzzyQuery fuzzyQuery: |
|||
return VisitFuzzy(fuzzyQuery); |
|||
case NumericRangeQuery<float> rangeQuery: |
|||
return VisitNumericRange(rangeQuery); |
|||
case NumericRangeQuery<double> rangeQuery: |
|||
return VisitNumericRange(rangeQuery); |
|||
case NumericRangeQuery<int> rangeQuery: |
|||
return VisitNumericRange(rangeQuery); |
|||
case NumericRangeQuery<long> rangeQuery: |
|||
return VisitNumericRange(rangeQuery); |
|||
case TermRangeQuery termRangeQuery: |
|||
return VisitTermRange(termRangeQuery); |
|||
default: |
|||
throw new NotSupportedException(); |
|||
} |
|||
} |
|||
|
|||
private BsonDocument VisitTermRange(TermRangeQuery termRangeQuery) |
|||
{ |
|||
if (!TryParseValue(termRangeQuery.LowerTerm, out var min) || |
|||
!TryParseValue(termRangeQuery.UpperTerm, out var max)) |
|||
{ |
|||
throw new NotSupportedException(); |
|||
} |
|||
|
|||
var minField = termRangeQuery.IncludesLower ? "gte" : "gt"; |
|||
var maxField = termRangeQuery.IncludesUpper ? "lte" : "lt"; |
|||
|
|||
var doc = new BsonDocument |
|||
{ |
|||
["path"] = GetPath(termRangeQuery.Field), |
|||
[minField] = BsonValue.Create(min), |
|||
[maxField] = BsonValue.Create(max) |
|||
}; |
|||
|
|||
ApplyBoost(termRangeQuery, doc); |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["range"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument VisitNumericRange<T>(NumericRangeQuery<T> numericRangeQuery) where T : struct, IComparable<T> |
|||
{ |
|||
var minField = numericRangeQuery.IncludesMin ? "gte" : "gt"; |
|||
var maxField = numericRangeQuery.IncludesMin ? "lte" : "lt"; |
|||
|
|||
var doc = new BsonDocument |
|||
{ |
|||
["path"] = GetPath(numericRangeQuery.Field), |
|||
[minField] = BsonValue.Create(numericRangeQuery.Min), |
|||
[maxField] = BsonValue.Create(numericRangeQuery.Max) |
|||
}; |
|||
|
|||
ApplyBoost(numericRangeQuery, doc); |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["range"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument VisitFuzzy(FuzzyQuery fuzzyQuery) |
|||
{ |
|||
var doc = CreateDefaultDoc(fuzzyQuery, fuzzyQuery.Term); |
|||
|
|||
if (fuzzyQuery.MaxEdits > 0) |
|||
{ |
|||
var fuzzy = new BsonDocument |
|||
{ |
|||
["maxEdits"] = fuzzyQuery.MaxEdits, |
|||
}; |
|||
|
|||
if (fuzzyQuery.PrefixLength > 0) |
|||
{ |
|||
fuzzy["prefixLength"] = fuzzyQuery.PrefixLength; |
|||
} |
|||
|
|||
doc["fuzzy"] = fuzzy; |
|||
} |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["text"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument VisitPrefix(PrefixQuery prefixQuery) |
|||
{ |
|||
var doc = CreateDefaultDoc(prefixQuery, new Term(prefixQuery.Prefix.Field, prefixQuery.Prefix.Text + "*")); |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["wildcard"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument VisitWilcard(WildcardQuery wildcardQuery) |
|||
{ |
|||
var doc = CreateDefaultDoc(wildcardQuery, wildcardQuery.Term); |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["wildcard"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument VisitPhrase(PhraseQuery phraseQuery) |
|||
{ |
|||
var terms = phraseQuery.GetTerms(); |
|||
|
|||
var doc = new BsonDocument |
|||
{ |
|||
["path"] = GetPath(terms[0].Field), |
|||
}; |
|||
|
|||
if (terms.Length == 1) |
|||
{ |
|||
doc["query"] = terms[0].Text; |
|||
} |
|||
else |
|||
{ |
|||
doc["query"] = new BsonArray(terms.Select(x => x.Text)); |
|||
} |
|||
|
|||
if (phraseQuery.Slop != 0) |
|||
{ |
|||
doc["slop"] = phraseQuery.Slop; |
|||
} |
|||
|
|||
ApplyBoost(phraseQuery, doc); |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["phrase"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument VisitTerm(TermQuery termQuery) |
|||
{ |
|||
var doc = CreateDefaultDoc(termQuery, termQuery.Term); |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["text"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument VisitBoolean(BooleanQuery booleanQuery) |
|||
{ |
|||
var doc = new BsonDocument(); |
|||
|
|||
BsonArray? musts = null; |
|||
BsonArray? mustNots = null; |
|||
BsonArray? shoulds = null; |
|||
|
|||
foreach (var clause in booleanQuery.Clauses) |
|||
{ |
|||
var converted = Visit(clause.Query); |
|||
|
|||
switch (clause.Occur) |
|||
{ |
|||
case Occur.MUST: |
|||
musts ??= new BsonArray(); |
|||
musts.Add(converted); |
|||
break; |
|||
case Occur.SHOULD: |
|||
shoulds ??= new BsonArray(); |
|||
shoulds.Add(converted); |
|||
break; |
|||
case Occur.MUST_NOT: |
|||
mustNots ??= new BsonArray(); |
|||
mustNots.Add(converted); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
if (musts != null) |
|||
{ |
|||
doc.Add("must", musts); |
|||
} |
|||
|
|||
if (mustNots != null) |
|||
{ |
|||
doc.Add("mustNot", mustNots); |
|||
} |
|||
|
|||
if (shoulds != null) |
|||
{ |
|||
doc.Add("should", shoulds); |
|||
} |
|||
|
|||
if (booleanQuery.MinimumNumberShouldMatch > 0) |
|||
{ |
|||
doc["minimumShouldMatch"] = booleanQuery.MinimumNumberShouldMatch; |
|||
} |
|||
|
|||
return new BsonDocument |
|||
{ |
|||
["compound"] = doc |
|||
}; |
|||
} |
|||
|
|||
private BsonDocument CreateDefaultDoc(Query query, Term term) |
|||
{ |
|||
var doc = new BsonDocument |
|||
{ |
|||
["path"] = GetPath(term.Field), |
|||
["query"] = term.Text |
|||
}; |
|||
|
|||
ApplyBoost(query, doc); |
|||
|
|||
return doc; |
|||
} |
|||
|
|||
private BsonValue GetPath(string field) |
|||
{ |
|||
if (field != "*" && fieldConverter != null) |
|||
{ |
|||
field = fieldConverter(field); |
|||
} |
|||
|
|||
if (field.Contains('*', StringComparison.Ordinal)) |
|||
{ |
|||
return new BsonDocument |
|||
{ |
|||
["wildcard"] = field |
|||
}; |
|||
} |
|||
|
|||
return field; |
|||
} |
|||
|
|||
#pragma warning disable RECS0018 // Comparison of floating point numbers with equality operator
|
|||
private static void ApplyBoost(Query query, BsonDocument doc) |
|||
{ |
|||
if (query.Boost != 1) |
|||
{ |
|||
doc["score"] = new BsonDocument |
|||
{ |
|||
["boost"] = query.Boost |
|||
}; |
|||
} |
|||
} |
|||
|
|||
private static bool TryParseValue(BytesRef bytes, out object result) |
|||
{ |
|||
result = null!; |
|||
|
|||
try |
|||
{ |
|||
var text = Encoding.ASCII.GetString(bytes.Bytes, bytes.Offset, bytes.Length); |
|||
|
|||
if (!double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var number)) |
|||
{ |
|||
return false; |
|||
} |
|||
|
|||
var integer = (long)number; |
|||
|
|||
if (number == integer) |
|||
{ |
|||
if (integer <= int.MaxValue && integer >= int.MinValue) |
|||
{ |
|||
result = (int)integer; |
|||
} |
|||
else |
|||
{ |
|||
result = integer; |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
result = number; |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
catch (Exception) |
|||
{ |
|||
return false; |
|||
} |
|||
} |
|||
#pragma warning restore RECS0018 // Comparison of floating point numbers with equality operator
|
|||
} |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using MongoDB.Bson; |
|||
using MongoDB.Bson.Serialization; |
|||
using MongoDB.Driver; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Text |
|||
{ |
|||
public static class LuceneSearchDefinitionExtensions |
|||
{ |
|||
public static IAggregateFluent<TResult> Search<TResult>( |
|||
this IAggregateFluent<TResult> aggregate, |
|||
BsonDocument search) |
|||
{ |
|||
const string OperatorName = "$search"; |
|||
|
|||
var stage = new DelegatedPipelineStageDefinition<TResult, TResult>( |
|||
OperatorName, |
|||
serializer => |
|||
{ |
|||
var document = new BsonDocument(OperatorName, search); |
|||
|
|||
return new RenderedPipelineStageDefinition<TResult>(OperatorName, document, serializer); |
|||
}); |
|||
|
|||
return aggregate.AppendStage(stage); |
|||
} |
|||
|
|||
private sealed class DelegatedPipelineStageDefinition<TInput, TOutput> : PipelineStageDefinition<TInput, TOutput> |
|||
{ |
|||
private readonly Func<IBsonSerializer<TInput>, RenderedPipelineStageDefinition<TOutput>> renderer; |
|||
|
|||
public override string OperatorName { get; } |
|||
|
|||
public DelegatedPipelineStageDefinition(string operatorName, |
|||
Func<IBsonSerializer<TInput>, RenderedPipelineStageDefinition<TOutput>> renderer) |
|||
{ |
|||
this.renderer = renderer; |
|||
|
|||
OperatorName = operatorName; |
|||
} |
|||
|
|||
public override RenderedPipelineStageDefinition<TOutput> Render( |
|||
IBsonSerializer<TInput> inputSerializer, |
|||
IBsonSerializerRegistry serializerRegistry) |
|||
{ |
|||
return renderer(inputSerializer); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading; |
|||
using System.Threading.Tasks; |
|||
using MongoDB.Driver; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.MongoDb.Text |
|||
{ |
|||
public sealed class MongoTextIndex : MongoTextIndexBase<List<MongoTextIndexEntityText>> |
|||
{ |
|||
public MongoTextIndex(IMongoDatabase database, bool setup = false) |
|||
: base(database, setup) |
|||
{ |
|||
} |
|||
|
|||
protected override async Task SetupCollectionAsync(IMongoCollection<MongoTextIndexEntity<List<MongoTextIndexEntityText>>> collection, |
|||
CancellationToken ct) |
|||
{ |
|||
await base.SetupCollectionAsync(collection, ct); |
|||
|
|||
await collection.Indexes.CreateOneAsync( |
|||
new CreateIndexModel<MongoTextIndexEntity<List<MongoTextIndexEntityText>>>( |
|||
Index |
|||
.Text("t.t") |
|||
.Ascending(x => x.AppId) |
|||
.Ascending(x => x.ServeAll) |
|||
.Ascending(x => x.ServePublished) |
|||
.Ascending(x => x.SchemaId)), |
|||
cancellationToken: ct); |
|||
} |
|||
|
|||
protected override List<MongoTextIndexEntityText> BuildTexts(Dictionary<string, string> source) |
|||
{ |
|||
return source.Select(x => new MongoTextIndexEntityText { Text = x.Value }).ToList(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class Query |
|||
{ |
|||
public string Text { get; init; } |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Text; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class QueryParser |
|||
{ |
|||
private readonly Func<string, string> fieldProvider; |
|||
|
|||
public QueryParser(Func<string, string> fieldProvider) |
|||
{ |
|||
this.fieldProvider = fieldProvider; |
|||
} |
|||
|
|||
public Query? Parse(string text) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(text)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
text = text.Trim(); |
|||
text = ConvertFieldNames(text); |
|||
|
|||
return new Query |
|||
{ |
|||
Text = text |
|||
}; |
|||
} |
|||
|
|||
private string ConvertFieldNames(string query) |
|||
{ |
|||
var indexOfColon = query.IndexOf(':', StringComparison.Ordinal); |
|||
|
|||
if (indexOfColon < 0) |
|||
{ |
|||
return query; |
|||
} |
|||
|
|||
var sb = new StringBuilder(); |
|||
|
|||
int position = 0, lastIndexOfColon = 0; |
|||
|
|||
while (indexOfColon >= 0) |
|||
{ |
|||
lastIndexOfColon = indexOfColon; |
|||
|
|||
var i = 0; |
|||
|
|||
for (i = indexOfColon - 1; i >= position; i--) |
|||
{ |
|||
var c = query[i]; |
|||
|
|||
if (!char.IsLetter(c) && c != '-' && c != '_') |
|||
{ |
|||
break; |
|||
} |
|||
} |
|||
|
|||
i++; |
|||
|
|||
sb.Append(query[position..i]); |
|||
sb.Append(fieldProvider(query[i..indexOfColon])); |
|||
|
|||
position = indexOfColon + 1; |
|||
|
|||
indexOfColon = query.IndexOf(':', position); |
|||
} |
|||
|
|||
sb.Append(query[lastIndexOfColon..]); |
|||
|
|||
return sb.ToString(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,423 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.IO; |
|||
using System.Text; |
|||
using System.Text.Json; |
|||
using Lucene.Net.Analysis.Standard; |
|||
using Lucene.Net.Analysis.Util; |
|||
using Lucene.Net.Util; |
|||
using MongoDB.Bson; |
|||
using Squidex.Domain.Apps.Entities.MongoDb.Text; |
|||
using Xunit; |
|||
using LuceneQueryAnalyzer = Lucene.Net.QueryParsers.Classic.QueryParser; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public class AtlasParsingTests |
|||
{ |
|||
private static readonly LuceneQueryVisitor QueryVisitor = new LuceneQueryVisitor(); |
|||
private static readonly LuceneQueryAnalyzer QueryParser = |
|||
new LuceneQueryAnalyzer(LuceneVersion.LUCENE_48, "*", |
|||
new StandardAnalyzer(LuceneVersion.LUCENE_48, CharArraySet.EMPTY_SET)); |
|||
private static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions |
|||
{ |
|||
WriteIndented = true |
|||
}; |
|||
|
|||
[Fact] |
|||
public void Should_parse_term_query() |
|||
{ |
|||
var actual = ParseQuery("hello"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "hello" |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_phrase_query() |
|||
{ |
|||
var actual = ParseQuery("\"hello dolly\""); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
phrase = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = new[] { "hello", "dolly" } |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_compound_phrase_query() |
|||
{ |
|||
var actual = ParseQuery("title:\"The Right Way\" AND text:go"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
compound = new |
|||
{ |
|||
must = new object[] |
|||
{ |
|||
new |
|||
{ |
|||
phrase = new |
|||
{ |
|||
path = "title", |
|||
query = new[] |
|||
{ |
|||
"the", |
|||
"right", |
|||
"way" |
|||
} |
|||
} |
|||
}, |
|||
new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = "text", |
|||
query = "go" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_compound_phrase_query_with_widldcard() |
|||
{ |
|||
var actual = ParseQuery("title:\"Do it right\" AND right"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
compound = new |
|||
{ |
|||
must = new object[] |
|||
{ |
|||
new |
|||
{ |
|||
phrase = new |
|||
{ |
|||
path = "title", |
|||
query = new[] |
|||
{ |
|||
"do", |
|||
"it", |
|||
"right" |
|||
} |
|||
} |
|||
}, |
|||
new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "right" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_wildcard_query() |
|||
{ |
|||
var actual = ParseQuery("te?t"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
wildcard = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "te?t" |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_prefix_query() |
|||
{ |
|||
var actual = ParseQuery("test*"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
wildcard = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "test*" |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_fuzzy_query() |
|||
{ |
|||
var actual = ParseQuery("roam~"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "roam", |
|||
fuzzy = new |
|||
{ |
|||
maxEdits = 2 |
|||
} |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_fuzzy_query_with_max_edits() |
|||
{ |
|||
var actual = ParseQuery("roam~1"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "roam", |
|||
fuzzy = new |
|||
{ |
|||
maxEdits = 1 |
|||
} |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_fuzzy_phrase_query_with_slop() |
|||
{ |
|||
var actual = ParseQuery("\"jakarta apache\"~10"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
phrase = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = new[] |
|||
{ |
|||
"jakarta", |
|||
"apache" |
|||
}, |
|||
slop = 10 |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_compound_query_with_brackets() |
|||
{ |
|||
var actual = ParseQuery("(jakarta OR apache) AND website"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
compound = new |
|||
{ |
|||
must = new object[] |
|||
{ |
|||
new |
|||
{ |
|||
compound = new |
|||
{ |
|||
should = new object[] |
|||
{ |
|||
new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "jakarta" |
|||
} |
|||
}, |
|||
new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "apache" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = new |
|||
{ |
|||
wildcard = "*" |
|||
}, |
|||
query = "website" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_compound_query_and_optimize() |
|||
{ |
|||
var actual = ParseQuery("title:(+return +\"pink panther\")"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
compound = new |
|||
{ |
|||
must = new object[] |
|||
{ |
|||
new |
|||
{ |
|||
text = new |
|||
{ |
|||
path = "title", |
|||
query = "return" |
|||
} |
|||
}, |
|||
new |
|||
{ |
|||
phrase = new |
|||
{ |
|||
path = "title", |
|||
query = new[] |
|||
{ |
|||
"pink", |
|||
"panther" |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_range_query() |
|||
{ |
|||
var actual = ParseQuery("mod_date:[20020101 TO 20030101]"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
range = new |
|||
{ |
|||
path = "mod_date", |
|||
gte = 20020101, |
|||
lte = 20030101 |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_parse_open_range_query() |
|||
{ |
|||
var actual = ParseQuery("mod_date:{20020101 TO 20030101}"); |
|||
|
|||
var expected = CreateQuery(new |
|||
{ |
|||
range = new |
|||
{ |
|||
path = "mod_date", |
|||
gt = 20020101, |
|||
lt = 20030101 |
|||
} |
|||
}); |
|||
|
|||
Assert.Equal(expected, actual); |
|||
} |
|||
|
|||
private static object CreateQuery(object query) |
|||
{ |
|||
return JsonSerializer.Serialize(query, JsonSerializerOptions); |
|||
} |
|||
|
|||
private static object ParseQuery(string query) |
|||
{ |
|||
var luceneQuery = QueryParser.Parse(query); |
|||
|
|||
var rendered = QueryVisitor.Visit(luceneQuery); |
|||
|
|||
var jsonStream = new MemoryStream(); |
|||
var jsonDocument = JsonDocument.Parse(rendered.ToJson()); |
|||
|
|||
var jsonWriter = new Utf8JsonWriter(jsonStream, new JsonWriterOptions { Indented = true }); |
|||
|
|||
jsonDocument.WriteTo(jsonWriter); |
|||
|
|||
jsonWriter.Flush(); |
|||
|
|||
return Encoding.UTF8.GetString(jsonStream.ToArray()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Microsoft.Extensions.Configuration; |
|||
using Microsoft.Extensions.Options; |
|||
using MongoDB.Driver; |
|||
using Newtonsoft.Json; |
|||
using Squidex.Domain.Apps.Core.TestHelpers; |
|||
using Squidex.Domain.Apps.Entities.MongoDb.Text; |
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class AtlasTextIndexFixture |
|||
{ |
|||
public AtlasTextIndex Index { get; } |
|||
|
|||
public AtlasTextIndexFixture() |
|||
{ |
|||
BsonJsonConvention.Register(JsonSerializer.Create(TestUtils.CreateSerializerSettings())); |
|||
|
|||
DomainIdSerializer.Register(); |
|||
|
|||
var mongoClient = new MongoClient(TestConfig.Configuration["atlas:configuration"]); |
|||
var mongoDatabase = mongoClient.GetDatabase(TestConfig.Configuration["atlas:database"]); |
|||
|
|||
var options = TestConfig.Configuration.GetSection("atlas").Get<AtlasOptions>(); |
|||
|
|||
Index = new AtlasTextIndex(mongoDatabase, Options.Create(options), false); |
|||
Index.InitializeAsync(default).Wait(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,62 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using Xunit; |
|||
|
|||
#pragma warning disable SA1300 // Element should begin with upper-case letter
|
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
[Trait("Category", "Dependencies")] |
|||
public class AtlasTextIndexTests : TextIndexerTestsBase, IClassFixture<AtlasTextIndexFixture> |
|||
{ |
|||
public override bool SupportsQuerySyntax => true; |
|||
|
|||
public override bool SupportsGeo => true; |
|||
|
|||
public override int WaitAfterUpdate => 2000; |
|||
|
|||
public AtlasTextIndexFixture _ { get; } |
|||
|
|||
public AtlasTextIndexTests(AtlasTextIndexFixture fixture) |
|||
{ |
|||
_ = fixture; |
|||
} |
|||
|
|||
public override ITextIndex CreateIndex() |
|||
{ |
|||
return _.Index; |
|||
} |
|||
|
|||
[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 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 SearchText(expected: ids1, text: "and"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_index_cjk_content_and_retrieve() |
|||
{ |
|||
await CreateTextAsync(ids1[0], "zh", "東京大学"); |
|||
|
|||
await SearchText(expected: ids1, text: "東京"); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,26 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Extensions.Text.Azure; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class AzureTextIndexFixture |
|||
{ |
|||
public AzureTextIndex Index { get; } |
|||
|
|||
public AzureTextIndexFixture() |
|||
{ |
|||
Index = new AzureTextIndex( |
|||
TestConfig.Configuration["azureText:serviceEndpoint"], |
|||
TestConfig.Configuration["azureText:apiKey"], |
|||
TestConfig.Configuration["azureText:indexName"]); |
|||
Index.InitializeAsync(default).Wait(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Extensions.Text.ElasticSearch; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class ElasticTextIndexFixture |
|||
{ |
|||
public ElasticSearchTextIndex Index { get; } |
|||
|
|||
public ElasticTextIndexFixture() |
|||
{ |
|||
Index = new ElasticSearchTextIndex( |
|||
TestConfig.Configuration["elastic:configuration"], |
|||
TestConfig.Configuration["elastic:indexName"]); |
|||
Index.InitializeAsync(default).Wait(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using MongoDB.Driver; |
|||
using Newtonsoft.Json; |
|||
using Squidex.Domain.Apps.Core.TestHelpers; |
|||
using Squidex.Domain.Apps.Entities.MongoDb.Text; |
|||
using Squidex.Domain.Apps.Entities.TestHelpers; |
|||
using Squidex.Infrastructure.MongoDb; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public sealed class MongoTextIndexFixture |
|||
{ |
|||
public MongoTextIndex Index { get; } |
|||
|
|||
public MongoTextIndexFixture() |
|||
{ |
|||
BsonJsonConvention.Register(JsonSerializer.Create(TestUtils.CreateSerializerSettings())); |
|||
|
|||
DomainIdSerializer.Register(); |
|||
|
|||
var mongoClient = new MongoClient(TestConfig.Configuration["mongodb:configuration"]); |
|||
var mongoDatabase = mongoClient.GetDatabase(TestConfig.Configuration["mongodb:database"]); |
|||
|
|||
Index = new MongoTextIndex(mongoDatabase, false); |
|||
Index.InitializeAsync(default).Wait(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Xunit; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents.Text |
|||
{ |
|||
public class QueryParserTests |
|||
{ |
|||
private readonly QueryParser sut = new QueryParser(x => $"texts.{x}"); |
|||
|
|||
[Fact] |
|||
public void Should_prefix_field_query() |
|||
{ |
|||
var source = "en:Hello"; |
|||
|
|||
Assert.Equal("texts.en:Hello", sut.Parse(source)?.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_prefix_field_with_complex_language() |
|||
{ |
|||
var source = "en-EN:Hello"; |
|||
|
|||
Assert.Equal("texts.en-EN:Hello", sut.Parse(source)?.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_prefix_field_query_within_query() |
|||
{ |
|||
var source = "Hello en:World"; |
|||
|
|||
Assert.Equal("Hello texts.en:World", sut.Parse(source)?.Text); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_prefix_field_query_within_complex_query() |
|||
{ |
|||
var source = "Hallo OR (Hello en:World)"; |
|||
|
|||
Assert.Equal("Hallo OR (Hello texts.en:World)", sut.Parse(source)?.Text); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.IO; |
|||
using Microsoft.Extensions.Configuration; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.TestHelpers |
|||
{ |
|||
public static class TestConfig |
|||
{ |
|||
public static IConfiguration Configuration { get; } |
|||
|
|||
static TestConfig() |
|||
{ |
|||
var basePath = Path.GetFullPath("../../../"); |
|||
|
|||
Configuration = new ConfigurationBuilder() |
|||
.SetBasePath(basePath) |
|||
.AddJsonFile("appsettings.json", true) |
|||
.AddJsonFile("appsettings.Development.json", true) |
|||
.AddEnvironmentVariables() |
|||
.Build(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
{ |
|||
"mongoDb": { |
|||
// The connection string to your Mongo Server. |
|||
// |
|||
// Read More: https://docs.mongodb.com/manual/reference/connection-string/ |
|||
"configuration": "mongodb://localhost", |
|||
|
|||
// The name of the event store database. |
|||
"database": "Squidex_Testing" |
|||
}, |
|||
"atlas": { |
|||
// The connection string to your Mongo Server. |
|||
// |
|||
// Read More: https://docs.mongodb.com/manual/reference/connection-string/ |
|||
"configuration": "mongodb://localhost", |
|||
|
|||
// The name of the event store database. |
|||
"database": "Squidex_Testing", |
|||
|
|||
// The organization id. |
|||
"groupId": "", |
|||
|
|||
// The name of the cluster. |
|||
"clusterName": "", |
|||
|
|||
// Credentials to your account. |
|||
"publicKey": "", |
|||
"privateKey": "", |
|||
|
|||
"fullTextEnabled": true |
|||
}, |
|||
"elastic": { |
|||
// The configuration to your elastic search cluster. |
|||
// |
|||
// Read More: https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/client-configuration.html |
|||
"configuration": "http://localhost:9200", |
|||
|
|||
// The name of the test index. |
|||
"indexName": "test" |
|||
}, |
|||
"azureText": { |
|||
// The URL to your azure search instance. |
|||
// |
|||
// Read More: https://docs.microsoft.com/en-us/azure/search/search-create-service-portal#get-a-key-and-url-endpoint |
|||
"serviceEndpoint": "https://<name>.search.windows.net", |
|||
|
|||
// The api key. See link above. |
|||
"apiKey": "", |
|||
|
|||
// The name of the index. |
|||
"indexName": "test" |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.IO; |
|||
using Microsoft.Extensions.Configuration; |
|||
|
|||
namespace Squidex.Infrastructure.TestHelpers |
|||
{ |
|||
public static class TestConfig |
|||
{ |
|||
public static IConfiguration Configuration { get; } |
|||
|
|||
static TestConfig() |
|||
{ |
|||
var basePath = Path.GetFullPath("../../../"); |
|||
|
|||
Configuration = new ConfigurationBuilder() |
|||
.SetBasePath(basePath) |
|||
.AddJsonFile("appsettings.json", true) |
|||
.AddJsonFile("appsettings.Development.json", true) |
|||
.AddEnvironmentVariables() |
|||
.Build(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
{ |
|||
"mongoDb": { |
|||
// The connection string to your Mongo Server. |
|||
// |
|||
// Read More: https://docs.mongodb.com/manual/reference/connection-string/ |
|||
"configuration": "mongodb://localhost", |
|||
"configurationReplica": "mongodb://localhost", |
|||
|
|||
// The name of the event store database. |
|||
"database": "Squidex_Testing" |
|||
}, |
|||
"eventStore": { |
|||
"configuration": "esdb://admin:changeit@127.0.0.1:2113?tls=false" |
|||
} |
|||
} |
|||
Loading…
Reference in new issue