diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c480452..0d4c9e743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/concepts/includes.md b/docs/concepts/includes.md index eb9c4a814..52093464a 100644 --- a/docs/concepts/includes.md +++ b/docs/concepts/includes.md @@ -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: diff --git a/docs/modeling/model-components/attributes/search.md b/docs/modeling/model-components/attributes/search.md index e73849d0a..fa2931924 100644 --- a/docs/modeling/model-components/attributes/search.md +++ b/docs/modeling/model-components/attributes/search.md @@ -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 @@ -66,9 +68,19 @@ public class Person [Search(RootWhitelist = nameof(Person))] public ICollection
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 @@ -93,4 +105,22 @@ A comma-delimited list of model class names that, if set, will prevent the targe -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. \ No newline at end of file +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 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"`. + + + +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. \ No newline at end of file diff --git a/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs b/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs index e8643f501..d8f2dc813 100644 --- a/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs +++ b/src/IntelliTect.Coalesce.Tests/Tests/Api/SearchTests.cs @@ -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( + "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( + "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( + "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( + "John", + model => model.Name = "John", + shouldMatch, + includes: includes); + } + public class SearchTestDataSource(CrudContext context) : QueryableDataSourceBase(context) where T : class { } - private async Task SearchHelper( Expression> propSelector, string searchTerm, @@ -548,7 +611,8 @@ private async Task SearchHelper( string searchTerm, Action configureModel, bool expectedMatch, - TimeZoneInfo timeZoneInfo = null + TimeZoneInfo timeZoneInfo = null, + string includes = null ) where T : class, new() { @@ -559,7 +623,7 @@ private async Task SearchHelper( var ds = new SearchTestDataSource(context); var query = new List { model }.AsQueryable(); - query = ds.ApplyListSearchTerm(query, new FilterParameters { Search = searchTerm }); + query = ds.ApplyListSearchTerm(query, new FilterParameters { Search = searchTerm, Includes = includes }); var matchedItems = query.ToArray(); diff --git a/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs index 657431942..f13a61f65 100644 --- a/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs +++ b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs @@ -52,6 +52,54 @@ public QueryableDataSourceBase(CrudContext context) ?? throw new ArgumentException("Generic type T has no ClassViewModel.", nameof(T)); } + /// + /// 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 value). + /// + /// The property to check. + /// The content view from the current request, if any. + /// True if the property should be searched, false otherwise. + protected virtual bool ShouldSearchProperty(PropertyViewModel property, string? contentView) + { + var includes = property.GetAttributeValue(a => a.Includes); + var excludes = property.GetAttributeValue(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); + } + /// /// 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. @@ -331,6 +379,7 @@ protected virtual IQueryable ApplyListPropertyFilter( public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterParameters parameters) { var searchTerm = parameters.Search; + var contentView = parameters.Includes; // Add general search filters. // These search specified fields in the class @@ -359,6 +408,7 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable 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) .ToList(); @@ -391,7 +441,7 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable 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(); @@ -416,7 +466,7 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable 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); diff --git a/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs index 5a7cee9e5..b0b370e19 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs @@ -2,7 +2,12 @@ namespace IntelliTect.Coalesce.DataAnnotations; /// -/// 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 enumeration to control how the search term is matched. +/// Use and to restrict searching based on the root model type. +/// Use and to restrict searching based on the request's content view. /// [System.AttributeUsage(System.AttributeTargets.Property)] public class SearchAttribute : System.Attribute @@ -71,4 +76,46 @@ public enum SearchMethods /// the root object of the API call was one of the specified class names. /// public string? RootBlacklist { get; set; } + + /// + /// 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 the content view. + /// + /// + /// + /// For example, if a property has [Search(Includes = "details")], + /// it will only be searched when the request includes ?includes=details. + /// Multiple content views can be specified as a comma-delimited list: Includes = "details, admin". + /// + /// + public string? Includes { get; set; } + + /// + /// 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 the content view. + /// + /// + /// + /// For example, if a property has [Search(Excludes = "preview")], + /// it will not be searched when the request includes ?includes=preview. + /// Multiple content views can be specified as a comma-delimited list: Excludes = "preview, summary". + /// + /// + /// + /// Note: If both and are specified, takes precedence. + /// + /// + public string? Excludes { get; set; } }