Skip to content

Commit 4b2d89a

Browse files
committed
Ability to control which fields are full text indexed
1 parent fd9302f commit 4b2d89a

File tree

6 files changed

+100
-48
lines changed

6 files changed

+100
-48
lines changed

src/Foundatio.Parsers.SqlQueries/Extensions/SqlNodeExtensions.cs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
187187
builder.Append(i.IsFirst ? "(" : " OR ");
188188
builder.Append(fieldPrefix);
189189

190-
if (context.FullTextSearchEnabled)
190+
if (context.FullTextFields.Contains(kvp.Key.Name, StringComparer.OrdinalIgnoreCase))
191191
{
192192
builder.Append("FTS.Contains(");
193193
builder.Append(kvp.Key.Name);
@@ -215,12 +215,12 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
215215
builder.Append(i.IsFirst ? "(" : " OR ");
216216
builder.Append(fieldPrefix);
217217

218-
if (context.FullTextSearchEnabled)
218+
if (context.FullTextFields.Contains(kvp.Key.Name, StringComparer.OrdinalIgnoreCase))
219219
{
220220
builder.Append("FTS.Contains(");
221221
builder.Append(kvp.Key.Name);
222222
builder.Append(", ");
223-
AppendField(builder, kvp.Key, token + "*", context);
223+
AppendField(builder, kvp.Key, "\\\"" + token + "*\\\"", context);
224224
builder.Append(")");
225225
}
226226
else
@@ -266,19 +266,45 @@ public static string ToDynamicLinqString(this TermNode node, ISqlQueryVisitorCon
266266
else if (searchOperator == SqlSearchOperator.Contains)
267267
{
268268
builder.Append(fieldPrefix);
269-
builder.Append(field.Name);
270-
builder.Append(".Contains(");
271-
AppendField(builder, field, node.Term, context);
272-
builder.Append(")");
269+
270+
if (context.FullTextFields.Contains(field.Name, StringComparer.OrdinalIgnoreCase))
271+
{
272+
builder.Append("FTS.Contains(");
273+
builder.Append(field.Name);
274+
builder.Append(", ");
275+
AppendField(builder, field, node.Term, context);
276+
builder.Append(")");
277+
}
278+
else
279+
{
280+
builder.Append(field.Name);
281+
builder.Append(".Contains(");
282+
AppendField(builder, field, node.Term, context);
283+
builder.Append(")");
284+
}
285+
273286
builder.Append(fieldSuffix);
274287
}
275288
else
276289
{
277290
builder.Append(fieldPrefix);
278-
builder.Append(field.Name);
279-
builder.Append(".StartsWith(");
280-
AppendField(builder, field, node.Term, context);
281-
builder.Append(")");
291+
292+
if (context.FullTextFields.Contains(field.Name, StringComparer.OrdinalIgnoreCase))
293+
{
294+
builder.Append("FTS.Contains(");
295+
builder.Append(field.Name);
296+
builder.Append(", ");
297+
AppendField(builder, field, "\\\"" + node.Term + "*\\\"", context);
298+
builder.Append(")");
299+
}
300+
else
301+
{
302+
builder.Append(field.Name);
303+
builder.Append(".Contains(");
304+
AppendField(builder, field, node.Term, context);
305+
builder.Append(")");
306+
}
307+
282308
builder.Append(fieldSuffix);
283309
}
284310

src/Foundatio.Parsers.SqlQueries/SqlQueryParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ private void SetupQueryVisitorContextDefaults(IQueryVisitorContext context)
225225
sqlContext.DateTimeParser = Configuration.DateTimeParser;
226226
sqlContext.DateOnlyParser = Configuration.DateOnlyParser;
227227
sqlContext.DefaultSearchOperator = Configuration.DefaultFieldsSearchOperator;
228-
sqlContext.FullTextSearchEnabled = Configuration.FullTextSearchEnabled;
228+
sqlContext.FullTextFields = Configuration.FullTextFields;
229229
}
230230
}
231231
}

