From 040e3becce4a62330d1d48e65f66b7102c25ac1e Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 18 Jun 2026 14:07:04 -0700 Subject: [PATCH 1/4] feat: Expand SearchAttribute to support Includes and Excludes for content views Add support for filtering searchable properties based on content views: - Add Includes and Excludes properties to SearchAttribute - Add SearchIncludes and SearchExcludes properties to PropertyViewModel - Add ContentView property to IFilterParameters and implementations - Implement filtering logic in ApplyListSearchTerm - Update documentation with examples Includes: properties only searched when matching content view Excludes: properties not searched when matching content view Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../model-components/attributes/search.md | 30 +++++++++++- .../DataSources/QueryableDataSourceBase`1.cs | 41 ++++++++++++++-- .../Api/Parameters/FilterParameters.cs | 3 ++ .../Api/Parameters/IFilterParameters.cs | 6 +++ .../DataAnnotations/SearchAttribute.cs | 49 ++++++++++++++++++- .../TypeDefinition/PropertyViewModel.cs | 20 ++++++++ 6 files changed, 144 insertions(+), 5 deletions(-) diff --git a/docs/modeling/model-components/attributes/search.md b/docs/modeling/model-components/attributes/search.md index e73849d0a..fe6086125 100644 --- a/docs/modeling/model-components/attributes/search.md +++ b/docs/modeling/model-components/attributes/search.md @@ -66,9 +66,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 `?contentView=details` +- `Notes` will not be searched when the request includes `?contentView=preview` + ## Properties @@ -93,4 +103,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 content view in the `ContentView` 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 `?contentView=details`. Multiple content views 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 content view in the `ContentView` 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 `?contentView=preview`. Multiple content views 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/Api/DataSources/QueryableDataSourceBase`1.cs b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs index 657431942..87fa5927e 100644 --- a/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs +++ b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs @@ -52,6 +52,39 @@ 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 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 searchIncludes = property.SearchIncludes.ToList(); + var searchExcludes = property.SearchExcludes.ToList(); + + // If neither includes nor excludes are specified, search the property + if (searchIncludes.Count == 0 && searchExcludes.Count == 0) + { + return true; + } + + // If includes are specified, only search if the content view matches + if (searchIncludes.Count > 0) + { + return !string.IsNullOrWhiteSpace(contentView) && searchIncludes.Contains(contentView, StringComparer.OrdinalIgnoreCase); + } + + // If excludes are specified, search unless the content view matches + if (searchExcludes.Count > 0) + { + return string.IsNullOrWhiteSpace(contentView) || !searchExcludes.Contains(contentView, StringComparer.OrdinalIgnoreCase); + } + + return true; + } + /// /// 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 +364,7 @@ protected virtual IQueryable ApplyListPropertyFilter( public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterParameters parameters) { var searchTerm = parameters.Search; + var contentView = parameters.ContentView; // Add general search filters. // These search specified fields in the class @@ -354,11 +388,12 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterPar string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) || string.Equals(f.DisplayName, field, StringComparison.OrdinalIgnoreCase)); - if (prop != null && !string.IsNullOrWhiteSpace(value)) + if (prop != null && !string.IsNullOrWhiteSpace(value) && ShouldSearchProperty(prop, contentView)) { 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 +426,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 +451,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/Api/Parameters/FilterParameters.cs b/src/IntelliTect.Coalesce/Api/Parameters/FilterParameters.cs index 8009a7a00..932341861 100644 --- a/src/IntelliTect.Coalesce/Api/Parameters/FilterParameters.cs +++ b/src/IntelliTect.Coalesce/Api/Parameters/FilterParameters.cs @@ -8,6 +8,9 @@ public class FilterParameters : DataSourceParameters, IFilterParameters /// public string? Search { get; set; } + /// + public string? ContentView { get; set; } + /// public Dictionary Filter { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs b/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs index a4874817f..3ea0af0c1 100644 --- a/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs +++ b/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs @@ -10,6 +10,12 @@ public interface IFilterParameters : IDataSourceParameters /// string? Search { get; } + /// + /// The content view for which to search. Used to filter which properties are searched + /// based on SearchAttribute.Includes and SearchAttribute.Excludes. + /// + string? ContentView { get; } + /// /// A mapping of values, keyed by field name, on which to filter. /// It is the responsibility of the consumer to decide how to interpret these values. diff --git a/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs index 5a7cee9e5..1b770acaa 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 content view in the ContentView 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 ?contentView=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 content view in the ContentView 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 ?contentView=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; } } diff --git a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs index 2f59bf7c6..ae3774189 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs @@ -900,6 +900,26 @@ public bool CanAutoInclude .Select(s => s.Trim()) .Where(s => !string.IsNullOrEmpty(s)); + /// + /// Returns a list of content views from the Search Includes attribute + /// + public IEnumerable SearchIncludes => + (this.GetAttributeValue(a => a.Includes) ?? "") + .Trim() + .Split(',') + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)); + + /// + /// Returns a list of content views from the Search Excludes attribute + /// + public IEnumerable SearchExcludes => + (this.GetAttributeValue(a => a.Excludes) ?? "") + .Trim() + .Split(',') + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)); + /// /// Returns the role the property plays in a relational model. /// From e38fa4fd3250ca6312b65a0ac9155b9c4a3498d7 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 18 Jun 2026 22:54:54 -0700 Subject: [PATCH 2/4] fix: align SearchAttribute includes filtering behavior and docs Address review feedback by wiring SearchAttribute Includes/Excludes to IDataSourceParameters.Includes, removing extra filter-parameter surface area, refining short-circuit logic in ApplyListSearchTerm, and clarifying related documentation in search.md and includes.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/concepts/includes.md | 16 +++++++++ .../model-components/attributes/search.md | 14 ++++---- .../DataSources/QueryableDataSourceBase`1.cs | 35 +++++++++++++++---- .../Api/Parameters/FilterParameters.cs | 3 -- .../Api/Parameters/IFilterParameters.cs | 6 ---- .../DataAnnotations/SearchAttribute.cs | 8 ++--- .../TypeDefinition/PropertyViewModel.cs | 20 ----------- 7 files changed, 56 insertions(+), 46 deletions(-) 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 fe6086125..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 @@ -76,8 +78,8 @@ public class Person ``` In the above example: -- `Biography` will only be searched when the request includes `?contentView=details` -- `Notes` will not be searched when the request includes `?contentView=preview` +- `Biography` will only be searched when the request includes `?includes=details` +- `Notes` will not be searched when the request includes `?includes=preview` ## Properties @@ -109,16 +111,16 @@ A comma-delimited list of model class names that, if set, will prevent the targe 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 content view in the `ContentView` parameter. If this is empty or null, the property is searched regardless of the content view. +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 `?contentView=details`. Multiple content views can be specified as a comma-delimited list: `Includes = "details, admin"`. +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 content view in the `ContentView` parameter. If this is empty or null, the property is searched regardless of the content view. +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 `?contentView=preview`. Multiple content views can be specified as a comma-delimited list: `Excludes = "preview, summary"`. +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/Api/DataSources/QueryableDataSourceBase`1.cs b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs index 87fa5927e..8e2748369 100644 --- a/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs +++ b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs @@ -61,22 +61,43 @@ public QueryableDataSourceBase(CrudContext context) /// True if the property should be searched, false otherwise. protected virtual bool ShouldSearchProperty(PropertyViewModel property, string? contentView) { - var searchIncludes = property.SearchIncludes.ToList(); - var searchExcludes = property.SearchExcludes.ToList(); + var includes = property.GetAttributeValue(a => a.Includes); + // No incoming content view (`includes` query string is empty): + // 1. If this property has Search.Includes, we can't satisfy that requirement, so don't search it. + // 2. Otherwise, search it. Search.Excludes has nothing to match against in this scenario. + if (string.IsNullOrWhiteSpace(contentView)) + { + return string.IsNullOrWhiteSpace(includes); + } + + var searchIncludes = (includes ?? "") + .Trim() + .Split(',') + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + var searchExcludes = (property.GetAttributeValue(a => a.Excludes) ?? "") + .Trim() + .Split(',') + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); - // If neither includes nor excludes are specified, search the property + // Incoming content view is present. + // 1. No search include/exclude constraints -> search the property. if (searchIncludes.Count == 0 && searchExcludes.Count == 0) { return true; } - // If includes are specified, only search if the content view matches + // 2. Search.Includes is specified -> require an explicit include match. + // Includes wins over excludes when both are set. if (searchIncludes.Count > 0) { return !string.IsNullOrWhiteSpace(contentView) && searchIncludes.Contains(contentView, StringComparer.OrdinalIgnoreCase); } - // If excludes are specified, search unless the content view matches + // 3. Search.Excludes is specified (and Search.Includes is not) -> block only on exclude match. if (searchExcludes.Count > 0) { return string.IsNullOrWhiteSpace(contentView) || !searchExcludes.Contains(contentView, StringComparer.OrdinalIgnoreCase); @@ -364,7 +385,7 @@ protected virtual IQueryable ApplyListPropertyFilter( public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterParameters parameters) { var searchTerm = parameters.Search; - var contentView = parameters.ContentView; + var contentView = parameters.Includes; // Add general search filters. // These search specified fields in the class @@ -388,7 +409,7 @@ public virtual IQueryable ApplyListSearchTerm(IQueryable query, IFilterPar string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) || string.Equals(f.DisplayName, field, StringComparison.OrdinalIgnoreCase)); - if (prop != null && !string.IsNullOrWhiteSpace(value) && ShouldSearchProperty(prop, contentView)) + if (prop != null && !string.IsNullOrWhiteSpace(value)) { var expressions = prop .SearchProperties(ClassViewModel, maxDepth: 1, force: true) diff --git a/src/IntelliTect.Coalesce/Api/Parameters/FilterParameters.cs b/src/IntelliTect.Coalesce/Api/Parameters/FilterParameters.cs index 932341861..8009a7a00 100644 --- a/src/IntelliTect.Coalesce/Api/Parameters/FilterParameters.cs +++ b/src/IntelliTect.Coalesce/Api/Parameters/FilterParameters.cs @@ -8,9 +8,6 @@ public class FilterParameters : DataSourceParameters, IFilterParameters /// public string? Search { get; set; } - /// - public string? ContentView { get; set; } - /// public Dictionary Filter { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs b/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs index 3ea0af0c1..a4874817f 100644 --- a/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs +++ b/src/IntelliTect.Coalesce/Api/Parameters/IFilterParameters.cs @@ -10,12 +10,6 @@ public interface IFilterParameters : IDataSourceParameters /// string? Search { get; } - /// - /// The content view for which to search. Used to filter which properties are searched - /// based on SearchAttribute.Includes and SearchAttribute.Excludes. - /// - string? ContentView { get; } - /// /// A mapping of values, keyed by field name, on which to filter. /// It is the responsibility of the consumer to decide how to interpret these values. diff --git a/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs index 1b770acaa..b0b370e19 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/SearchAttribute.cs @@ -84,13 +84,13 @@ public enum SearchMethods /// /// /// When this property is set, the property will only be searched if the API request - /// includes a matching content view in the ContentView parameter. + /// 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 ?contentView=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". /// /// @@ -103,13 +103,13 @@ public enum SearchMethods /// /// /// When this property is set, the property will not be searched if the API request - /// includes a matching content view in the ContentView parameter. + /// 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 ?contentView=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". /// /// diff --git a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs index ae3774189..2f59bf7c6 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/PropertyViewModel.cs @@ -900,26 +900,6 @@ public bool CanAutoInclude .Select(s => s.Trim()) .Where(s => !string.IsNullOrEmpty(s)); - /// - /// Returns a list of content views from the Search Includes attribute - /// - public IEnumerable SearchIncludes => - (this.GetAttributeValue(a => a.Includes) ?? "") - .Trim() - .Split(',') - .Select(s => s.Trim()) - .Where(s => !string.IsNullOrEmpty(s)); - - /// - /// Returns a list of content views from the Search Excludes attribute - /// - public IEnumerable SearchExcludes => - (this.GetAttributeValue(a => a.Excludes) ?? "") - .Trim() - .Split(',') - .Select(s => s.Trim()) - .Where(s => !string.IsNullOrEmpty(s)); - /// /// Returns the role the property plays in a relational model. /// From b2201d40fd748e88a0fa7a07e2d0db1e6771b464 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 18 Jun 2026 22:55:45 -0700 Subject: [PATCH 3/4] docs: add SearchAttribute includes/excludes changelog entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8388359d..78e64f086 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Added `adminOverrides` option to `createCoalesceVuetify()`, allowing custom Vue components to replace the default input and/or display components used in admin pages (`c-admin-editor`, `c-admin-method`, `c-table`) for specific model properties, method parameters, or method return values. - `c-datetime-picker`: Assorted UI and UX improvements and fixes. - 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`. ## Template Changes - Multi-tenancy database configuration is now provided by the `IntelliTect.Coalesce.MultiTenancy` package instead of inline code in `AppDbContext`. To migrate an existing project that doesn't diverge significantly from the previous out-of-the-box Coalesce template tenancy behavior: From 304e49701259598037b09c49717ac45c1a6c577a Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Mon, 29 Jun 2026 21:01:12 -0700 Subject: [PATCH 4/4] test: #731 add SearchAttribute content-view tests; optimize ShouldSearchProperty Refactor ShouldSearchProperty to short-circuit before any string allocation when a property has no Includes/Excludes constraints, and trim the incoming content view once. Add unit tests covering Includes match, Excludes match, Includes precedence over Excludes, comma-separated lists, and the no-incoming-content-view short-circuit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Tests/Api/SearchTests.cs | 70 ++++++++++++++++++- .../DataSources/QueryableDataSourceBase`1.cs | 60 +++++++--------- 2 files changed, 94 insertions(+), 36 deletions(-) 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 8e2748369..f13a61f65 100644 --- a/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs +++ b/src/IntelliTect.Coalesce/Api/DataSources/QueryableDataSourceBase`1.cs @@ -54,7 +54,7 @@ public QueryableDataSourceBase(CrudContext context) /// /// Determines if a property should be searched based on its SearchAttribute.Includes and SearchAttribute.Excludes - /// relative to the current request's content view. + /// 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. @@ -62,48 +62,42 @@ public QueryableDataSourceBase(CrudContext context) protected virtual bool ShouldSearchProperty(PropertyViewModel property, string? contentView) { var includes = property.GetAttributeValue(a => a.Includes); - // No incoming content view (`includes` query string is empty): - // 1. If this property has Search.Includes, we can't satisfy that requirement, so don't search it. - // 2. Otherwise, search it. Search.Excludes has nothing to match against in this scenario. - if (string.IsNullOrWhiteSpace(contentView)) - { - return string.IsNullOrWhiteSpace(includes); - } + var excludes = property.GetAttributeValue(a => a.Excludes); - var searchIncludes = (includes ?? "") - .Trim() - .Split(',') - .Select(s => s.Trim()) - .Where(s => !string.IsNullOrEmpty(s)) - .ToList(); - var searchExcludes = (property.GetAttributeValue(a => a.Excludes) ?? "") - .Trim() - .Split(',') - .Select(s => s.Trim()) - .Where(s => !string.IsNullOrEmpty(s)) - .ToList(); + var hasIncludes = !string.IsNullOrWhiteSpace(includes); + var hasExcludes = !string.IsNullOrWhiteSpace(excludes); - // Incoming content view is present. - // 1. No search include/exclude constraints -> search the property. - if (searchIncludes.Count == 0 && searchExcludes.Count == 0) + // 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; } - // 2. Search.Includes is specified -> require an explicit include match. - // Includes wins over excludes when both are set. - if (searchIncludes.Count > 0) - { - return !string.IsNullOrWhiteSpace(contentView) && searchIncludes.Contains(contentView, StringComparer.OrdinalIgnoreCase); - } + var trimmedContentView = contentView?.Trim(); - // 3. Search.Excludes is specified (and Search.Includes is not) -> block only on exclude match. - if (searchExcludes.Count > 0) + // 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.IsNullOrWhiteSpace(contentView) || !searchExcludes.Contains(contentView, StringComparer.OrdinalIgnoreCase); + return !string.IsNullOrEmpty(trimmedContentView) + && includes! + .Split(',') + .Select(s => s.Trim()) + .Contains(trimmedContentView, StringComparer.OrdinalIgnoreCase); } - return true; + // 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); } ///