Skip to content

Commit bec034c

Browse files
paulirwinclaude
andauthored
Implement synonym map support (closes #69) (#79)
Synonym maps are service-level resources with their own /synonymmaps routes, stored alongside the index definitions as {name}.synonymmap.json. A field opts in through the synonymMaps property it already carried but which was, until now, accepted and ignored. Expansion happens at query time only, as it does in Azure: the maps are resolved per search and layered onto the per-field search analyzer, so a field's own analyzer still decides how text is split before any synonym is considered. Expanding while indexing would bake the current rules into the stored terms and leave documents indexed before an edit disagreeing with those after it, repairable only by a full reindex. Doing it at query time is what makes a map safe to edit. Rules are parsed with Lucene's SolrSynonymParser, which is the format — and the only format — Azure supports. Both rule forms behave as the service documents them: an equivalency rule keeps the term that was typed and adds the alternatives, while an arrow rule replaces it. A field naming a map that does not exist, or one no query could reach, is rejected when the index is created rather than left to search unexpanded. A map that goes missing later is skipped instead, since the emulator's indexes and maps are separate files a user may restore independently, and failing every search would be a harsh answer to a dangling name that only widens results. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1bdf6c7 commit bec034c

19 files changed

Lines changed: 1953 additions & 24 deletions
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
using Azure;
2+
using Azure.Search.Documents;
3+
using Azure.Search.Documents.Indexes;
4+
using Azure.Search.Documents.Indexes.Models;
5+
using Azure.Search.Documents.Models;
6+
using Xunit;
7+
using SearchIndex = Azure.Search.Documents.Indexes.Models.SearchIndex;
8+
9+
namespace AzureSearchEmulator.IntegrationTests;
10+
11+
/// <summary>
12+
/// Integration tests for synonym map support (issue #69), run against a containerized emulator
13+
/// through the real Azure Search SDK.
14+
/// </summary>
15+
/// <remarks>
16+
/// The SDK is the point of these tests. It decides the routes a synonym map is created and read
17+
/// on, the <c>value</c> wrapper a listing comes back in, and the <c>format</c>/<c>synonyms</c>
18+
/// property names that go on the wire — none of which the unit tests exercise, because they
19+
/// call the emulator's own types directly. A map the emulator stored under a different shape,
20+
/// or a <c>synonymMaps</c> field property it accepted and ignored, would fail here rather than
21+
/// pass unnoticed.
22+
///
23+
/// The scenarios are the ones Azure's documentation gives: a query for a word the indexed
24+
/// documents do not contain, matched through a rule.
25+
/// </remarks>
26+
public class SynonymMapIntegrationTests(EmulatorFactory factory)
27+
: IClassFixture<EmulatorFactory>
28+
{
29+
private class Document
30+
{
31+
public string Id { get; set; } = "";
32+
33+
public string? Name { get; set; }
34+
}
35+
36+
private static readonly Document[] Products =
37+
[
38+
new() { Id = "1", Name = "canine chew toy" },
39+
new() { Id = "2", Name = "feline scratcher" },
40+
new() { Id = "3", Name = "united states map" },
41+
];
42+
43+
private static SearchIndex CreateIndex(string indexName, string? synonymMapName)
44+
{
45+
var name = new SearchField(nameof(Document.Name), SearchFieldDataType.String)
46+
{
47+
IsSearchable = true,
48+
IsStored = true,
49+
};
50+
51+
if (synonymMapName != null)
52+
{
53+
name.SynonymMapNames.Add(synonymMapName);
54+
}
55+
56+
return new SearchIndex(indexName)
57+
{
58+
Fields =
59+
{
60+
new SearchField(nameof(Document.Id), SearchFieldDataType.String)
61+
{ IsKey = true, IsSearchable = true, IsFilterable = true, IsStored = true },
62+
name,
63+
}
64+
};
65+
}
66+
67+
private static async Task<SearchClient> PopulateAsync(SearchIndexClient indexClient, SearchIndex index)
68+
{
69+
await indexClient.CreateIndexAsync(index, TestContext.Current.CancellationToken);
70+
71+
var searchClient = indexClient.GetSearchClient(index.Name);
72+
73+
var response = await searchClient.IndexDocumentsAsync(
74+
IndexDocumentsBatch.Upload(Products),
75+
cancellationToken: TestContext.Current.CancellationToken);
76+
77+
Assert.All(response.Value.Results, i => Assert.True(i.Succeeded));
78+
79+
await WaitForCountAsync(searchClient, Products.Length);
80+
81+
return searchClient;
82+
}
83+
84+
private static async Task<List<string>> IdsAsync(SearchClient searchClient, string search)
85+
{
86+
var response = await searchClient.SearchAsync<Document>(
87+
search, new SearchOptions(), TestContext.Current.CancellationToken);
88+
89+
var ids = new List<string>();
90+
91+
await foreach (var result in response.Value.GetResultsAsync())
92+
{
93+
ids.Add(result.Document.Id);
94+
}
95+
96+
return ids;
97+
}
98+
99+
/// <summary>
100+
/// A synonym map survives being written and read back through the SDK, with its rules
101+
/// intact.
102+
/// </summary>
103+
/// <remarks>
104+
/// Read back before any search, so a persistence fault shows as a definition mismatch here
105+
/// rather than as a mysteriously empty result later.
106+
/// </remarks>
107+
[Fact]
108+
public async Task SynonymMap_RoundTrips()
109+
{
110+
const string mapName = "test-synonyms-roundtrip";
111+
112+
var indexClient = factory.CreateSearchIndexClient();
113+
114+
await indexClient.CreateSynonymMapAsync(
115+
new SynonymMap(mapName, "usa, united states\ndog => canine"),
116+
TestContext.Current.CancellationToken);
117+
118+
var read = await indexClient.GetSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
119+
120+
Assert.Equal(mapName, read.Value.Name);
121+
Assert.Contains("usa, united states", read.Value.Synonyms);
122+
Assert.Contains("dog => canine", read.Value.Synonyms);
123+
124+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
125+
}
126+
127+
/// <summary>
128+
/// A created map appears in the listing, which the SDK reads out of the <c>value</c> wrapper.
129+
/// </summary>
130+
[Fact]
131+
public async Task SynonymMaps_AreListed()
132+
{
133+
const string mapName = "test-synonyms-listed";
134+
135+
var indexClient = factory.CreateSearchIndexClient();
136+
137+
await indexClient.CreateSynonymMapAsync(
138+
new SynonymMap(mapName, "usa, united states"),
139+
TestContext.Current.CancellationToken);
140+
141+
var names = await indexClient.GetSynonymMapNamesAsync(TestContext.Current.CancellationToken);
142+
143+
Assert.Contains(mapName, names.Value);
144+
145+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
146+
}
147+
148+
/// <summary>
149+
/// A deleted map is gone.
150+
/// </summary>
151+
[Fact]
152+
public async Task DeletedSynonymMap_IsNotFound()
153+
{
154+
const string mapName = "test-synonyms-deleted";
155+
156+
var indexClient = factory.CreateSearchIndexClient();
157+
158+
await indexClient.CreateSynonymMapAsync(
159+
new SynonymMap(mapName, "usa, united states"),
160+
TestContext.Current.CancellationToken);
161+
162+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
163+
164+
var ex = await Assert.ThrowsAsync<RequestFailedException>(() =>
165+
indexClient.GetSynonymMapAsync(mapName, TestContext.Current.CancellationToken));
166+
167+
Assert.Equal(404, ex.Status);
168+
}
169+
170+
/// <summary>
171+
/// An edit replaces the rules, and the map reads back with the new ones.
172+
/// </summary>
173+
[Fact]
174+
public async Task SynonymMap_CanBeEdited()
175+
{
176+
const string mapName = "test-synonyms-edited";
177+
178+
var indexClient = factory.CreateSearchIndexClient();
179+
180+
await indexClient.CreateSynonymMapAsync(
181+
new SynonymMap(mapName, "usa, united states"),
182+
TestContext.Current.CancellationToken);
183+
184+
await indexClient.CreateOrUpdateSynonymMapAsync(
185+
new SynonymMap(mapName, "dog, canine"),
186+
onlyIfUnchanged: false,
187+
cancellationToken: TestContext.Current.CancellationToken);
188+
189+
var read = await indexClient.GetSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
190+
191+
Assert.Equal("dog, canine", read.Value.Synonyms);
192+
193+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
194+
}
195+
196+
/// <summary>
197+
/// The scenario the feature exists for: a query for a word no document contains matches
198+
/// through an equivalency rule.
199+
/// </summary>
200+
[Fact]
201+
public async Task EquivalencyRule_WidensTheQuery()
202+
{
203+
const string indexName = "test-synonym-equivalency";
204+
const string mapName = "test-synonyms-equivalency";
205+
206+
var indexClient = factory.CreateSearchIndexClient();
207+
208+
await indexClient.CreateSynonymMapAsync(
209+
new SynonymMap(mapName, "dog, canine"),
210+
TestContext.Current.CancellationToken);
211+
212+
var searchClient = await PopulateAsync(indexClient, CreateIndex(indexName, mapName));
213+
214+
Assert.Equal(["1"], await IdsAsync(searchClient, "dog"));
215+
216+
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
217+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
218+
}
219+
220+
/// <summary>
221+
/// Sits beside the test above: the same query against a field naming no map matches
222+
/// nothing, so the match there comes from the expansion rather than from looser matching.
223+
/// </summary>
224+
[Fact]
225+
public async Task WithoutASynonymMap_TheSameQueryMatchesNothing()
226+
{
227+
const string indexName = "test-synonym-none";
228+
229+
var indexClient = factory.CreateSearchIndexClient();
230+
231+
var searchClient = await PopulateAsync(indexClient, CreateIndex(indexName, synonymMapName: null));
232+
233+
Assert.Empty(await IdsAsync(searchClient, "dog"));
234+
235+
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
236+
}
237+
238+
/// <summary>
239+
/// A mapping rule replaces the query's term rather than adding to it.
240+
/// </summary>
241+
[Fact]
242+
public async Task MappingRule_SearchesForTheReplacement()
243+
{
244+
const string indexName = "test-synonym-mapping";
245+
const string mapName = "test-synonyms-mapping";
246+
247+
var indexClient = factory.CreateSearchIndexClient();
248+
249+
await indexClient.CreateSynonymMapAsync(
250+
new SynonymMap(mapName, "cat => feline"),
251+
TestContext.Current.CancellationToken);
252+
253+
var searchClient = await PopulateAsync(indexClient, CreateIndex(indexName, mapName));
254+
255+
Assert.Equal(["2"], await IdsAsync(searchClient, "cat"));
256+
257+
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
258+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
259+
}
260+
261+
/// <summary>
262+
/// A multi-word rule matches as a phrase.
263+
/// </summary>
264+
[Fact]
265+
public async Task MultiWordRule_MatchesThePhrase()
266+
{
267+
const string indexName = "test-synonym-phrase";
268+
const string mapName = "test-synonyms-phrase";
269+
270+
var indexClient = factory.CreateSearchIndexClient();
271+
272+
await indexClient.CreateSynonymMapAsync(
273+
new SynonymMap(mapName, "usa, united states"),
274+
TestContext.Current.CancellationToken);
275+
276+
var searchClient = await PopulateAsync(indexClient, CreateIndex(indexName, mapName));
277+
278+
Assert.Equal(["3"], await IdsAsync(searchClient, "usa"));
279+
280+
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
281+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
282+
}
283+
284+
/// <summary>
285+
/// Editing a map changes what a query matches without the documents being touched, which is
286+
/// the property that makes a map safe to edit.
287+
/// </summary>
288+
[Fact]
289+
public async Task EditedSynonymMap_TakesEffectWithoutReindexing()
290+
{
291+
const string indexName = "test-synonym-reedit";
292+
const string mapName = "test-synonyms-reedit";
293+
294+
var indexClient = factory.CreateSearchIndexClient();
295+
296+
await indexClient.CreateSynonymMapAsync(
297+
new SynonymMap(mapName, "bird, avian"),
298+
TestContext.Current.CancellationToken);
299+
300+
var searchClient = await PopulateAsync(indexClient, CreateIndex(indexName, mapName));
301+
302+
Assert.Empty(await IdsAsync(searchClient, "dog"));
303+
304+
await indexClient.CreateOrUpdateSynonymMapAsync(
305+
new SynonymMap(mapName, "dog, canine"),
306+
onlyIfUnchanged: false,
307+
cancellationToken: TestContext.Current.CancellationToken);
308+
309+
Assert.Equal(["1"], await IdsAsync(searchClient, "dog"));
310+
311+
await indexClient.DeleteIndexAsync(indexName, TestContext.Current.CancellationToken);
312+
await indexClient.DeleteSynonymMapAsync(mapName, TestContext.Current.CancellationToken);
313+
}
314+
315+
/// <summary>
316+
/// An index naming a synonym map that does not exist is refused when it is created, rather
317+
/// than accepted and left to search unexpanded.
318+
/// </summary>
319+
[Fact]
320+
public async Task IndexNamingAMissingSynonymMap_IsRejected()
321+
{
322+
const string indexName = "test-synonym-missing";
323+
324+
var indexClient = factory.CreateSearchIndexClient();
325+
326+
var ex = await Assert.ThrowsAsync<RequestFailedException>(() =>
327+
indexClient.CreateIndexAsync(
328+
CreateIndex(indexName, "no-such-map"),
329+
TestContext.Current.CancellationToken));
330+
331+
Assert.Equal(400, ex.Status);
332+
}
333+
334+
private static async Task WaitForCountAsync(SearchClient searchClient, int expected)
335+
{
336+
for (var attempt = 0; attempt < 20; attempt++)
337+
{
338+
var count = await searchClient.GetDocumentCountAsync(TestContext.Current.CancellationToken);
339+
340+
if (count.Value >= expected)
341+
{
342+
return;
343+
}
344+
345+
await Task.Delay(100, TestContext.Current.CancellationToken);
346+
}
347+
348+
Assert.Fail($"{expected} documents were expected to become searchable, but the count never reached it.");
349+
}
350+
}

0 commit comments

Comments
 (0)