src/Foundatio.Parsers.SqlQueries/SqlQueryParserConfiguration.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ public SqlQueryParserConfiguration()
2424
public ILoggerFactory LoggerFactory { get; private set; } = NullLoggerFactory.Instance;
2525
public string[] DefaultFields { get; private set; }
2626
public SqlSearchOperator DefaultFieldsSearchOperator { get; private set; } = SqlSearchOperator.StartsWith;
27-
public bool FullTextSearchEnabled { get; private set; } = false;
28-
27+
public string[] FullTextFields { get; private set; }
2928
public int MaxFieldDepth { get; private set; } = 10;
3029
public QueryFieldResolver FieldResolver { get; private set; }
3130
public Action<SearchTerm> SearchTokenizer { get; set; } = static _ => { };
@@ -61,9 +60,9 @@ public SqlQueryParserConfiguration SetSearchTokenizer(Action<SearchTerm> tokeniz
6160
return this;
6261
}
6362

64-
public SqlQueryParserConfiguration UseFullTextSearch(bool useFullTextSearch = true)
63+
public SqlQueryParserConfiguration SetFullTextFields(string[] fields)
6564
{
66-
FullTextSearchEnabled = useFullTextSearch;
65+
FullTextFields = fields;
6766
return this;
6867
}
6968

