Skip to content

Commit ced9e72

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/elastic-client
# Conflicts: # src/Foundatio.Repositories.Elasticsearch/Queries/Builders/FieldConditionsQueryBuilder.cs # tests/Foundatio.Repositories.Elasticsearch.Tests/Foundatio.Repositories.Elasticsearch.Tests.csproj
2 parents 3cef702 + 989503a commit ced9e72

File tree

12 files changed

+2456
-228
lines changed

12 files changed

+2456
-228
lines changed

.agents/skills/foundatio-repositories/SKILL.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,15 +122,28 @@ var employee = hit?.Document;
122122
| Method | Purpose |
123123
| --- | --- |
124124
| `.FieldEquals(e => e.Field, value)` | Exact match (multiple values = OR) |
125-
| `.FieldCondition(e => e.Field, op, value)` | Comparison (Equals, Contains, etc.) |
125+
| `.FieldNotEquals(e => e.Field, value)` | Negated exact match |
126+
| `.FieldCondition(e => e.Field, op, value)` | Comparison (any operator) |
126127
| `.FieldHasValue(e => e.Field)` | Field is not null/empty |
127128
| `.FieldEmpty(e => e.Field)` | Field is null/empty |
129+
| `.FieldContains(e => e.Field, "token")` | Full-text token match (analyzed fields only) |
130+
| `.FieldNotContains(e => e.Field, "token")` | Negated full-text token match |
131+
| `.FieldGreaterThan(e => e.Field, value)` | Strictly greater than |
132+
| `.FieldGreaterThanOrEqual(e => e.Field, value)` | Greater than or equal |
133+
| `.FieldLessThan(e => e.Field, value)` | Strictly less than |
134+
| `.FieldLessThanOrEqual(e => e.Field, value)` | Less than or equal |
135+
| `.FieldOr(g => g.FieldEquals(...).FieldEquals(...))` | OR grouping |
136+
| `.FieldAnd(g => g.FieldEquals(...).FieldEquals(...))` | AND grouping (explicit) |
137+
| `.FieldNot(g => g.FieldEquals(...))` | NOT grouping (AND-NOT semantics) |
128138
| `.DateRange(start, end, e => e.DateField)` | Date range filter |
129139
| `.SortAscending(e => e.Field)` | Sort ascending |
130140
| `.SortDescending(e => e.Field)` | Sort descending |
131141
| `.Include(e => e.Field)` | Return only specific fields |
132142
| `.Exclude(e => e.Field)` | Exclude fields from response |
133143

144+
Most single-field `Field*` predicate methods (such as `FieldEquals`, `FieldNotEquals`, `FieldContains`, `FieldNotContains`, `FieldGreaterThan`, `FieldGreaterThanOrEqual`, `FieldLessThan`, `FieldLessThanOrEqual`, `FieldHasValue`, and `FieldEmpty`) have `*If` variants for conditional application (e.g., `.FieldEqualsIf(e => e.Field, value, condition)`).
145+
Range operators support DateTime, DateTimeOffset, numeric (int/long/double/float/decimal), and string types.
146+
134147
### FilterExpression (Lucene-style)
135148

136149
Use for dynamic or user-provided queries. Parsed by Foundatio.Parsers.
@@ -350,4 +363,8 @@ bool exists = await repository.ExistsAsync(id);
350363
- **`ImmediateConsistency()` is for tests only**: It triggers an Elasticsearch index refresh after writes. Never use in production -- it degrades cluster performance.
351364
- **Register repositories as singletons**: Repository instances maintain internal state (index configuration, cache references). Always register via DI as singletons.
352365
- **`FieldEquals` with multiple values is OR**: `.FieldEquals(e => e.EmploymentType, "FullTime", "Contract")` produces an OR filter, not AND.
366+
- **`FieldContains` is token matching, NOT wildcard**: `FieldContains(f => f.Name, "Er")` will NOT match "Eric". Use `FilterExpression("field:pattern*")` for prefix/wildcard matching.
367+
- **`FieldNot` is AND-NOT**: Multiple conditions inside `FieldNot` mean NOT A AND NOT B. For NOT (A AND B), nest `FieldAnd` inside `FieldNot`.
368+
- **Range operators + time-series indexes**: `FieldLessThanOrEqual(f => f.CreatedUtc, now)` does NOT narrow which daily/monthly indexes are queried. Always pair with `.Index(start, end)`.
369+
- **`FieldEquals` on analyzed text fields throws**: If the field has no `.keyword` sub-field, `FieldEquals` throws `QueryValidationException`. Use `FieldContains` for full-text search.
353370
- **`PatchAsync(Ids, ...)` requires `Ids` type**: Use `new Ids(id1, id2)` -- `string[]` does not implicitly convert to `Ids`.

