Skip to content

Commit b2e052f

Browse files
authored
Enhance AzureAISearch to support indexes independent of content items (#17556)
1 parent ca19f85 commit b2e052f

File tree

49 files changed

+1445
-449
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1445
-449
lines changed

src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Controllers/AdminController.cs

Lines changed: 103 additions & 148 deletions
Large diffs are not rendered by default.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using Microsoft.AspNetCore.Mvc.Rendering;
2+
using Microsoft.Extensions.Localization;
3+
using Microsoft.Extensions.Options;
4+
using OrchardCore.DisplayManagement.Handlers;
5+
using OrchardCore.DisplayManagement.Views;
6+
using OrchardCore.Mvc.ModelBinding;
7+
using OrchardCore.Search.AzureAI.Models;
8+
using OrchardCore.Search.AzureAI.Services;
9+
using OrchardCore.Search.AzureAI.ViewModels;
10+
11+
namespace OrchardCore.Search.AzureAI.Drivers;
12+
13+
internal sealed class AzureAISearchIndexSettingsDisplayDriver : DisplayDriver<AzureAISearchIndexSettings>
14+
{
15+
private readonly AzureAISearchIndexManager _indexManager;
16+
private readonly AzureAISearchDefaultOptions _azureAIOptions;
17+
private readonly IStringLocalizer S;
18+
19+
public AzureAISearchIndexSettingsDisplayDriver(
20+
AzureAISearchIndexManager indexManager,
21+
IOptions<AzureAISearchDefaultOptions> azureAIOptions,
22+
IStringLocalizer<AzureAISearchIndexSettingsDisplayDriver> stringLocalizer)
23+
{
24+
_indexManager = indexManager;
25+
_azureAIOptions = azureAIOptions.Value;
26+
S = stringLocalizer;
27+
}
28+
29+
public override Task<IDisplayResult> DisplayAsync(AzureAISearchIndexSettings settings, BuildDisplayContext context)
30+
{
31+
return CombineAsync(
32+
View("AzureAISearchIndexSettings_Fields_SummaryAdmin", settings).Location("Content:1"),
33+
View("AzureAISearchIndexSettings_Buttons_SummaryAdmin", settings).Location("Actions:5"),
34+
View("AzureAISearchIndexSettings_DefaultTags_SummaryAdmin", settings).Location("Tags:5")
35+
);
36+
}
37+
38+
public override IDisplayResult Edit(AzureAISearchIndexSettings settings, BuildEditorContext context)
39+
{
40+
return Initialize<AzureAISettingsViewModel>("AzureAISearchIndexSettingsFields_Edit", model =>
41+
{
42+
model.AnalyzerName = settings.AnalyzerName ?? AzureAISearchDefaultOptions.DefaultAnalyzer;
43+
model.IndexName = settings.IndexName;
44+
model.IsNew = context.IsNew;
45+
model.Analyzers = _azureAIOptions.Analyzers.Select(x => new SelectListItem(x, x));
46+
}).Location("Content:1");
47+
}
48+
49+
public override async Task<IDisplayResult> UpdateAsync(AzureAISearchIndexSettings settings, UpdateEditorContext context)
50+
{
51+
var model = new AzureAISettingsViewModel();
52+
53+
await context.Updater.TryUpdateModelAsync(model, Prefix);
54+
55+
if (context.IsNew)
56+
{
57+
if (string.IsNullOrWhiteSpace(model.IndexName))
58+
{
59+
context.Updater.ModelState.AddModelError(Prefix, nameof(model.IndexName), S["The index name is required."]);
60+
}
61+
else if (!AzureAISearchIndexNamingHelper.TryGetSafeIndexName(model.IndexName, out var indexName) || indexName != model.IndexName)
62+
{
63+
context.Updater.ModelState.AddModelError(Prefix, nameof(model.IndexName), S["The index name contains forbidden characters."]);
64+
}
65+
else if (await _indexManager.ExistsAsync(model.IndexName))
66+
{
67+
context.Updater.ModelState.AddModelError(Prefix, nameof(AzureAISettingsViewModel.IndexName), S["An index named <em>{0}</em> already exist in Azure AI Search server.", model.IndexName]);
68+
}
69+
70+
settings.IndexName = model.IndexName;
71+
settings.IndexFullName = _indexManager.GetFullIndexName(model.IndexName);
72+
}
73+
74+
settings.AnalyzerName = model.AnalyzerName;
75+
settings.QueryAnalyzerName = model.AnalyzerName;
76+
77+
if (string.IsNullOrEmpty(settings.AnalyzerName))
78+
{
79+
settings.AnalyzerName = AzureAISearchDefaultOptions.DefaultAnalyzer;
80+
}
81+
82+
if (string.IsNullOrEmpty(settings.QueryAnalyzerName))
83+
{
84+
settings.QueryAnalyzerName = settings.AnalyzerName;
85+
}
86+
87+
return Edit(settings, context);
88+
}
89+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Globalization;
2+
using Microsoft.AspNetCore.Mvc.Rendering;
3+
using Microsoft.Extensions.Localization;
4+
using OrchardCore.DisplayManagement.Handlers;
5+
using OrchardCore.DisplayManagement.Views;
6+
using OrchardCore.Entities;
7+
using OrchardCore.Mvc.ModelBinding;
8+
using OrchardCore.Search.AzureAI.Models;
9+
using OrchardCore.Search.AzureAI.ViewModels;
10+
11+
namespace OrchardCore.Search.AzureAI.Drivers;
12+
13+
internal sealed class ContentAzureAISearchIndexSettingsDisplayDriver : DisplayDriver<AzureAISearchIndexSettings>
14+
{
15+
internal readonly IStringLocalizer S;
16+
17+
public ContentAzureAISearchIndexSettingsDisplayDriver(IStringLocalizer<ContentAzureAISearchIndexSettingsDisplayDriver> stringLocalizer)
18+
{
19+
S = stringLocalizer;
20+
}
21+
22+
public override IDisplayResult Edit(AzureAISearchIndexSettings settings, BuildEditorContext context)
23+
{
24+
if (!string.Equals(AzureAISearchConstants.ContentsIndexSource, settings.Source, StringComparison.OrdinalIgnoreCase))
25+
{
26+
return null;
27+
}
28+
29+
return Initialize<ContentIndexMetadataViewModel>("ContentIndexMetadata_Edit", model =>
30+
{
31+
var metadata = settings.As<ContentIndexMetadata>();
32+
33+
model.IndexLatest = metadata.IndexLatest;
34+
model.IndexedContentTypes = metadata.IndexedContentTypes;
35+
model.Culture = metadata.Culture;
36+
37+
model.Cultures = CultureInfo.GetCultures(CultureTypes.AllCultures)
38+
.Select(x => new SelectListItem { Text = $"{x.Name} ({x.DisplayName})", Value = x.Name });
39+
40+
}).Location("Content:5");
41+
}
42+
43+
public override async Task<IDisplayResult> UpdateAsync(AzureAISearchIndexSettings settings, UpdateEditorContext context)
44+
{
45+
if (!string.Equals(AzureAISearchConstants.ContentsIndexSource, settings.Source, StringComparison.OrdinalIgnoreCase))
46+
{
47+
return null;
48+
}
49+
50+
var model = new ContentIndexMetadataViewModel();
51+
52+
await context.Updater.TryUpdateModelAsync(model, Prefix);
53+
54+
if (model.IndexedContentTypes is null || model.IndexedContentTypes.Length == 0)
55+
{
56+
context.Updater.ModelState.AddModelError(Prefix, nameof(model.IndexedContentTypes), S["At least one content type must be selected."]);
57+
}
58+
59+
settings.Alter<ContentIndexMetadata>(m =>
60+
{
61+
m.IndexLatest = model.IndexLatest;
62+
m.IndexedContentTypes = model.IndexedContentTypes ?? [];
63+
m.Culture = model.Culture;
64+
});
65+
66+
return Edit(settings, context);
67+
}
68+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
using System.Text.Json.Nodes;
2+
using Dapper;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using OrchardCore.Data;
5+
using OrchardCore.Data.Migration;
6+
using OrchardCore.Documents;
7+
using OrchardCore.Entities;
8+
using OrchardCore.Environment.Shell.Scope;
9+
using OrchardCore.Search.AzureAI.Models;
10+
using YesSql;
11+
using YesSql.Sql;
12+
13+
namespace OrchardCore.Search.AzureAI.Migrations;
14+
15+
/// <summary>
16+
/// In version 3, we introduced Source, Id and the ability to add metadata to index settings.
17+
/// This migration will migrate any index that was created before v3 to use the new structure.
18+
/// This migration will be removed in future releases.
19+
/// </summary>
20+
internal sealed class AzureAISearchIndexSettingsMigrations : DataMigration
21+
{
22+
#pragma warning disable CA1822 // Mark members as static
23+
public int Create()
24+
#pragma warning restore CA1822 // Mark members as static
25+
{
26+
ShellScope.AddDeferredTask(async scope =>
27+
{
28+
var store = scope.ServiceProvider.GetRequiredService<IStore>();
29+
var dbConnectionAccessor = scope.ServiceProvider.GetRequiredService<IDbConnectionAccessor>();
30+
var settingsManager = scope.ServiceProvider.GetRequiredService<IDocumentManager<AzureAISearchIndexSettingsDocument>>();
31+
32+
var documentTableName = store.Configuration.TableNameConvention.GetDocumentTable();
33+
var table = $"{store.Configuration.TablePrefix}{documentTableName}";
34+
var dialect = store.Configuration.SqlDialect;
35+
var quotedTableName = dialect.QuoteForTableName(table, store.Configuration.Schema);
36+
var quotedContentColumnName = dialect.QuoteForColumnName("Content");
37+
var quotedTypeColumnName = dialect.QuoteForColumnName("Type");
38+
39+
var sqlBuilder = new SqlBuilder(store.Configuration.TablePrefix, store.Configuration.SqlDialect);
40+
sqlBuilder.AddSelector(quotedContentColumnName);
41+
sqlBuilder.From(quotedTableName);
42+
sqlBuilder.WhereAnd($" {quotedTypeColumnName} = 'OrchardCore.Search.AzureAI.Models.AzureAISearchIndexSettingsDocument, OrchardCore.Search.AzureAI.Core' ");
43+
sqlBuilder.Take("1");
44+
45+
await using var connection = dbConnectionAccessor.CreateConnection();
46+
await connection.OpenAsync();
47+
var jsonContent = await connection.QueryFirstOrDefaultAsync<string>(sqlBuilder.ToSqlString());
48+
49+
if (string.IsNullOrEmpty(jsonContent))
50+
{
51+
return;
52+
}
53+
54+
var jsonObject = JsonNode.Parse(jsonContent);
55+
56+
if (jsonObject["IndexSettings"] is not JsonObject indexesObject)
57+
{
58+
return;
59+
}
60+
61+
var document = await settingsManager.GetOrCreateMutableAsync();
62+
63+
foreach (var indexObject in indexesObject)
64+
{
65+
var source = indexObject.Value["Source"]?.GetValue<string>();
66+
67+
if (!string.IsNullOrEmpty(source))
68+
{
69+
// No migration is needed.
70+
continue;
71+
}
72+
73+
var indexName = indexObject.Value["IndexName"]?.GetValue<string>();
74+
75+
if (string.IsNullOrEmpty(indexName))
76+
{
77+
// Bad index! this is a scenario that should never happen.
78+
continue;
79+
}
80+
81+
if (!document.IndexSettings.TryGetValue(indexName, out var indexSettings))
82+
{
83+
// Bad index! this is a scenario that should never happen.
84+
continue;
85+
}
86+
87+
indexSettings.Source = AzureAISearchConstants.ContentsIndexSource;
88+
89+
if (string.IsNullOrEmpty(indexSettings.Id))
90+
{
91+
indexSettings.Id = IdGenerator.GenerateId();
92+
}
93+
94+
var metadata = indexSettings.As<ContentIndexMetadata>();
95+
96+
if (string.IsNullOrEmpty(metadata.Culture))
97+
{
98+
metadata.Culture = indexObject.Value[nameof(ContentIndexMetadata.Culture)]?.GetValue<string>();
99+
}
100+
101+
var indexLatest = indexObject.Value[nameof(ContentIndexMetadata.IndexLatest)]?.GetValue<bool>();
102+
103+
if (indexLatest.HasValue)
104+
{
105+
metadata.IndexLatest = indexLatest.Value;
106+
}
107+
108+
var indexContentTypes = indexObject.Value[nameof(ContentIndexMetadata.IndexedContentTypes)]?.AsArray();
109+
110+
if (indexContentTypes is not null)
111+
{
112+
var items = new HashSet<string>();
113+
114+
foreach (var indexContentType in indexContentTypes)
115+
{
116+
var value = indexContentType.GetValue<string>();
117+
118+
if (!string.IsNullOrEmpty(value))
119+
{
120+
items.Add(value);
121+
}
122+
}
123+
124+
metadata.IndexedContentTypes = items.ToArray();
125+
}
126+
127+
indexSettings.Put(metadata);
128+
129+
document.IndexSettings.Remove(indexName);
130+
document.IndexSettings[indexSettings.Id] = indexSettings;
131+
}
132+
133+
await settingsManager.UpdateAsync(document);
134+
});
135+
136+
return 1;
137+
}
138+
}

src/OrchardCore.Modules/OrchardCore.Search.AzureAI/Startup.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Microsoft.AspNetCore.Authorization;
22
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Localization;
34
using OrchardCore.ContentTypes.Editors;
5+
using OrchardCore.Data.Migration;
46
using OrchardCore.Deployment;
57
using OrchardCore.DisplayManagement.Handlers;
68
using OrchardCore.Modules;
@@ -9,6 +11,8 @@
911
using OrchardCore.Search.AzureAI.Deployment;
1012
using OrchardCore.Search.AzureAI.Drivers;
1113
using OrchardCore.Search.AzureAI.Handlers;
14+
using OrchardCore.Search.AzureAI.Migrations;
15+
using OrchardCore.Search.AzureAI.Models;
1216
using OrchardCore.Search.AzureAI.Services;
1317

1418
namespace OrchardCore.Search.AzureAI;
@@ -20,6 +24,11 @@ public override void ConfigureServices(IServiceCollection services)
2024
services.AddAzureAISearchServices();
2125
services.AddSiteDisplayDriver<AzureAISearchDefaultSettingsDisplayDriver>();
2226
services.AddNavigationProvider<AdminMenu>();
27+
28+
services.AddDisplayDriver<AzureAISearchIndexSettings, AzureAISearchIndexSettingsDisplayDriver>();
29+
services.AddScoped<IAzureAISearchIndexSettingsHandler, AzureAISearchIndexHandler>();
30+
31+
services.AddDataMigration<AzureAISearchIndexSettingsMigrations>();
2332
}
2433
}
2534

@@ -45,6 +54,32 @@ public override void ConfigureServices(IServiceCollection services)
4554
}
4655
}
4756