src/Foundatio.Parsers.SqlQueries/Visitors/ISqlQueryVisitorContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public interface ISqlQueryVisitorContext : IQueryVisitorContext
88
{
99
List<EntityFieldInfo> Fields { get; set; }
1010
SqlSearchOperator DefaultSearchOperator { get; set; }
11-
bool FullTextSearchEnabled { get; set; }
11+
string[] FullTextFields { get; set; }
1212
Action<SearchTerm> SearchTokenizer { get; set; }
1313
Func<string, string> DateTimeParser { get; set; }
1414
Func<string, string> DateOnlyParser { get; set; }

src/Foundatio.Parsers.SqlQueries/Visitors/SqlQueryVisitorContext.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public class SqlQueryVisitorContext : QueryVisitorContext, ISqlQueryVisitorConte
1111
{
1212
public List<EntityFieldInfo> Fields { get; set; }
1313
public SqlSearchOperator DefaultSearchOperator { get; set; } = SqlSearchOperator.StartsWith;
14-
public bool FullTextSearchEnabled { get; set; } = false;
14+
public string[] FullTextFields { get; set; } = [];
1515
public Action<SearchTerm> SearchTokenizer { get; set; } = static _ => { };
1616
public Func<string, string> DateTimeParser { get; set; }
1717
public Func<string, string> DateOnlyParser { get; set; }

tests/Foundatio.Parsers.SqlQueries.Tests/SqlQueryParserTests.cs

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Foundatio.Parsers.LuceneQueries.Visitors;
88
using Foundatio.Parsers.SqlQueries.Visitors;
99
using Foundatio.Xunit;
10+
using Microsoft.Data.SqlClient;
1011
using Microsoft.EntityFrameworkCore;
1112
using Microsoft.EntityFrameworkCore.Infrastructure;
1213
using Microsoft.Extensions.DependencyInjection;
@@ -70,14 +71,17 @@ public async Task CanSearchDefaultFields()
7071

7172
var context = parser.GetContext(db.Employees.EntityType);
7273

73-
string sqlExpected = db.Employees.Where(e => e.FullName.StartsWith("John") || e.Title.StartsWith("John")).ToQueryString();
74-
string sqlActual = db.Employees.Where("""FullName.StartsWith("John") || Title.StartsWith("John") """).ToQueryString();
74+
string sqlExpected = db.Employees.Where(e => EF.Functions.Contains(e.FullName, "\"John*\"") || EF.Functions.Contains(e.Title, "\"John*\"")).ToQueryString();
75+
string sqlActual = db.Employees.Where(parser.ParsingConfig, """FTS.Contains(FullName, "\"John*\"") || FTS.Contains(Title, "\"John*\"") """).ToQueryString();
7576
Assert.Equal(sqlExpected, sqlActual);
7677
string sql = await parser.ToDynamicLinqAsync("John", context);
77-
sqlActual = db.Employees.Where(sql).ToQueryString();
78-
var results = await db.Employees.Where(sql).ToListAsync();
79-
Assert.Single(results);
78+
sqlActual = db.Employees.Where(parser.ParsingConfig, sql).ToQueryString();
8079
Assert.Equal(sqlExpected, sqlActual);
80+
81+
await WaitForFullTextIndexAsync(db, "ftCatalog");
82+
83+
var results = await db.Employees.Where(parser.ParsingConfig, sql).ToListAsync();
84+
Assert.Single(results);
8185
}
8286

8387
[Fact]
@@ -101,28 +105,31 @@ public async Task CanSearchWithTokenizer()
101105

102106
var context = parser.GetContext(db.Employees.EntityType);
103107

104-
string sqlExpected = db.Employees.Where(e => e.NationalPhoneNumber.StartsWith("2142222222")).ToQueryString();
105-
string sqlActual = db.Employees.Where("NationalPhoneNumber.StartsWith(\"2142222222\")").ToQueryString();
108+
string sqlExpected = db.Employees.Where(e => EF.Functions.Contains(e.NationalPhoneNumber, "\"2142222222*\"")).ToQueryString();
109+
string sqlActual = db.Employees.Where(parser.ParsingConfig, "FTS.Contains(NationalPhoneNumber, \"\\\"2142222222*\\\"\")").ToQueryString();
106110
Assert.Equal(sqlExpected, sqlActual);
107111

108112
string sql = await parser.ToDynamicLinqAsync("214-222-2222", context);
109113
_logger.LogInformation(sql);
110-
sqlActual = db.Employees.Where(sql).ToQueryString();
111-
var results = await db.Employees.Where(sql).ToListAsync();
112-
Assert.Single(results);
113114
Assert.Equal(sqlExpected, sqlActual);
114115

116+
await WaitForFullTextIndexAsync(db, "ftCatalog");
117+
118+
sqlActual = db.Employees.Where(parser.ParsingConfig, sql).ToQueryString();
119+
var results = await db.Employees.Where(parser.ParsingConfig, sql).ToListAsync();
120+
Assert.Single(results);
121+
115122
sql = await parser.ToDynamicLinqAsync("2142222222", context);
116123
_logger.LogInformation(sql);
117-
sqlActual = db.Employees.Where(sql).ToQueryString();
118-
results = await db.Employees.Where(sql).ToListAsync();
124+
sqlActual = db.Employees.Where(parser.ParsingConfig, sql).ToQueryString();
125+
results = await db.Employees.Where(parser.ParsingConfig, sql).ToListAsync();
119126
Assert.Single(results);
120127
Assert.Equal(sqlExpected, sqlActual);
121128

122129
sql = await parser.ToDynamicLinqAsync("21422", context);
123130
_logger.LogInformation(sql);
124-
sqlActual = db.Employees.Where(sql).ToQueryString();
125-
results = await db.Employees.Where(sql).ToListAsync();
131+
sqlActual = db.Employees.Where(parser.ParsingConfig, sql).ToQueryString();
132+
results = await db.Employees.Where(parser.ParsingConfig, sql).ToListAsync();
126133
Assert.Single(results);
127134
}
128135

@@ -142,8 +149,8 @@ public async Task CanHandleEmptyTokens()
142149

143150
string sql = await parser.ToDynamicLinqAsync("test", context);
144151
_logger.LogInformation(sql);
145-
string sqlActual = db.Employees.Where(sql).ToQueryString();
146-
var results = await db.Employees.Where(sql).ToListAsync();
152+
string sqlActual = db.Employees.Where(parser.ParsingConfig, sql).ToQueryString();
153+
var results = await db.Employees.Where(parser.ParsingConfig, sql).ToListAsync();
147154
Assert.Empty(results);
148155
}
149156

@@ -302,11 +309,11 @@ public async Task CanUseCollectionDefaultFields()
302309

303310
var context = parser.GetContext(db.Employees.EntityType);
304311