build/common.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
</PropertyGroup>
4040

4141
<ItemGroup>
42-
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.200" PrivateAssets="All"/>
42+
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.201" PrivateAssets="All"/>
4343
<PackageReference Include="AsyncFixer" Version="2.1.0" PrivateAssets="All" />
4444
<PackageReference Include="MinVer" Version="7.0.0" PrivateAssets="All" />
4545
</ItemGroup>

docs/guide/querying.md

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ var results = await repository.FindAsync(q => q.FieldEquals(e => e.Type, Employe
7676

7777
### Field Conditions
7878

79-
`FieldCondition` supports equality, text matching, and existence checks:
79+
`FieldCondition` supports equality, text matching, existence checks, and range comparisons:
8080

8181
```csharp
8282
// Equality check
@@ -88,9 +88,110 @@ var results = await repository.FindAsync(q => q
8888
.FieldCondition(e => e.Name, ComparisonOperator.Contains, "John"));
8989
```
9090

91-
Available operators: `Equals`, `NotEquals`, `IsEmpty`, `HasValue`, `Contains`, `NotContains`.
91+
Available operators: `Equals`, `NotEquals`, `IsEmpty`, `HasValue`, `Contains`, `NotContains`, `GreaterThan`, `GreaterThanOrEqual`, `LessThan`, `LessThanOrEqual`.
92+
93+
### Range Operators
94+
95+
For one-sided comparisons and non-date types, use the range operator shorthands:
96+
97+
```csharp
98+
// Date comparison (generates DateRangeQuery)
99+
var results = await repository.FindAsync(q => q
100+
.FieldLessThanOrEqual(e => e.SnoozeUntilUtc, DateTime.UtcNow));
101+
102+
// Numeric comparison (generates NumericRangeQuery)
103+
var results = await repository.FindAsync(q => q
104+
.FieldGreaterThanOrEqual(e => e.Age, 18));
105+
106+
// Bounded range (two conditions ANDed)
107+
var results = await repository.FindAsync(q => q
108+
.FieldGreaterThanOrEqual(e => e.Age, 18)
109+
.FieldLessThan(e => e.Age, 65));
110+
111+
// String/keyword comparison (generates TermRangeQuery on keyword field)
112+
var results = await repository.FindAsync(q => q
113+
.FieldGreaterThanOrEqual(e => e.InstanceKey, "20240101"));
114+
115+
// Conditional range (only applied when condition is true)
116+
var results = await repository.FindAsync(q => q
117+
.FieldGreaterThanIf(e => e.Age, minAge, minAge is not null));
118+
```
119+
120+
> **String ranges require keyword fields:** String range operators generate a `TermRangeQuery` and automatically resolve to the `.keyword` sub-field (like `FieldEquals`). If the field is an analyzed text field with no `.keyword` sub-field, a `QueryValidationException` is thrown at build time.
121+
122+
**DateRange vs range operators:** Use `.DateRange(start, end, field)` for bounded date windows (validates start < end, supports timezone). Use `FieldGreaterThan`/`FieldLessThanOrEqual` etc. for one-sided comparisons and non-date types.
123+
124+
> **Numeric precision note:** `long` values use NEST's `LongRangeQuery` which preserves full precision. `decimal` values are converted to `double` for NEST's `NumericRangeQuery`, which may lose precision for values exceeding ~15-17 significant digits. If exact precision matters, prefer `long` fields with an explicit scaling factor.
125+
126+
### Contains (Full-Text Token Matching)
127+
128+
`FieldContains` generates a `MatchQuery` on analyzed fields. It matches complete analyzed tokens, NOT substrings or wildcards:
129+
130+
```csharp
131+
// Matches documents where Name contains the token "eric"
132+
var results = await repository.FindAsync(q => q
133+
.FieldContains(e => e.Name, "Eric"));
134+
135+
// Multiple tokens: all must be present (AND), order-independent
136+
// Matches "Eric J. Smith" AND "Smith, Eric" but NOT "Eric"
137+
var results = await repository.FindAsync(q => q
138+
.FieldContains(e => e.Name, "Eric Smith"));
139+
140+
// DOES NOT WORK: "Er" is not a complete token
141+
var results = await repository.FindAsync(q => q
142+
.FieldContains(e => e.Name, "Er")); // returns nothing
143+
```
144+
145+
### OR / AND / NOT Grouping
146+
147+
For complex boolean logic, use `FieldOr`, `FieldAnd`, and `FieldNot`:
148+
149+
```csharp
150+
// Simple OR: match either condition
151+
var results = await repository.FindAsync(q => q.FieldOr(g => g
152+
.FieldEquals(f => f.IsPrivate, false)
153+
.FieldEquals(f => f.CompanyId, companyIds)
154+
));
155+
156+
// Nested AND inside OR
157+
var results = await repository.FindAsync(q => q.FieldOr(g => g
158+
.FieldEquals(f => f.Status, "RunNow")
159+
.FieldAnd(g2 => g2
160+
.FieldEquals(f => f.IsEnabled, true)
161+
.FieldLessThanOrEqual(f => f.NextRunDateUtc, DateTime.UtcNow)
162+
)
163+
));
164+
165+
// NOT: exclude documents matching any condition (AND-NOT semantics)
166+
var results = await repository.FindAsync(q => q.FieldNot(g => g
167+
.FieldEquals(f => f.BillingStatus, BillingStatus.Active)
168+
.FieldEquals(f => f.BillingStatus, BillingStatus.Trialing)
169+
));
170+
171+
// Dynamic/conditional OR groups (builder API)
172+
var group = FieldConditionGroup<Program>.Or();
173+
group.FieldEquals(f => f.IsPrivate, false);
174+
if (privateIds.Count > 0)
175+
group.FieldEquals(f => f.PrivateId, privateIds);
176+
if (includeIds.Count > 0)
177+
group.FieldEquals(f => f.Id, includeIds);
178+
var results = await repository.FindAsync(q => q.FieldOr(group));
179+
```
180+
181+
> **FieldNot semantics:** Multiple conditions inside `FieldNot` produce NOT A AND NOT B (exclude documents matching **any** clause). For NOT (A AND B), nest an explicit AND: `FieldNot(g => g.FieldAnd(g2 => g2.FieldEquals(A).FieldEquals(B)))`.
182+
183+
### Field-Type Validation
184+
185+
The query builder performs runtime validation and throws `QueryValidationException` for detectable misuse:
186+
187+
- **FieldEquals/FieldNotEquals on text-only fields** — throws if the field is an analyzed text field with no `.keyword` sub-field (TermQuery on analyzed fields almost never matches).
188+
- **FieldContains/FieldNotContains on keyword fields** — throws because MatchQuery requires an analyzed field.
189+
- **FieldEquals on IsDeleted with ActiveOnly soft-delete mode** — throws because it creates a contradictory filter.
190+
- **Range with null value** — throws with guidance to use `*If` variants or `FieldHasValue`/`FieldEmpty`.
191+
- **Range with collection value** — throws with guidance to use `FieldEquals` for multi-value matching.
192+
193+
> **Note:** For wildcard/prefix matching on keyword fields, use `FilterExpression("field:pattern*")`. For phrase matching (adjacent words in order), use `FilterExpression("field:\"quick brown\"")`.
92194
93-
> **Note:** For numeric range comparisons (e.g., age >= 25), use `FilterExpression` with Lucene syntax: `q.FilterExpression("age:[25 TO *]")`
94195

95196
### Field Empty/Has Value
96197

samples/Foundatio.SampleApp/Client/Foundatio.SampleApp.Client.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
</PropertyGroup>
88

99
<ItemGroup>
10-
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.4" />
11-
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.4" PrivateAssets="all" />
10+
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.5" />
11+
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.5" PrivateAssets="all" />
1212
</ItemGroup>
1313

1414
<ItemGroup>

samples/Foundatio.SampleApp/Server/Foundatio.SampleApp.Server.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
<ItemGroup>
1010
<PackageReference Include="Foundatio.Extensions.Hosting" Version="13.0.0-beta3.13" />
11-
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.4" />
11+
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.5" />
1212
</ItemGroup>
1313

1414
<ItemGroup>

0 commit comments

Comments
 (0)