Skip to content

Commit 14a9900

Browse files
committed
Tests and cleanup
1 parent 9225c78 commit 14a9900

File tree

14 files changed

+1588
-417
lines changed

14 files changed

+1588
-417
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ public class LowercaseTermVisitor : QueryNodeVisitor
144144
{
145145
// Process this node's field
146146
node.Field = node.Field?.ToLowerInvariant();
147-
147+
148148
// Visit children
149149
return await base.VisitAsync(node, context);
150150
}
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
using System.Linq.Expressions;
2+
using BenchmarkDotNet.Attributes;
3+
using BenchmarkDotNet.Configs;
4+
using BenchmarkDotNet.Jobs;
5+
using BenchmarkDotNet.Toolchains.InProcess.Emit;
6+
using Foundatio.LuceneQueryParser.EntityFramework;
7+
using Microsoft.EntityFrameworkCore;
8+
9+
namespace Foundatio.LuceneQueryParser.Benchmarks;
10+
11+
/// <summary>
12+
/// Benchmarks for Entity Framework query generation from Lucene queries.
13+
/// Measures the overhead of converting Lucene AST to LINQ expressions.
14+
/// </summary>
15+
[MemoryDiagnoser]
16+
[Config(typeof(AntiVirusFriendlyConfig))]
17+
public class EntityFrameworkBenchmarks
18+
{
19+
// Use InProcess toolchain to avoid antivirus/rebuild issues
20+
private class AntiVirusFriendlyConfig : ManualConfig
21+
{
22+
public AntiVirusFriendlyConfig()
23+
{
24+
AddJob(Job.ShortRun.WithToolchain(InProcessEmitToolchain.Instance));
25+
}
26+
}
27+
28+
// Query complexity levels
29+
private const string SimpleTermQuery = "John";
30+
private const string SimpleFieldQuery = "Name:John";
31+
private const string MultiFieldQuery = "Name:John AND Age:30";
32+
private const string WildcardQuery = "Name:John* AND Email:*@acme.com";
33+
private const string RangeQuery = "Salary:[50000 TO 100000] AND Age:[25 TO 40]";
34+
private const string ComplexQuery = "Name:John AND (Title:Engineer OR Title:Developer) AND Salary:[50000 TO *] AND IsActive:true";
35+
private const string NavigationQuery = "Company.Name:Acme AND Department.Name:Engineering";
36+
private const string CollectionQuery = "Employees.Name:John";
37+
private const string FullTextQuery = "Name:developer AND Title:senior";
38+
39+
private EntityFrameworkQueryParser _parser = null!;
40+
private EntityFrameworkQueryParser _parserWithFullText = null!;
41+
private EntityFrameworkQueryVisitorContext _context = null!;
42+
private EntityFrameworkQueryVisitorContext _contextWithFullText = null!;
43+
private BenchmarkDbContext _dbContext = null!;
44+
45+
// Pre-parsed documents for expression building benchmarks
46+
private LuceneParseResult _simpleTermParsed = null!;
47+
private LuceneParseResult _simpleFieldParsed = null!;
48+
private LuceneParseResult _multiFieldParsed = null!;
49+
private LuceneParseResult _wildcardParsed = null!;
50+
private LuceneParseResult _rangeParsed = null!;
51+
private LuceneParseResult _complexParsed = null!;
52+
53+
[GlobalSetup]
54+
public void Setup()
55+
{
56+
// Setup parser without full-text
57+
_parser = new EntityFrameworkQueryParser();
58+
59+
// Setup parser with full-text fields
60+
_parserWithFullText = new EntityFrameworkQueryParser(config =>
61+
{
62+
config.AddFullTextFields("Employee.Name", "Employee.Title");
63+
});
64+
65+
// Setup DbContext for metadata-based discovery
66+
var options = new DbContextOptionsBuilder<BenchmarkDbContext>()
67+
.UseInMemoryDatabase("BenchmarkDb")
68+
.Options;
69+
_dbContext = new BenchmarkDbContext(options);
70+
71+
// Pre-create contexts
72+
_context = new EntityFrameworkQueryVisitorContext();
73+
_contextWithFullText = new EntityFrameworkQueryVisitorContext();
74+
75+
// Pre-parse queries for expression-only benchmarks
76+
_simpleTermParsed = LuceneQuery.Parse(SimpleTermQuery);
77+
_simpleFieldParsed = LuceneQuery.Parse(SimpleFieldQuery);
78+
_multiFieldParsed = LuceneQuery.Parse(MultiFieldQuery);
79+
_wildcardParsed = LuceneQuery.Parse(WildcardQuery);
80+
_rangeParsed = LuceneQuery.Parse(RangeQuery);
81+
_complexParsed = LuceneQuery.Parse(ComplexQuery);
82+
}
83+
84+
[GlobalCleanup]
85+
public void Cleanup()
86+
{
87+
_dbContext.Dispose();
88+
}
89+
90+
#region Full Pipeline (Parse + Build Expression)
91+
92+
[Benchmark(Baseline = true)]
93+
public Expression<Func<Employee, bool>> BuildFilter_SimpleTerm()
94+
=> _parser.BuildFilter<Employee>(SimpleTermQuery);
95+
96+
[Benchmark]
97+
public Expression<Func<Employee, bool>> BuildFilter_SimpleField()
98+
=> _parser.BuildFilter<Employee>(SimpleFieldQuery);
99+
100+
[Benchmark]
101+
public Expression<Func<Employee, bool>> BuildFilter_MultiField()
102+
=> _parser.BuildFilter<Employee>(MultiFieldQuery);
103+
104+
[Benchmark]
105+
public Expression<Func<Employee, bool>> BuildFilter_Wildcard()
106+
=> _parser.BuildFilter<Employee>(WildcardQuery);
107+
108+
[Benchmark]
109+
public Expression<Func<Employee, bool>> BuildFilter_Range()
110+
=> _parser.BuildFilter<Employee>(RangeQuery);
111+
112+
[Benchmark]
113+
public Expression<Func<Employee, bool>> BuildFilter_Complex()
114+
=> _parser.BuildFilter<Employee>(ComplexQuery);
115+
116+
#endregion
117+
118+
#region Navigation Properties
119+
120+
[Benchmark]
121+
public Expression<Func<Employee, bool>> BuildFilter_Navigation()
122+
=> _parser.BuildFilter<Employee>(NavigationQuery);
123+
124+
[Benchmark]
125+
public Expression<Func<Company, bool>> BuildFilter_Collection()
126+
=> _parser.BuildFilter<Company>(CollectionQuery);
127+
128+
#endregion
129+
130+
#region Full-Text Search
131+
132+
[Benchmark]
133+
public Expression<Func<Employee, bool>> BuildFilter_FullText()
134+
=> _parserWithFullText.BuildFilter<Employee>(FullTextQuery);
135+
136+
#endregion
137+
138+
#region Context Reuse Comparison
139+
140+
[Benchmark]
141+
public Expression<Func<Employee, bool>> BuildFilter_NewContext()
142+
{
143+
var context = new EntityFrameworkQueryVisitorContext();
144+
return _parser.BuildFilter<Employee>(SimpleFieldQuery, context);
145+
}
146+
147+
[Benchmark]
148+
public Expression<Func<Employee, bool>> BuildFilter_ReusedContext()
149+
{
150+
// Note: In real usage, context should be reset between uses
151+
return _parser.BuildFilter<Employee>(SimpleFieldQuery, _context);
152+
}
153+
154+
#endregion
155+
156+
#region With EF Metadata Discovery
157+
158+
[Benchmark]
159+
public Expression<Func<Employee, bool>> BuildFilter_WithEfMetadata()
160+
{
161+
var entityType = _dbContext.Model.FindEntityType(typeof(Employee))!;
162+
return _parser.BuildFilter<Employee>(SimpleFieldQuery, entityType);
163+
}
164+
165+
#endregion
166+
}
167+
168+
#region Benchmark Entities
169+
170+
public class BenchmarkDbContext : DbContext
171+
{
172+
public BenchmarkDbContext(DbContextOptions<BenchmarkDbContext> options) : base(options) { }
173+
174+
public DbSet<Employee> Employees => Set<Employee>();
175+
public DbSet<Company> Companies => Set<Company>();
176+
public DbSet<Department> Departments => Set<Department>();
177+
178+
protected override void OnModelCreating(ModelBuilder modelBuilder)
179+
{
180+
modelBuilder.Entity<Employee>(entity =>
181+
{
182+
entity.HasKey(e => e.Id);
183+
entity.HasOne(e => e.Company).WithMany(c => c.Employees).HasForeignKey(e => e.CompanyId);
184+
entity.HasOne(e => e.Department).WithMany(d => d.Employees).HasForeignKey(e => e.DepartmentId);
185+
});
186+
187+
modelBuilder.Entity<Company>(entity =>
188+
{
189+
entity.HasKey(c => c.Id);
190+
});
191+
192+
modelBuilder.Entity<Department>(entity =>
193+
{
194+
entity.HasKey(d => d.Id);
195+
entity.HasOne(d => d.Company).WithMany(c => c.Departments).HasForeignKey(d => d.CompanyId);
196+
});
197+
}
198+
}
199+
200+
public class Employee
201+
{
202+
public int Id { get; set; }
203+
public string Name { get; set; } = "";
204+
public string? Email { get; set; }
205+
public string? Title { get; set; }
206+
public int Age { get; set; }
207+
public decimal Salary { get; set; }
208+
public bool IsActive { get; set; }
209+
public DateTime HireDate { get; set; }
210+
211+
public int CompanyId { get; set; }
212+
public Company Company { get; set; } = null!;
213+
214+
public int? DepartmentId { get; set; }
215+
public Department? Department { get; set; }
216+
}
217+
218+
public class Company
219+
{
220+
public int Id { get; set; }
221+
public string Name { get; set; } = "";
222+
public string? Location { get; set; }
223+
224+
public ICollection<Employee> Employees { get; set; } = new List<Employee>();
225+
public ICollection<Department> Departments { get; set; } = new List<Department>();
226+
}
227+
228+
public class Department
229+
{
230+
public int Id { get; set; }
231+
public string Name { get; set; } = "";
232+
public decimal Budget { get; set; }
233+
234+
public int CompanyId { get; set; }
235+
public Company Company { get; set; } = null!;
236+
237+
public ICollection<Employee> Employees { get; set; } = new List<Employee>();
238+
}
239+
240+
#endregion