305-
string sqlExpected = db.Employees.Where(e => e.Companies.Any(c => c.Name.StartsWith("acme"))).ToQueryString();
306-
string sqlActual = db.Employees.Where("""Companies.Any(Name.StartsWith("acme"))""").ToQueryString();
312+
string sqlExpected = db.Employees.Where(e => e.Companies.Any(c => EF.Functions.Contains(c.Name, "\"acme*\""))).ToQueryString();
313+
string sqlActual = db.Employees.Where(parser.ParsingConfig, """Companies.Any(FTS.Contains(Name, "\"acme*\""))""").ToQueryString();
307314
Assert.Equal(sqlExpected, sqlActual);
308315
string sql = await parser.ToDynamicLinqAsync("acme", context);
309-
sqlActual = db.Employees.Where(sql).ToQueryString();
316+
sqlActual = db.Employees.Where(parser.ParsingConfig, sql).ToQueryString();
310317
Assert.Equal(sqlExpected, sqlActual);
311318
}
312319

@@ -450,7 +457,7 @@ public IServiceProvider GetServiceProvider()
450457
parser.Configuration.UseEntityTypePropertyFilter(p => p.Name != nameof(Company.Description));
451458
parser.Configuration.AddQueryVisitor(new DynamicFieldVisitor());
452459
parser.Configuration.SetDefaultFields(["FullName"], SqlSearchOperator.Contains);
453-
parser.Configuration.UseFullTextSearch();
460+
parser.Configuration.SetFullTextFields(["Name", "FullName", "Title", "NationalPhoneNumber"]);
454461
services.AddSingleton(parser);
455462
return services.BuildServiceProvider();
456463
}
@@ -504,24 +511,23 @@ await db.Database.ExecuteSqlRawAsync(
504511
@"IF FULLTEXTSERVICEPROPERTY('IsFullTextInstalled') != 1
505512
BEGIN
506513
RAISERROR('Full-Text Search is not installed', 16, 1);
507-
END");
514+
END
508515
509-
await db.Database.ExecuteSqlRawAsync(
510-
@"IF NOT EXISTS (SELECT * FROM sys.fulltext_catalogs WHERE name = 'ftCatalog')
516+
IF NOT EXISTS (SELECT * FROM sys.fulltext_catalogs WHERE name = 'ftCatalog')
511517
BEGIN
512518
CREATE FULLTEXT CATALOG ftCatalog AS DEFAULT;
513-
END");
519+
END
514520
515-
await db.Database.ExecuteSqlRawAsync(
516-
@"IF EXISTS (SELECT * FROM sys.fulltext_indexes WHERE object_id = OBJECT_ID('Employees'))
521+
IF EXISTS (SELECT * FROM sys.fulltext_indexes WHERE object_id = OBJECT_ID('Employees'))
517522
BEGIN
518523
DROP FULLTEXT INDEX ON Employees;
519-
END");
524+
END
520525
521-
await db.Database.ExecuteSqlRawAsync(
522-
@"CREATE FULLTEXT INDEX ON Employees
526+
CREATE FULLTEXT INDEX ON Employees
523527
(
524-
FullName LANGUAGE 1033
528+
FullName LANGUAGE 1033,
529+
NationalPhoneNumber LANGUAGE 1033,
530+
Title LANGUAGE 1033
525531
)
526532
KEY INDEX PK_Employees
527533
ON ftCatalog
@@ -530,6 +536,27 @@ ON ftCatalog
530536
return db;
531537
}
532538

539+
private async Task WaitForFullTextIndexAsync(DbContext db, string catalogName, int timeoutSeconds = 30)
540+
{
541+
var end = DateTime.UtcNow.AddSeconds(timeoutSeconds);
542+
543+
while (DateTime.UtcNow < end)
544+
{
545+
string sql = "SELECT FULLTEXTCATALOGPROPERTY(@catalogName, 'PopulateStatus') AS Value";
546+
547+
int status = await db.Database
548+
.SqlQueryRaw<int>(sql, new SqlParameter("@catalogName", catalogName))
549+
.SingleAsync();
550+
551+
if (status == 0)
552+
return;
553+
554+
await Task.Delay(500);
555+
}
556+
557+
throw new TimeoutException($"Full-text catalog '{catalogName}' didn't finish populating in time.");
558+
}
559+
533560
private async Task ParseAndValidateQuery(string query, string expected, bool isValid)
534561
{
535562
#if ENABLE_TRACING

0 commit comments

Comments
 (0)