Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- `c-datetime-picker`: Assorted UI and UX improvements and fixes.
- `c-display` now auto-refreshes date distance formatting (`format: { distance: true }`) using an adaptive refresh interval based on the displayed distance.
- 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.
- Added `SearchAttribute.Includes` and `SearchAttribute.Excludes` to scope search participation by the request `includes` value in `ApplyListSearchTerm`.
- `[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.

## Template Changes
Expand Down
16 changes: 16 additions & 0 deletions docs/concepts/includes.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,22 @@ personList.$includes = "details";

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

## SearchAttribute Includes/Excludes

`includes` is also used by [`[Search]`](/modeling/model-components/attributes/search.md) when you set `SearchAttribute.Includes` or `SearchAttribute.Excludes`.

- `Search(Includes = "...")`: property is searchable only when the request `includes` string matches.
- `Search(Excludes = "...")`: property is not searchable when the request `includes` string matches.

Example:

```cs
[Search(Includes = "details")]
public string Biography { get; set; }
```

With the above, `Biography` participates in list searching only when the request has `?includes=details`.

### Special Values

There are a few values of `includes` that are either set by default in the auto-generated views, or otherwise have special meaning:
Expand Down
32 changes: 31 additions & 1 deletion docs/modeling/model-components/attributes/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ By default, the system will search any field with the name 'Name'. If this doesn

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

`[Search(Includes = ...)]` and `[Search(Excludes = ...)]` use the request's `includes` value. For background on `includes`, see [Includes String](/concepts/includes.md).

## Searchable Property Types

#### Strings
Expand Down Expand Up @@ -66,9 +68,19 @@ public class Person

[Search(RootWhitelist = nameof(Person))]
public ICollection<Address> Addresses { get; set; }

[Search(Includes = "details")]
public string Biography { get; set; }

[Search(Excludes = "preview")]
public string Notes { get; set; }
}
```

In the above example:
- `Biography` will only be searched when the request includes `?includes=details`
- `Notes` will not be searched when the request includes `?includes=preview`

## Properties

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

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

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.
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.

<Prop def="public string Includes { get; set; } = null;" />

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.

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.

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"`.

<Prop def="public string Excludes { get; set; } = null;" />

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.

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.

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"`.

If both `Includes` and `Excludes` are specified, `Includes` takes precedence.
70 changes: 67 additions & 3 deletions src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -511,11 +511,74 @@ public class CollectedNoSearchable
public string NonSearchableField { get; set; }
}

class ContentView_Includes { [Search(Includes = "details")] public string Name { get; set; } }

[Test]
[Arguments(true, "details")] // incoming content view matches Includes -> property is searched
[Arguments(false, "summary")] // incoming content view doesn't match Includes -> property is not searched
[Arguments(false, null)] // no incoming content view -> property opted in to specific views, so not searched
[Arguments(false, "")] // empty incoming content view -> not searched
public async Task Search_ContentView_Includes_RestrictsSearchedProperties(
bool shouldMatch, string includes)
{
await SearchHelper<ContentView_Includes>(
"John",
model => model.Name = "John",
shouldMatch,
includes: includes);
}

class ContentView_Excludes { [Search(Excludes = "preview")] public string Name { get; set; } }

[Test]
[Arguments(false, "preview")] // incoming content view matches Excludes -> property is not searched
[Arguments(true, "details")] // incoming content view doesn't match Excludes -> property is searched
[Arguments(true, null)] // no incoming content view -> nothing to exclude, so searched
public async Task Search_ContentView_Excludes_RestrictsSearchedProperties(
bool shouldMatch, string includes)
{
await SearchHelper<ContentView_Excludes>(
"John",
model => model.Name = "John",
shouldMatch,
includes: includes);
}

class ContentView_IncludesAndExcludes { [Search(Includes = "details", Excludes = "details")] public string Name { get; set; } }

[Test]
[Arguments(true, "details")] // Includes match wins over Excludes when both are specified -> searched
[Arguments(false, "summary")] // doesn't match Includes -> not searched
public async Task Search_ContentView_IncludesTakesPrecedenceOverExcludes(
bool shouldMatch, string includes)
{
await SearchHelper<ContentView_IncludesAndExcludes>(
"John",
model => model.Name = "John",
shouldMatch,
includes: includes);
}