benchmarks/Foundatio.LuceneQueryParser.Benchmarks/Foundatio.LuceneQueryParser.Benchmarks.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@
1111
<ItemGroup>
1212
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" />
1313
<PackageReference Include="Foundatio.Parsers.LuceneQueries" Version="7.*" />
14+
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.0" />
15+
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
1416
</ItemGroup>
1517

1618
<ItemGroup>
1719
<ProjectReference Include="..\..\src\Foundatio.LuceneQueryParser\Foundatio.LuceneQueryParser.csproj" />
20+
<ProjectReference Include="..\..\src\Foundatio.LuceneQueryParser.EntityFramework\Foundatio.LuceneQueryParser.EntityFramework.csproj" />
1821
</ItemGroup>
1922

2023
</Project>

src/Foundatio.LuceneQueryParser.EntityFramework/EntityFieldInfo.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ public class EntityFieldInfo
6464
/// </summary>
6565
public bool IsNavigation { get; set; }
6666

67+
/// <summary>
68+
/// Whether the field has a full-text search index.
69+
/// When true, queries will use EF.Functions.Contains() for full-text search.
70+
/// </summary>
71+
public bool IsFullTextIndexed { get; set; }
72+
73+
/// <summary>
74+
/// The name of the CLR type that declares this field (e.g., "Employee" for Employee.Name).
75+
/// Used for full-text field configuration matching.
76+
/// </summary>
77+
public string? DeclaringTypeName { get; set; }
78+
6779
/// <summary>
6880
/// The parent field info for nested fields.
6981
/// </summary>

