Skip to content

Commit 47a3470

Browse files
authored
feat: expand SearchAttribute includes/excludes filtering (#731)
1 parent e634583 commit 47a3470

6 files changed

Lines changed: 215 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
- `c-datetime-picker`: Assorted UI and UX improvements and fixes.
1818
- `c-display` now auto-refreshes date distance formatting (`format: { distance: true }`) using an adaptive refresh interval based on the displayed distance.
1919
- Added `headerComment` generator configuration option to emit custom comments at the start of all generated files. Supports cascading hierarchical configuration—define on a parent generator and child generators automatically inherit the value.
20+
- Added `SearchAttribute.Includes` and `SearchAttribute.Excludes` to scope search participation by the request `includes` value in `ApplyListSearchTerm`.
2021
- `[DefaultOrderBy(Suppress = true)]` can now be placed on a collection navigation property to suppress the default sorting of that collection in the generated response DTO.
2122

2223
## Template Changes

docs/concepts/includes.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,22 @@ personList.$includes = "details";
2323

2424
The default value (i.e. no action) is the empty string.
2525

26+
## SearchAttribute Includes/Excludes
27+
28+
`includes` is also used by [`[Search]`](/modeling/model-components/attributes/search.md) when you set `SearchAttribute.Includes` or `SearchAttribute.Excludes`.
29+
30+
- `Search(Includes = "...")`: property is searchable only when the request `includes` string matches.
31+
- `Search(Excludes = "...")`: property is not searchable when the request `includes` string matches.
32+
33+
Example:
34+
35+
```cs
36+
[Search(Includes = "details")]
37+
public string Biography { get; set; }
38+
```
39+
40+
With the above, `Biography` participates in list searching only when the request has `?includes=details`.
41+
2642
### Special Values
2743

2844
There are a few values of `includes` that are either set by default in the auto-generated views, or otherwise have special meaning:

docs/modeling/model-components/attributes/search.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ By default, the system will search any field with the name 'Name'. If this doesn
1111

1212
Searching will not search on properties that are hidden from the user by [Security Attributes](./security-attribute.md).
1313

14+
`[Search(Includes = ...)]` and `[Search(Excludes = ...)]` use the request's `includes` value. For background on `includes`, see [Includes String](/concepts/includes.md).
15+
1416
## Searchable Property Types
1517

1618
#### Strings
@@ -66,9 +68,19 @@ public class Person
6668

6769
[Search(RootWhitelist = nameof(Person))]
6870
public ICollection<Address> Addresses { get; set; }
71+
72+
[Search(Includes = "details")]
73+
public string Biography { get; set; }
74+
75+
[Search(Excludes = "preview")]
76+
public string Notes { get; set; }
6977
}
7078
```
7179

80+
In the above example:
81+
- `Biography` will only be searched when the request includes `?includes=details`
82+
- `Notes` will not be searched when the request includes `?includes=preview`
83+
7284
## Properties
7385

7486
<Prop def="public bool IsSplitOnSpaces { get; set; } = true;" />
@@ -93,4 +105,22 @@ A comma-delimited list of model class names that, if set, will prevent the targe
93105

94106
<Prop def="public string RootBlacklist { get; set; } = null;" />
95107

96-
A comma-delimited list of model class names that, if set, will prevent the targeted property from being searched if the root object of the API call was one of the specified class names.
108+
A comma-delimited list of model class names that, if set, will prevent the targeted property from being searched if the root object of the API call was one of the specified class names.
109+
110+
<Prop def="public string Includes { get; set; } = null;" />
111+
112+
A comma-delimited list of content views that, if set, will restrict the targeted property from being searched unless the request includes one of the specified content views.
113+
114+
When this property is set, the property will only be searched if the API request includes a matching value in the `includes` parameter. If this is empty or null, the property is searched regardless of content view.
115+
116+
For example, if a property has `[Search(Includes = "details")]`, it will only be searched when the request includes `?includes=details`. Multiple values can be specified as a comma-delimited list: `Includes = "details, admin"`.
117+
118+
<Prop def="public string Excludes { get; set; } = null;" />
119+
120+
A comma-delimited list of content views that, if set, will restrict the targeted property from being searched if the request includes one of the specified content views.
121+
122+
When this property is set, the property will not be searched if the API request includes a matching value in the `includes` parameter. If this is empty or null, the property is searched regardless of content view.
123+
124+
For example, if a property has `[Search(Excludes = "preview")]`, it will not be searched when the request includes `?includes=preview`. Multiple values can be specified as a comma-delimited list: `Excludes = "preview, summary"`.
125+
126+
If both `Includes` and `Excludes` are specified, `Includes` takes precedence.

src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -511,11 +511,74 @@ public class CollectedNoSearchable
511511
public string NonSearchableField { get; set; }
512512
}
513513

514+
class ContentView_Includes { [Search(Includes = "details")] public string Name { get; set; } }
515+
516+
[Test]
517+
[Arguments(true, "details")] // incoming content view matches Includes -> property is searched
518+
[Arguments(false, "summary")] // incoming content view doesn't match Includes -> property is not searched
519+
[Arguments(false, null)] // no incoming content view -> property opted in to specific views, so not searched
520+
[Arguments(false, "")] // empty incoming content view -> not searched
521+
public async Task Search_ContentView_Includes_RestrictsSearchedProperties(
522+
bool shouldMatch, string includes)
523+
{
524+
await SearchHelper<ContentView_Includes>(
525+
"John",
526+
model => model.Name = "John",
527+
shouldMatch,
528+
includes: includes);
529+
}
530+
531+
class ContentView_Excludes { [Search(Excludes = "preview")] public string Name { get; set; } }
532+
533+
[Test]
534+
[Arguments(false, "preview")] // incoming content view matches Excludes -> property is not searched
535+
[Arguments(true, "details")] // incoming content view doesn't match Excludes -> property is searched
536+
[Arguments(true, null)] // no incoming content view -> nothing to exclude, so searched
537+
public async Task Search_ContentView_Excludes_RestrictsSearchedProperties(
538+
bool shouldMatch, string includes)
539+
{
540+
await SearchHelper<ContentView_Excludes>(
541+
"John",
542+
model => model.Name = "John",
543+
shouldMatch,
544+
includes: includes);
545+
}
546+
547+
class ContentView_IncludesAndExcludes { [Search(Includes = "details", Excludes = "details")] public string Name { get; set; } }
548+
549+
[Test]
550+
[Arguments(true, "details")] // Includes match wins over Excludes when both are specified -> searched
551+
[Arguments(false, "summary")] // doesn't match Includes -> not searched
552+
public async Task Search_ContentView_IncludesTakesPrecedenceOverExcludes(
553+
bool shouldMatch, string includes)
554+
{
555+
await SearchHelper<ContentView_IncludesAndExcludes>(
556+
"John",
557+
model => model.Name = "John",
558+
shouldMatch,
559+
includes: includes);
560+
}
561+
562+
class ContentView_MultipleIncludes { [Search(Includes = "details, full")] public string Name { get; set; } }
563+
564+
[Test]
565+
[Arguments(true, "details")] // matches first entry in the comma-separated list
566+
[Arguments(true, "FULL")] // matches second entry, case-insensitively
567+
[Arguments(false, "summary")] // matches no entry -> not searched
568+
public async Task Search_ContentView_Includes_SupportsCommaSeparatedList(
569+
bool shouldMatch, string includes)
570+
{
571+
await SearchHelper<ContentView_MultipleIncludes>(
572+
"John",
573+
model => model.Name = "John",
574+
shouldMatch,
575+
includes: includes);
576+
}
577+
514578
public class SearchTestDataSource<T>(CrudContext context) : QueryableDataSourceBase<T>(context)
515579
where T : class
516580
{
517581
}
518-
519582
private async Task SearchHelper<T, TProp>(
520583
Expression<Func<T, TProp>> propSelector,
521584
string searchTerm,
@@ -548,7 +611,8 @@ private async Task SearchHelper<T>(
548611
string searchTerm,
549612
Action<T> configureModel,
550613
bool expectedMatch,
551-
TimeZoneInfo timeZoneInfo = null
614+
TimeZoneInfo timeZoneInfo = null,
615+
string includes = null
552616
)
553617
where T : class, new()
554618
{
@@ -559,7 +623,7 @@ private async Task SearchHelper<T>(
559623

560624
var ds = new SearchTestDataSource<T>(context);
561625
var query = new List<T> { model }.AsQueryable();
562-
query = ds.ApplyListSearchTerm(query, new FilterParameters { Search = searchTerm });
626+
query = ds.ApplyListSearchTerm(query, new FilterParameters { Search = searchTerm, Includes = includes });
563627

564628
var matchedItems = query.ToArray();
565629

src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,54 @@ public QueryableDataSourceBase(CrudContext context)
5252
?? throw new ArgumentException("Generic type T has no ClassViewModel.", nameof(T));
5353
}
5454

55+
/// <summary>
56+
/// Determines if a property should be searched based on its SearchAttribute.Includes and SearchAttribute.Excludes
57+
/// relative to the current request's content view (the request's <see cref="IDataSourceParameters.Includes"/> value).
58+
/// </summary>
59+
/// <param name="property">The property to check.</param>
60+
/// <param name="contentView">The content view from the current request, if any.</param>
61+
/// <returns>True if the property should be searched, false otherwise.</returns>
62+
protected virtual bool ShouldSearchProperty(PropertyViewModel property, string? contentView)
63+
{
64+
var includes = property.GetAttributeValue<SearchAttribute>(a => a.Includes);
65+
var excludes = property.GetAttributeValue<SearchAttribute>(a => a.Excludes);
66+
67+
var hasIncludes = !string.IsNullOrWhiteSpace(includes);
68+
var hasExcludes = !string.IsNullOrWhiteSpace(excludes);
69+
70+
// Fast path: the vast majority of searchable properties have no content view
71+
// constraints. Return before doing any string splitting/allocation. This matters
72+
// because ApplyListSearchTerm can call this once per search statement per request.
73+
if (!hasIncludes && !hasExcludes)
74+
{
75+
return true;
76+
}
77+
78+
var trimmedContentView = contentView?.Trim();
79+
80+
// Scenario 1: Search.Includes is specified -> require an explicit include match.
81+
// Includes wins over excludes when both are set.
82+
// A property opted in to specific content views can never match a request that
83+
// didn't specify a content view, so short-circuit that case.
84+
if (hasIncludes)
85+
{
86+
return !string.IsNullOrEmpty(trimmedContentView)
87+
&& includes!
88+
.Split(',')
89+
.Select(s => s.Trim())
90+
.Contains(trimmedContentView, StringComparer.OrdinalIgnoreCase);
91+
}
92+
93+
// Scenario 2: Search.Excludes is specified (and Search.Includes is not) -> search
94+
// unless the incoming content view matches one of the excluded views. With no
95+
// incoming content view there is nothing to exclude, so the property is searched.
96+
return string.IsNullOrEmpty(trimmedContentView)
97+
|| !excludes!
98+
.Split(',')
99+
.Select(s => s.Trim())
100+
.Contains(trimmedContentView, StringComparer.OrdinalIgnoreCase);
101+
}
102+
55103
/// <summary>
56104
/// Applies the "filter.propertyName=exactValue" filters to a query.
57105
/// These filters may be set when making a list request, and are found in ListParameters.Filters.
@@ -331,6 +379,7 @@ protected virtual IQueryable<T> ApplyListPropertyFilter(
331379
public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterParameters parameters)
332380
{
333381
var searchTerm = parameters.Search;
382+
var contentView = parameters.Includes;
334383

335384
// Add general search filters.
336385
// These search specified fields in the class
@@ -359,6 +408,7 @@ public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterPar
359408
var expressions = prop
360409
.SearchProperties(ClassViewModel, maxDepth: 1, force: true)
361410
.SelectMany(p => p.GetLinqSearchStatements(Context, param, value))
411+
.Where(f => ShouldSearchProperty(f.property, contentView))
362412
.Select(t => t.statement)
363413
.ToList();
364414

@@ -391,7 +441,7 @@ public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterPar
391441
var splitOnStringClauses = ClassViewModel
392442
.SearchProperties(ClassViewModel)
393443
.SelectMany(p => p.GetLinqSearchStatements(Context, param, termWord))
394-
.Where(f => f.property.SearchIsSplitOnSpaces)
444+
.Where(f => f.property.SearchIsSplitOnSpaces && ShouldSearchProperty(f.property, contentView))
395445
.Select(t => t.statement)
396446
.ToList();
397447

@@ -416,7 +466,7 @@ public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterPar
416466
var searchClauses = ClassViewModel
417467
.SearchProperties(ClassViewModel)
418468
.SelectMany(p => p.GetLinqSearchStatements(Context, param, searchTerm))
419-
.Where(f => !f.property.SearchIsSplitOnSpaces)
469+
.Where(f => !f.property.SearchIsSplitOnSpaces && ShouldSearchProperty(f.property, contentView))
420470
.Select(t => t.statement)
421471
.ToList();
422472
completeSearchClauses.AddRange(searchClauses);

src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ namespace IntelliTect.Coalesce.DataAnnotations;
22

33

44
/// <summary>
5-
/// All fields marked with this field will be searched when using the list search feature.
5+
/// Marks a property as searchable in list search operations. When applied to a property,
6+
/// that property will be included in searches performed by the API list endpoint.
7+
///
8+
/// Use the <see cref="SearchMethods"/> enumeration to control how the search term is matched.
9+
/// Use <see cref="RootWhitelist"/> and <see cref="RootBlacklist"/> to restrict searching based on the root model type.
10+
/// Use <see cref="Includes"/> and <see cref="Excludes"/> to restrict searching based on the request's content view.
611
/// </summary>
712
[System.AttributeUsage(System.AttributeTargets.Property)]
813
public class SearchAttribute : System.Attribute
@@ -71,4 +76,46 @@ public enum SearchMethods
7176
/// the root object of the API call was one of the specified class names.
7277
/// </summary>
7378
public string? RootBlacklist { get; set; }
79+
80+
/// <summary>
81+
/// A comma-delimited list of content views that, if set,
82+
/// will restrict the targeted property from being searched unless
83+
/// the request includes one of the specified content views.
84+
///
85+
/// <para>
86+
/// When this property is set, the property will only be searched if the API request
87+
/// includes a matching value in the <c>includes</c> parameter.
88+
/// If this is empty or null, the property is searched regardless of the content view.
89+
/// </para>
90+
///
91+
/// <para>
92+
/// For example, if a property has <c>[Search(Includes = "details")]</c>,
93+
/// it will only be searched when the request includes <c>?includes=details</c>.
94+
/// Multiple content views can be specified as a comma-delimited list: <c>Includes = "details, admin"</c>.
95+
/// </para>
96+
/// </summary>
97+
public string? Includes { get; set; }
98+
99+
/// <summary>
100+
/// A comma-delimited list of content views that, if set,
101+
/// will restrict the targeted property from being searched if
102+
/// the request includes one of the specified content views.
103+
///
104+
/// <para>
105+
/// When this property is set, the property will not be searched if the API request
106+
/// includes a matching value in the <c>includes</c> parameter.
107+
/// If this is empty or null, the property is searched regardless of the content view.
108+
/// </para>
109+
///
110+
/// <para>
111+
/// For example, if a property has <c>[Search(Excludes = "preview")]</c>,
112+
/// it will not be searched when the request includes <c>?includes=preview</c>.
113+
/// Multiple content views can be specified as a comma-delimited list: <c>Excludes = "preview, summary"</c>.
114+
/// </para>
115+
///
116+
/// <para>
117+
/// Note: If both <see cref="Includes"/> and <see cref="Excludes"/> are specified, <see cref="Includes"/> takes precedence.
118+
/// </para>
119+
/// </summary>
120+
public string? Excludes { get; set; }
74121
}

0 commit comments

Comments
 (0)