class ContentView_MultipleIncludes { [Search(Includes = "details, full")] public string Name { get; set; } }

[Test]
[Arguments(true, "details")] // matches first entry in the comma-separated list
[Arguments(true, "FULL")] // matches second entry, case-insensitively
[Arguments(false, "summary")] // matches no entry -> not searched
public async Task Search_ContentView_Includes_SupportsCommaSeparatedList(
bool shouldMatch, string includes)
{
await SearchHelper<ContentView_MultipleIncludes>(
"John",
model => model.Name = "John",
shouldMatch,
includes: includes);
}

public class SearchTestDataSource<T>(CrudContext context) : QueryableDataSourceBase<T>(context)
where T : class
{
}

private async Task SearchHelper<T, TProp>(
Expression<Func<T, TProp>> propSelector,
string searchTerm,
Expand Down Expand Up @@ -548,7 +611,8 @@ private async Task SearchHelper<T>(
string searchTerm,
Action<T> configureModel,
bool expectedMatch,
TimeZoneInfo timeZoneInfo = null
TimeZoneInfo timeZoneInfo = null,
string includes = null
)
where T : class, new()
{
Expand All @@ -559,7 +623,7 @@ private async Task SearchHelper<T>(

var ds = new SearchTestDataSource<T>(context);
var query = new List<T> { model }.AsQueryable();
query = ds.ApplyListSearchTerm(query, new FilterParameters { Search = searchTerm });
query = ds.ApplyListSearchTerm(query, new FilterParameters { Search = searchTerm, Includes = includes });

var matchedItems = query.ToArray();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,54 @@ public QueryableDataSourceBase(CrudContext context)
?? throw new ArgumentException("Generic type T has no ClassViewModel.", nameof(T));
}

/// <summary>
/// Determines if a property should be searched based on its SearchAttribute.Includes and SearchAttribute.Excludes
/// relative to the current request's content view (the request's <see cref="IDataSourceParameters.Includes"/> value).
/// </summary>
/// <param name="property">The property to check.</param>
/// <param name="contentView">The content view from the current request, if any.</param>
/// <returns>True if the property should be searched, false otherwise.</returns>
protected virtual bool ShouldSearchProperty(PropertyViewModel property, string? contentView)
{
var includes = property.GetAttributeValue<SearchAttribute>(a => a.Includes);
var excludes = property.GetAttributeValue<SearchAttribute>(a => a.Excludes);

var hasIncludes = !string.IsNullOrWhiteSpace(includes);
var hasExcludes = !string.IsNullOrWhiteSpace(excludes);

// Fast path: the vast majority of searchable properties have no content view
// constraints. Return before doing any string splitting/allocation. This matters
// because ApplyListSearchTerm can call this once per search statement per request.
if (!hasIncludes && !hasExcludes)
{
return true;
}

var trimmedContentView = contentView?.Trim();

// Scenario 1: Search.Includes is specified -> require an explicit include match.
// Includes wins over excludes when both are set.
// A property opted in to specific content views can never match a request that
// didn't specify a content view, so short-circuit that case.
if (hasIncludes)
{
return !string.IsNullOrEmpty(trimmedContentView)
&& includes!
.Split(',')
.Select(s => s.Trim())
.Contains(trimmedContentView, StringComparer.OrdinalIgnoreCase);
}

// Scenario 2: Search.Excludes is specified (and Search.Includes is not) -> search
// unless the incoming content view matches one of the excluded views. With no
// incoming content view there is nothing to exclude, so the property is searched.
return string.IsNullOrEmpty(trimmedContentView)
|| !excludes!
.Split(',')
.Select(s => s.Trim())
.Contains(trimmedContentView, StringComparer.OrdinalIgnoreCase);
}

/// <summary>
/// Applies the "filter.propertyName=exactValue" filters to a query.
/// These filters may be set when making a list request, and are found in ListParameters.Filters.
Expand Down Expand Up @@ -331,6 +379,7 @@ protected virtual IQueryable<T> ApplyListPropertyFilter(
public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterParameters parameters)
{
var searchTerm = parameters.Search;
var contentView = parameters.Includes;

// Add general search filters.
// These search specified fields in the class
Expand Down Expand Up @@ -359,6 +408,7 @@ public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterPar
var expressions = prop
.SearchProperties(ClassViewModel, maxDepth: 1, force: true)
.SelectMany(p => p.GetLinqSearchStatements(Context, param, value))
.Where(f => ShouldSearchProperty(f.property, contentView))
.Select(t => t.statement)
Comment thread
ascott18 marked this conversation as resolved.
.ToList();

Expand Down Expand Up @@ -391,7 +441,7 @@ public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterPar
var splitOnStringClauses = ClassViewModel
.SearchProperties(ClassViewModel)
.SelectMany(p => p.GetLinqSearchStatements(Context, param, termWord))
.Where(f => f.property.SearchIsSplitOnSpaces)
.Where(f => f.property.SearchIsSplitOnSpaces && ShouldSearchProperty(f.property, contentView))
.Select(t => t.statement)
.ToList();

Expand All @@ -416,7 +466,7 @@ public virtual IQueryable<T> ApplyListSearchTerm(IQueryable<T> query, IFilterPar
var searchClauses = ClassViewModel
.SearchProperties(ClassViewModel)
.SelectMany(p => p.GetLinqSearchStatements(Context, param, searchTerm))
.Where(f => !f.property.SearchIsSplitOnSpaces)
.Where(f => !f.property.SearchIsSplitOnSpaces && ShouldSearchProperty(f.property, contentView))
.Select(t => t.statement)
.ToList();
completeSearchClauses.AddRange(searchClauses);
Expand Down
49 changes: 48 additions & 1 deletion src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ namespace IntelliTect.Coalesce.DataAnnotations;


/// <summary>
/// All fields marked with this field will be searched when using the list search feature.
/// Marks a property as searchable in list search operations. When applied to a property,
/// that property will be included in searches performed by the API list endpoint.
///
/// Use the <see cref="SearchMethods"/> enumeration to control how the search term is matched.
/// Use <see cref="RootWhitelist"/> and <see cref="RootBlacklist"/> to restrict searching based on the root model type.
/// Use <see cref="Includes"/> and <see cref="Excludes"/> to restrict searching based on the request's content view.
/// </summary>
[System.AttributeUsage(System.AttributeTargets.Property)]
public class SearchAttribute : System.Attribute
Expand Down Expand Up @@ -71,4 +76,46 @@ public enum SearchMethods
/// the root object of the API call was one of the specified class names.
/// </summary>
public string? RootBlacklist { get; set; }

/// <summary>
/// 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.
///
/// <para>
/// When this property is set, the property will only be searched if the API request
/// includes a matching value in the <c>includes</c> parameter.
/// If this is empty or null, the property is searched regardless of the content view.
/// </para>
///
/// <para>
/// For example, if a property has <c>[Search(Includes = "details")]</c>,
/// it will only be searched when the request includes <c>?includes=details</c>.
/// Multiple content views can be specified as a comma-delimited list: <c>Includes = "details, admin"</c>.
/// </para>
/// </summary>
public string? Includes { get; set; }

/// <summary>
/// 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.
///
/// <para>
/// When this property is set, the property will not be searched if the API request
/// includes a matching value in the <c>includes</c> parameter.
/// If this is empty or null, the property is searched regardless of the content view.
/// </para>
///
/// <para>
/// For example, if a property has <c>[Search(Excludes = "preview")]</c>,
/// it will not be searched when the request includes <c>?includes=preview</c>.
/// Multiple content views can be specified as a comma-delimited list: <c>Excludes = "preview, summary"</c>.
/// </para>
///
/// <para>
/// Note: If both <see cref="Includes"/> and <see cref="Excludes"/> are specified, <see cref="Includes"/> takes precedence.
/// </para>
/// </summary>
public string? Excludes { get; set; }
}
Loading