src/Foundatio.LuceneQueryParser.EntityFramework/EntityFrameworkExtensions.cs

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System.Linq.Expressions;
22
using Microsoft.EntityFrameworkCore;
33
using Microsoft.EntityFrameworkCore.Infrastructure;
4-
using Microsoft.EntityFrameworkCore.Internal;
54
using Microsoft.Extensions.DependencyInjection;
65

76
namespace Foundatio.LuceneQueryParser.EntityFramework;
@@ -26,7 +25,7 @@ public static IQueryable<T> Where<T>(this DbSet<T> source, string query) where T
2625

2726
var context = source.GetDbContext();
2827
var parser = context.GetQueryParser();
29-
28+
3029
if (parser == null)
3130
throw new InvalidOperationException(
3231
$"EntityFrameworkQueryParser is not registered in the DbContext. " +
@@ -129,7 +128,7 @@ public static IQueryable<T> Where<T>(this DbSet<T> source, string query, EntityF
129128
{
130129
return ((IInfrastructure<IServiceProvider>)context).Instance.GetService(typeof(T)) as T;
131130
}
132-
131+
133132
/// <summary>
134133
/// Gets the DbContext from a DbSet.
135134
/// </summary>
@@ -140,12 +139,12 @@ public static IQueryable<T> Where<T>(this DbSet<T> source, string query, EntityF
140139
private static DbContext GetDbContext<T>(this DbSet<T> dbSet) where T : class
141140
{
142141
var infrastructure = dbSet as IInfrastructure<IServiceProvider>;
143-
var serviceProvider = infrastructure?.Instance
142+
var serviceProvider = infrastructure?.Instance
144143
?? throw new InvalidOperationException("Unable to get service provider from DbSet.");
145-
144+
146145
var contextService = serviceProvider.GetService<ICurrentDbContext>()
147146
?? throw new InvalidOperationException("Unable to get ICurrentDbContext from service provider.");
148-
147+
149148
return contextService.Context;
150149
}
151150
#pragma warning restore EF1001 // Internal EF Core API usage
@@ -162,7 +161,7 @@ public static Expression<Func<T, bool>> ToExpression<T>(string query, Action<Ent
162161
var parser = new EntityFrameworkQueryParser(configure);
163162
return parser.BuildFilter<T>(query);
164163
}
165-
164+
166165
/// <summary>
167166
/// Adds the Lucene query parser to the DbContext options.
168167
/// </summary>
@@ -189,12 +188,12 @@ public static DbContextOptionsBuilder AddLuceneQueryParser(
189188
Action<EntityFrameworkQueryParserConfiguration>? configure = null)
190189
{
191190
var parser = new EntityFrameworkQueryParser(configure);
192-
191+
193192
var extension = optionsBuilder.Options.FindExtension<LuceneQueryParserOptionsExtension>()
194193
?? new LuceneQueryParserOptionsExtension(parser);
195-
194+
196195
((IDbContextOptionsBuilderInfrastructure)optionsBuilder).AddOrUpdateExtension(extension);
197-
196+
198197
return optionsBuilder;
199198
}
200199
}

0 commit comments

Comments
 (0)