57+
[RequireFeatures("OrchardCore.Contents")]
58+
public sealed class ContentsStartup : StartupBase
59+
{
60+
private readonly IStringLocalizer S;
61+
62+
public ContentsStartup(IStringLocalizer<ContentsStartup> stringLocalizer)
63+
{
64+
S = stringLocalizer;
65+
}
66+
67+
public override void ConfigureServices(IServiceCollection services)
68+
{
69+
services.AddScoped<IAzureAISearchEvents, ContentAzureAISearchEvents>();
70+
services.AddDisplayDriver<AzureAISearchIndexSettings, ContentAzureAISearchIndexSettingsDisplayDriver>();
71+
services.AddScoped<IAzureAISearchIndexSettingsHandler, ContentAzureAISearchIndexHandler>();
72+
services.Configure<AzureAISearchOptions>(options =>
73+
{
74+
options.AddIndexSource(AzureAISearchConstants.ContentsIndexSource, o =>
75+
{
76+
o.DisplayName = S["Contents"];
77+
o.Description = S["Create an index based on content items."];
78+
});
79+
});
80+
}
81+
}
82+
4883
[RequireFeatures("OrchardCore.Deployment")]
4984
public sealed class DeploymentStartup : StartupBase
5085
{

src/OrchardCore.Modules/OrchardCore.Search.AzureAI/ViewModels/AdminIndexViewModel.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ namespace OrchardCore.Search.AzureAI.ViewModels;
55
public class AdminIndexViewModel
66
{
77
[BindNever]
8-
public IEnumerable<IndexViewModel> Indexes { get; set; }
8+
public IList<AzureAIIndexEntry> Indexes { get; set; }
99

1010
public AzureAIIndexOptions Options { get; set; } = new();
1111

1212
[BindNever]
1313
public dynamic Pager { get; set; }
14+
15+
public IEnumerable<string> SourceNames { get; set; }
1416
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using OrchardCore.DisplayManagement;
2+
using OrchardCore.Search.AzureAI.Models;
3+
4+
namespace OrchardCore.Search.AzureAI.ViewModels;
5+
6+
public class AzureAIIndexEntry
7+
{
8+
public AzureAISearchIndexSettings Index { get; set; }
9+
10+
public IShape Shape { get; set; }
11+
}

0 commit comments

Comments
 (0)