From 311af61ee4744d17d9902ba54ac9e9cfbec5522d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 09:59:19 +0200 Subject: [PATCH 1/9] feat: Introduce WhereGroupBuilder for advanced filter grouping and add related extensions --- .../Extensions/WhereGroupBuilderExtensions.cs | 167 ++++++++++++++++++ .../Interfaces/IAttributeNameResolver.cs | 21 +++ .../Interfaces/IValueConverter.cs | 16 ++ src/QueryBuilder/QueryExpressionBuilder.cs | 145 +++++++++++---- .../Services/AttributeNameResolver.cs | 32 ++++ src/QueryBuilder/Services/ValueConverter.cs | 41 +++++ src/QueryBuilder/WhereGroupBuilder.cs | 72 ++++++++ .../QueryExpressionBuilderTests.cs | 120 +++++++++++++ 8 files changed, 583 insertions(+), 31 deletions(-) create mode 100644 src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs create mode 100644 src/QueryBuilder/Interfaces/IAttributeNameResolver.cs create mode 100644 src/QueryBuilder/Interfaces/IValueConverter.cs create mode 100644 src/QueryBuilder/Services/AttributeNameResolver.cs create mode 100644 src/QueryBuilder/Services/ValueConverter.cs create mode 100644 src/QueryBuilder/WhereGroupBuilder.cs diff --git a/src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs b/src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs new file mode 100644 index 0000000..39d9b07 --- /dev/null +++ b/src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs @@ -0,0 +1,167 @@ +using System.Linq.Expressions; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace DataverseQuery.QueryBuilder.Extensions +{ + /// + /// Extension methods for WhereGroupBuilder to provide common filter patterns. + /// + public static class WhereGroupBuilderExtensions + { + /// + /// Adds an equality condition. + /// + /// The entity type. + /// The value type of the property. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// The value to compare against. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereEqual( + this WhereGroupBuilder builder, + Expression> fieldSelector, + TValue value) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.Equal, value); + } + + /// + /// Adds a 'like' condition. + /// + /// The entity type. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// The pattern to match (supports % wildcards). + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereLike( + this WhereGroupBuilder builder, + Expression> fieldSelector, + string pattern) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.Like, pattern); + } + + /// + /// Adds an 'in' condition. + /// + /// The entity type. + /// The value type of the property. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// The values to check against. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereIn( + this WhereGroupBuilder builder, + Expression> fieldSelector, + params TValue[] values) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.In, values); + } + + /// + /// Adds a 'not equal' condition. + /// + /// The entity type. + /// The value type of the property. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// The value to compare against. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereNotEqual( + this WhereGroupBuilder builder, + Expression> fieldSelector, + TValue value) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.NotEqual, value); + } + + /// + /// Adds a 'greater than' condition. + /// + /// The entity type. + /// The value type of the property. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// The value to compare against. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereGreaterThan( + this WhereGroupBuilder builder, + Expression> fieldSelector, + TValue value) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.GreaterThan, value); + } + + /// + /// Adds a 'less than' condition. + /// + /// The entity type. + /// The value type of the property. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// The value to compare against. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereLessThan( + this WhereGroupBuilder builder, + Expression> fieldSelector, + TValue value) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.LessThan, value); + } + + /// + /// Adds a 'null' condition. + /// + /// The entity type. + /// The value type of the property. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereNull( + this WhereGroupBuilder builder, + Expression> fieldSelector) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.Null); + } + + /// + /// Adds a 'not null' condition. + /// + /// The entity type. + /// The value type of the property. + /// The WhereGroupBuilder instance. + /// The lambda expression selecting the field. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when builder or fieldSelector is null. + public static WhereGroupBuilder WhereNotNull( + this WhereGroupBuilder builder, + Expression> fieldSelector) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(builder); + return builder.Where(fieldSelector, ConditionOperator.NotNull); + } + } +} diff --git a/src/QueryBuilder/Interfaces/IAttributeNameResolver.cs b/src/QueryBuilder/Interfaces/IAttributeNameResolver.cs new file mode 100644 index 0000000..29509af --- /dev/null +++ b/src/QueryBuilder/Interfaces/IAttributeNameResolver.cs @@ -0,0 +1,21 @@ +using System.Linq.Expressions; +using Microsoft.Xrm.Sdk; + +namespace DataverseQuery.QueryBuilder.Interfaces +{ + /// + /// Resolves attribute names from lambda expressions. + /// + public interface IAttributeNameResolver + { + /// + /// Gets the attribute name from a property selector expression. + /// + /// The entity type. + /// The value type of the property. + /// The lambda expression selecting the property. + /// The attribute name, or null if it cannot be resolved. + string? GetAttributeName(Expression> fieldSelector) + where TEntity : Entity; + } +} diff --git a/src/QueryBuilder/Interfaces/IValueConverter.cs b/src/QueryBuilder/Interfaces/IValueConverter.cs new file mode 100644 index 0000000..5050b3b --- /dev/null +++ b/src/QueryBuilder/Interfaces/IValueConverter.cs @@ -0,0 +1,16 @@ +namespace DataverseQuery.QueryBuilder.Interfaces +{ + /// + /// Converts values for use in Dataverse queries. + /// + public interface IValueConverter + { + /// + /// Converts an array of values to their primitive representations for use in Dataverse queries. + /// + /// The type of values to convert. + /// The values to convert. + /// An array of converted values suitable for Dataverse queries. + object[] ConvertValues(TValue[] values); + } +} diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index 807cbb0..0909062 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -1,4 +1,6 @@ using System.Linq.Expressions; +using DataverseQuery.QueryBuilder.Interfaces; +using DataverseQuery.QueryBuilder.Services; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; @@ -11,13 +13,39 @@ public sealed class QueryExpressionBuilder : IQueryBuilder private readonly List columns = new(); private readonly List filters = new(); private readonly List expands = new(); + private readonly IAttributeNameResolver attributeNameResolver; + private readonly IValueConverter valueConverter; private int? topCount; + /// + /// Initializes a new instance of the class. + /// public QueryExpressionBuilder() { var logicalNameProp = typeof(TEntity).GetField("EntityLogicalName"); entityLogicalName = logicalNameProp?.GetValue(null) as string ?? typeof(TEntity).Name.ToLowerInvariant(); + + attributeNameResolver = new AttributeNameResolver(); + valueConverter = new ValueConverter(); + } + + /// + /// Initializes a new instance of the class with custom services. + /// + /// The service for resolving attribute names from expressions. + /// The service for converting values for Dataverse queries. + /// Thrown when any parameter is null. + internal QueryExpressionBuilder( + IAttributeNameResolver attributeNameResolver, + IValueConverter valueConverter) + { + var logicalNameProp = typeof(TEntity).GetField("EntityLogicalName"); + entityLogicalName = logicalNameProp?.GetValue(null) as string + ?? typeof(TEntity).Name.ToLowerInvariant(); + + this.attributeNameResolver = attributeNameResolver ?? throw new ArgumentNullException(nameof(attributeNameResolver)); + this.valueConverter = valueConverter ?? throw new ArgumentNullException(nameof(valueConverter)); } // Implement interface methods @@ -52,10 +80,10 @@ public QueryExpressionBuilder Select(params Expression(selector); + var name = attributeNameResolver.GetAttributeName(selector); if (!string.IsNullOrEmpty(name)) { - columns.Add(name.ToLowerInvariant()); + columns.Add(name); } } @@ -70,22 +98,12 @@ public QueryExpressionBuilder Where( ArgumentNullException.ThrowIfNull(fieldSelector); ArgumentNullException.ThrowIfNull(values); - var name = GetAttributeName(fieldSelector); + var name = attributeNameResolver.GetAttributeName(fieldSelector); if (!string.IsNullOrEmpty(name)) { - var filter = new FilterExpression(); - var primitiveValues = values - .Cast() - .Select(v => - v switch - { - Enum => Convert.ChangeType(v, Enum.GetUnderlyingType(v.GetType()), System.Globalization.CultureInfo.InvariantCulture), - EntityReference er => er.Id, - _ => v, - }) - .ToArray(); - filter.AddCondition(name.ToLowerInvariant(), op, primitiveValues); - filters.Add(filter); + var andFilter = GetOrCreateAndFilter(); + var primitiveValues = valueConverter.ConvertValues(values); + andFilter.AddCondition(name, op, primitiveValues); } return this; @@ -229,21 +247,6 @@ private static string GetEntityLogicalName(Type t) } // --- Helpers --- - private static string? GetAttributeName(Expression> expr) - { - if (expr.Body is MemberExpression member) - { - return member.Member.Name; - } - - if (expr.Body is UnaryExpression unary && unary.Operand is MemberExpression m) - { - return m.Member.Name; - } - - return null; - } - private static string? GetRelationshipSchemaName(Expression> navigation) { string? propName = null; @@ -353,5 +356,85 @@ private static (string FromAttr, string ToAttr) GetLinkAttributesForReference(st var toAttr = GetEntityLogicalName(targetType) + "id"; return (fromAttr, toAttr); } + + /// + /// Adds a grouped set of filter conditions with the specified logical operator. + /// + /// The logical operator to use within the group (And/Or). + /// Action to configure the filter group. + /// This QueryExpressionBuilder instance for method chaining. + /// Thrown when configure is null. + public QueryExpressionBuilder WhereGroup( + LogicalOperator logicalOperator, + Action> configure) + { + ArgumentNullException.ThrowIfNull(configure); + + var groupBuilder = new WhereGroupBuilder(attributeNameResolver, valueConverter); + configure(groupBuilder); + + if (groupBuilder.HasConditions) + { + var groupFilter = new FilterExpression(logicalOperator); + foreach (var condition in groupBuilder.GetConditions()) + { + groupFilter.AddCondition(condition); + } + + filters.Add(groupFilter); + } + + return this; + } + + /// + /// Adds an OR group of filter conditions. + /// + /// Action to configure the filter group. + /// This QueryExpressionBuilder instance for method chaining. + /// Thrown when configure is null. + public QueryExpressionBuilder OrWhereGroup(Action> configure) + => WhereGroup(LogicalOperator.Or, configure); + + /// + /// Adds an AND group of filter conditions. + /// + /// Action to configure the filter group. + /// This QueryExpressionBuilder instance for method chaining. + /// Thrown when configure is null. + public QueryExpressionBuilder AndWhereGroup(Action> configure) + => WhereGroup(LogicalOperator.And, configure); + + /// + /// Adds multiple OR conditions to a single filter group. + /// Convenience method for creating OR groups. + /// + /// Action to configure the filter group. + /// This QueryExpressionBuilder instance for method chaining. + /// Thrown when configure is null. + public QueryExpressionBuilder WhereAny(Action> configure) + => OrWhereGroup(configure); + + /// + /// Adds multiple AND conditions to a single filter group. + /// Convenience method for creating AND groups. + /// + /// Action to configure the filter group. + /// This QueryExpressionBuilder instance for method chaining. + /// Thrown when configure is null. + public QueryExpressionBuilder WhereAll(Action> configure) + => AndWhereGroup(configure); + + private FilterExpression GetOrCreateAndFilter() + { + var andFilter = filters.LastOrDefault(f => f.FilterOperator == LogicalOperator.And); + if (andFilter == null) + { + andFilter = new FilterExpression(LogicalOperator.And); + filters.Add(andFilter); + } + + return andFilter; + } } } diff --git a/src/QueryBuilder/Services/AttributeNameResolver.cs b/src/QueryBuilder/Services/AttributeNameResolver.cs new file mode 100644 index 0000000..8f0a00e --- /dev/null +++ b/src/QueryBuilder/Services/AttributeNameResolver.cs @@ -0,0 +1,32 @@ +using System.Linq.Expressions; +using DataverseQuery.QueryBuilder.Interfaces; +using Microsoft.Xrm.Sdk; + +namespace DataverseQuery.QueryBuilder.Services +{ + /// + /// Default implementation of attribute name resolution. + /// + public sealed class AttributeNameResolver : IAttributeNameResolver + { + /// + /// Gets the attribute name from a property selector expression. + /// + /// The entity type. + /// The value type of the property. + /// The lambda expression selecting the property. + /// The attribute name in lowercase, or null if it cannot be resolved. + public string? GetAttributeName(Expression> fieldSelector) + where TEntity : Entity + { + ArgumentNullException.ThrowIfNull(fieldSelector); + + return fieldSelector.Body switch + { + MemberExpression member => member.Member.Name.ToLowerInvariant(), + UnaryExpression { Operand: MemberExpression unaryMember } => unaryMember.Member.Name.ToLowerInvariant(), + _ => null, + }; + } + } +} diff --git a/src/QueryBuilder/Services/ValueConverter.cs b/src/QueryBuilder/Services/ValueConverter.cs new file mode 100644 index 0000000..63e4900 --- /dev/null +++ b/src/QueryBuilder/Services/ValueConverter.cs @@ -0,0 +1,41 @@ +using System.Globalization; +using DataverseQuery.QueryBuilder.Interfaces; +using Microsoft.Xrm.Sdk; + +namespace DataverseQuery.QueryBuilder.Services +{ + /// + /// Default implementation of value conversion for Dataverse queries. + /// + public sealed class ValueConverter : IValueConverter + { + /// + /// Converts an array of values to their primitive representations for use in Dataverse queries. + /// + /// The type of values to convert. + /// The values to convert. + /// An array of converted values suitable for Dataverse queries. + /// Thrown when values is null. + /// Thrown when any value in the array is null. + public object[] ConvertValues(TValue[] values) + { + ArgumentNullException.ThrowIfNull(values); + + return values + .Cast() + .Select(ConvertValue) + .ToArray(); + } + + private static object ConvertValue(object value) + { + return value switch + { + null => throw new ArgumentException("Null values are not supported in filter conditions.", nameof(value)), + Enum enumValue => Convert.ChangeType(enumValue, Enum.GetUnderlyingType(enumValue.GetType()), CultureInfo.InvariantCulture), + EntityReference entityRef => entityRef.Id, + _ => value, + }; + } + } +} diff --git a/src/QueryBuilder/WhereGroupBuilder.cs b/src/QueryBuilder/WhereGroupBuilder.cs new file mode 100644 index 0000000..609906a --- /dev/null +++ b/src/QueryBuilder/WhereGroupBuilder.cs @@ -0,0 +1,72 @@ +using System.Linq.Expressions; +using DataverseQuery.QueryBuilder.Interfaces; +using Microsoft.Xrm.Sdk; +using Microsoft.Xrm.Sdk.Query; + +namespace DataverseQuery +{ + /// + /// Builder for creating grouped filter conditions with a specific logical operator. + /// + /// The entity type being queried. + public sealed class WhereGroupBuilder + where TEntity : Entity + { + private readonly List conditions = new(); + private readonly IAttributeNameResolver attributeNameResolver; + private readonly IValueConverter valueConverter; + + /// + /// Initializes a new instance of the class. + /// + /// The service for resolving attribute names from expressions. + /// The service for converting values for Dataverse queries. + /// Thrown when any parameter is null. + internal WhereGroupBuilder( + IAttributeNameResolver attributeNameResolver, + IValueConverter valueConverter) + { + this.attributeNameResolver = attributeNameResolver ?? throw new ArgumentNullException(nameof(attributeNameResolver)); + this.valueConverter = valueConverter ?? throw new ArgumentNullException(nameof(valueConverter)); + } + + /// + /// Adds a condition to this filter group. + /// + /// The type of the field value. + /// The lambda expression selecting the field. + /// The condition operator to apply. + /// The values to compare against. + /// This WhereGroupBuilder instance for method chaining. + /// Thrown when fieldSelector or values is null. + public WhereGroupBuilder Where( + Expression> fieldSelector, + ConditionOperator op, + params TValue[] values) + { + ArgumentNullException.ThrowIfNull(fieldSelector); + ArgumentNullException.ThrowIfNull(values); + + var attributeName = attributeNameResolver.GetAttributeName(fieldSelector); + if (!string.IsNullOrEmpty(attributeName)) + { + var convertedValues = valueConverter.ConvertValues(values); + conditions.Add(new ConditionExpression(attributeName, op, convertedValues)); + } + + return this; + } + + /// + /// Gets all conditions in this group. + /// + /// A read-only list of conditions in this group. + internal IReadOnlyList GetConditions() => conditions.AsReadOnly(); + + /// + /// Gets whether this group has any conditions. + /// + /// True if this group has conditions, false otherwise. + internal bool HasConditions => conditions.Count > 0; + } +} diff --git a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs index 42aa852..6e37ba0 100644 --- a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs +++ b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs @@ -116,5 +116,125 @@ public void Where_EntityReferenceFilter_UsesGuidValue() Assert.IsType(condition.Values[0]); Assert.Equal(parentId, (Guid)condition.Values[0]); } + + [Fact] + public void WhereGroup_WithOrConditions_CreatesCorrectFilterStructure() + { + // Arrange & Act + var query = new QueryExpressionBuilder() + .Select(a => a.Name, a => a.StateCode) + .Where(a => a.StateCode, ConditionOperator.Equal, SharedContext.AccountState.Aktiv) + .OrWhereGroup(group => group + .Where(a => a.Name, ConditionOperator.Like, "%Test%") + .Where(a => a.AccountNumber, ConditionOperator.Like, "%123%")) + .Build(); + + // Assert + Assert.Equal(2, query.Criteria.Filters.Count); + + var andFilter = query.Criteria.Filters.First(f => f.FilterOperator == LogicalOperator.And); + Assert.Single(andFilter.Conditions); + Assert.Equal("statecode", andFilter.Conditions[0].AttributeName); + + var orFilter = query.Criteria.Filters.First(f => f.FilterOperator == LogicalOperator.Or); + Assert.Equal(2, orFilter.Conditions.Count); + Assert.Equal("name", orFilter.Conditions[0].AttributeName); + Assert.Equal("accountnumber", orFilter.Conditions[1].AttributeName); + } + + [Fact] + public void AndWhereGroup_CreatesAndFilter() + { + // Arrange & Act + var query = new QueryExpressionBuilder() + .Select(a => a.Name) + .AndWhereGroup(group => group + .Where(a => a.Name, ConditionOperator.Like, "%Test%") + .Where(a => a.StateCode, ConditionOperator.Equal, SharedContext.AccountState.Aktiv)) + .Build(); + + // Assert + Assert.Single(query.Criteria.Filters); + var andFilter = query.Criteria.Filters[0]; + Assert.Equal(LogicalOperator.And, andFilter.FilterOperator); + Assert.Equal(2, andFilter.Conditions.Count); + } + + [Fact] + public void WhereAny_CreatesOrFilter() + { + // Arrange & Act + var query = new QueryExpressionBuilder() + .Select(a => a.Name) + .WhereAny(group => group + .Where(a => a.Name, ConditionOperator.Like, "%Test%") + .Where(a => a.Name, ConditionOperator.Like, "%Demo%")) + .Build(); + + // Assert + Assert.Single(query.Criteria.Filters); + var orFilter = query.Criteria.Filters[0]; + Assert.Equal(LogicalOperator.Or, orFilter.FilterOperator); + Assert.Equal(2, orFilter.Conditions.Count); + } + + [Fact] + public void WhereAll_CreatesAndFilter() + { + // Arrange & Act + var query = new QueryExpressionBuilder() + .Select(a => a.Name) + .WhereAll(group => group + .Where(a => a.StateCode, ConditionOperator.Equal, SharedContext.AccountState.Aktiv) + .Where(a => a.StatusCode, ConditionOperator.Equal, SharedContext.Account_StatusCode.Aktiv)) + .Build(); + + // Assert + Assert.Single(query.Criteria.Filters); + var andFilter = query.Criteria.Filters[0]; + Assert.Equal(LogicalOperator.And, andFilter.FilterOperator); + Assert.Equal(2, andFilter.Conditions.Count); + } + + [Fact] + public void ComplexGrouping_WithMultipleFilters_CreatesCorrectStructure() + { + // Arrange & Act - (status = active OR status = inactive) AND (type = customer OR type = partner) + var query = new QueryExpressionBuilder() + .Select(a => a.Name) + .OrWhereGroup(group => group + .Where(a => a.StateCode, ConditionOperator.Equal, SharedContext.AccountState.Aktiv) + .Where(a => a.StateCode, ConditionOperator.Equal, SharedContext.AccountState.Inaktiv)) + .OrWhereGroup(group => group + .Where(a => a.CustomerTypeCode, ConditionOperator.Equal, SharedContext.Account_CustomerTypeCode.Kunde) + .Where(a => a.CustomerTypeCode, ConditionOperator.Equal, SharedContext.Account_CustomerTypeCode.Partner)) + .Build(); + + // Assert + Assert.Equal(2, query.Criteria.Filters.Count); + + foreach (var filter in query.Criteria.Filters) + { + Assert.Equal(LogicalOperator.Or, filter.FilterOperator); + Assert.Equal(2, filter.Conditions.Count); + } + } + + [Fact] + public void WhereGroup_WithEmptyGroup_DoesNotAddFilter() + { + // Arrange & Act + var query = new QueryExpressionBuilder() + .Select(a => a.Name) + .Where(a => a.StateCode, ConditionOperator.Equal, SharedContext.AccountState.Aktiv) + .OrWhereGroup(group => { /* Empty group */ }) + .Build(); + + // Assert - Only the regular Where condition should be present + Assert.Single(query.Criteria.Filters); + var filter = query.Criteria.Filters[0]; + Assert.Equal(LogicalOperator.And, filter.FilterOperator); + Assert.Single(filter.Conditions); + } } } From ccc9e35d9bfca754174121e007cadfa5f5fb702b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:04:18 +0200 Subject: [PATCH 2/9] fix: Ensure final newlines in .editorconfig and add newlines at end of files in IQueryBuilder and QueryExpressionBuilderExtensions --- .editorconfig | 2 +- src/QueryBuilder/IQueryBuilder.cs | 2 +- src/QueryBuilder/QueryExpressionBuilder.cs | 3 ++- src/QueryBuilder/QueryExpressionBuilderExtensions.cs | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.editorconfig b/.editorconfig index 0cd4200..1912ba4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,7 +17,7 @@ root = true charset = utf-8 indent_style = space indent_size = 4 -insert_final_newline = false +insert_final_newline = true trim_trailing_whitespace = true ########################################## diff --git a/src/QueryBuilder/IQueryBuilder.cs b/src/QueryBuilder/IQueryBuilder.cs index 4f9a80f..631f8ec 100644 --- a/src/QueryBuilder/IQueryBuilder.cs +++ b/src/QueryBuilder/IQueryBuilder.cs @@ -12,4 +12,4 @@ public interface IQueryBuilder LinkEntity BuildLinkEntity(ExpandBuilder expand); } -} \ No newline at end of file +} diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index 0909062..ca354eb 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -58,7 +58,8 @@ public ColumnSet GetColumns() public FilterExpression GetCombinedFilter() { - if (filters.Count == 0) return new FilterExpression(); + if (filters.Count == 0) + return new FilterExpression(); var filter = new FilterExpression(LogicalOperator.And); foreach (var f in filters) diff --git a/src/QueryBuilder/QueryExpressionBuilderExtensions.cs b/src/QueryBuilder/QueryExpressionBuilderExtensions.cs index 297fee7..871ca3e 100644 --- a/src/QueryBuilder/QueryExpressionBuilderExtensions.cs +++ b/src/QueryBuilder/QueryExpressionBuilderExtensions.cs @@ -14,4 +14,4 @@ public static IReadOnlyCollection RetrieveAll(this IOrganizati return result.Entities.Select(e => e.ToEntity()).ToArray(); } } -} \ No newline at end of file +} From ee36ee8903e8ec38e72adb9e55e66e7d4131629b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:12:32 +0200 Subject: [PATCH 3/9] refactor: Update namespaces to DataverseQuery.QueryBuilder for consistency across files --- src/QueryBuilder/ExpandBuilder.cs | 2 +- src/QueryBuilder/IQueryBuilder.cs | 2 +- src/QueryBuilder/QueryExpressionBuilder.cs | 2 +- src/QueryBuilder/QueryExpressionBuilderExtensions.cs | 2 +- src/QueryBuilder/WhereGroupBuilder.cs | 2 +- test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/QueryBuilder/ExpandBuilder.cs b/src/QueryBuilder/ExpandBuilder.cs index 4b37e15..b2e4f81 100644 --- a/src/QueryBuilder/ExpandBuilder.cs +++ b/src/QueryBuilder/ExpandBuilder.cs @@ -1,4 +1,4 @@ -namespace DataverseQuery +namespace DataverseQuery.QueryBuilder { public sealed class ExpandBuilder { diff --git a/src/QueryBuilder/IQueryBuilder.cs b/src/QueryBuilder/IQueryBuilder.cs index 631f8ec..6bab783 100644 --- a/src/QueryBuilder/IQueryBuilder.cs +++ b/src/QueryBuilder/IQueryBuilder.cs @@ -1,6 +1,6 @@ using Microsoft.Xrm.Sdk.Query; -namespace DataverseQuery +namespace DataverseQuery.QueryBuilder { public interface IQueryBuilder { diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index ca354eb..0a7aa69 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -4,7 +4,7 @@ using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; -namespace DataverseQuery +namespace DataverseQuery.QueryBuilder { public sealed class QueryExpressionBuilder : IQueryBuilder where TEntity : Entity diff --git a/src/QueryBuilder/QueryExpressionBuilderExtensions.cs b/src/QueryBuilder/QueryExpressionBuilderExtensions.cs index 871ca3e..ca13643 100644 --- a/src/QueryBuilder/QueryExpressionBuilderExtensions.cs +++ b/src/QueryBuilder/QueryExpressionBuilderExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Xrm.Sdk; -namespace DataverseQuery +namespace DataverseQuery.QueryBuilder { public static class QueryExpressionBuilderExtensions { diff --git a/src/QueryBuilder/WhereGroupBuilder.cs b/src/QueryBuilder/WhereGroupBuilder.cs index 609906a..1de9121 100644 --- a/src/QueryBuilder/WhereGroupBuilder.cs +++ b/src/QueryBuilder/WhereGroupBuilder.cs @@ -3,7 +3,7 @@ using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; -namespace DataverseQuery +namespace DataverseQuery.QueryBuilder { /// /// Builder for creating grouped filter conditions with a specific logical operator. diff --git a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs index 6e37ba0..5fb74cf 100644 --- a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs +++ b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs @@ -1,3 +1,4 @@ +using DataverseQuery.QueryBuilder; using Microsoft.Xrm.Sdk; using Microsoft.Xrm.Sdk.Query; From 9d2295ac82f1bf68bdfa04ec1fdc4f9cc83125b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:20:23 +0200 Subject: [PATCH 4/9] Fixing build --- src/QueryBuilder/ExpandBuilder.cs | 2 +- src/QueryBuilder/QueryExpressionBuilder.cs | 2 +- test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/QueryBuilder/ExpandBuilder.cs b/src/QueryBuilder/ExpandBuilder.cs index b2e4f81..6b5d98e 100644 --- a/src/QueryBuilder/ExpandBuilder.cs +++ b/src/QueryBuilder/ExpandBuilder.cs @@ -18,4 +18,4 @@ public ExpandBuilder(string relationshipName, Type targetType, IQueryBuilder bui IsCollection = isCollection; } } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index 0a7aa69..0c1bf87 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -438,4 +438,4 @@ private FilterExpression GetOrCreateAndFilter() return andFilter; } } -} +} \ No newline at end of file diff --git a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs index 5fb74cf..a00ecb5 100644 --- a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs +++ b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs @@ -238,4 +238,4 @@ public void WhereGroup_WithEmptyGroup_DoesNotAddFilter() Assert.Single(filter.Conditions); } } -} +} \ No newline at end of file From 50a8f46ed66b2eed516caa07ba5d599a8f8c85e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:20:43 +0200 Subject: [PATCH 5/9] Added newline --- src/QueryBuilder/ExpandBuilder.cs | 2 +- src/QueryBuilder/QueryExpressionBuilder.cs | 2 +- test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/QueryBuilder/ExpandBuilder.cs b/src/QueryBuilder/ExpandBuilder.cs index 6b5d98e..b2e4f81 100644 --- a/src/QueryBuilder/ExpandBuilder.cs +++ b/src/QueryBuilder/ExpandBuilder.cs @@ -18,4 +18,4 @@ public ExpandBuilder(string relationshipName, Type targetType, IQueryBuilder bui IsCollection = isCollection; } } -} \ No newline at end of file +} diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index 0c1bf87..0a7aa69 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -438,4 +438,4 @@ private FilterExpression GetOrCreateAndFilter() return andFilter; } } -} \ No newline at end of file +} diff --git a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs index a00ecb5..5fb74cf 100644 --- a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs +++ b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs @@ -238,4 +238,4 @@ public void WhereGroup_WithEmptyGroup_DoesNotAddFilter() Assert.Single(filter.Conditions); } } -} \ No newline at end of file +} From e28cb4417516fb5179599db46f5440ffe1dc893e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:34:26 +0200 Subject: [PATCH 6/9] feat: Add .gitattributes to enforce consistent line endings across file types --- .gitattributes | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e2b54a6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Set default behavior to automatically normalize line endings +* text=auto + +# Force all C# files to have LF line endings +*.cs text eol=lf +*.csproj text eol=lf +*.sln text eol=lf + +# Ensure specific files are treated as text +*.md text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf \ No newline at end of file From 60c305cc130e8bf1db192db64278bec9beaa4007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:42:31 +0200 Subject: [PATCH 7/9] attempt to force update --- src/QueryBuilder/ExpandBuilder.cs | 4 ++-- src/QueryBuilder/QueryExpressionBuilder.cs | 4 ++-- test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/QueryBuilder/ExpandBuilder.cs b/src/QueryBuilder/ExpandBuilder.cs index b2e4f81..71de052 100644 --- a/src/QueryBuilder/ExpandBuilder.cs +++ b/src/QueryBuilder/ExpandBuilder.cs @@ -10,9 +10,9 @@ public sealed class ExpandBuilder public bool IsCollection { get; } - public ExpandBuilder(string relationshipName, Type targetType, IQueryBuilder builder, bool isCollection) + public ExpandBuilder(string relationshipNamex, Type targetType, IQueryBuilder builder, bool isCollection) { - RelationshipName = relationshipName; + RelationshipName = relationshipNamex; TargetType = targetType; Builder = builder; IsCollection = isCollection; diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index 0a7aa69..c748f74 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -40,8 +40,8 @@ internal QueryExpressionBuilder( IAttributeNameResolver attributeNameResolver, IValueConverter valueConverter) { - var logicalNameProp = typeof(TEntity).GetField("EntityLogicalName"); - entityLogicalName = logicalNameProp?.GetValue(null) as string + var logicalNamePropx = typeof(TEntity).GetField("EntityLogicalName"); + entityLogicalName = logicalNamePropx?.GetValue(null) as string ?? typeof(TEntity).Name.ToLowerInvariant(); this.attributeNameResolver = attributeNameResolver ?? throw new ArgumentNullException(nameof(attributeNameResolver)); diff --git a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs index 5fb74cf..2122cdf 100644 --- a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs +++ b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs @@ -231,7 +231,7 @@ public void WhereGroup_WithEmptyGroup_DoesNotAddFilter() .OrWhereGroup(group => { /* Empty group */ }) .Build(); - // Assert - Only the regular Where condition should be present + // Assertx - Only the regular Where condition should be present Assert.Single(query.Criteria.Filters); var filter = query.Criteria.Filters[0]; Assert.Equal(LogicalOperator.And, filter.FilterOperator); From 0abcff5f0ffe2e2bab9ea7144cc9a438298635c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:44:41 +0200 Subject: [PATCH 8/9] attempt to update --- src/QueryBuilder/ExpandBuilder.cs | 6 +++--- src/QueryBuilder/QueryExpressionBuilder.cs | 6 +++--- test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/QueryBuilder/ExpandBuilder.cs b/src/QueryBuilder/ExpandBuilder.cs index 71de052..6b5d98e 100644 --- a/src/QueryBuilder/ExpandBuilder.cs +++ b/src/QueryBuilder/ExpandBuilder.cs @@ -10,12 +10,12 @@ public sealed class ExpandBuilder public bool IsCollection { get; } - public ExpandBuilder(string relationshipNamex, Type targetType, IQueryBuilder builder, bool isCollection) + public ExpandBuilder(string relationshipName, Type targetType, IQueryBuilder builder, bool isCollection) { - RelationshipName = relationshipNamex; + RelationshipName = relationshipName; TargetType = targetType; Builder = builder; IsCollection = isCollection; } } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/QueryExpressionBuilder.cs b/src/QueryBuilder/QueryExpressionBuilder.cs index c748f74..0c1bf87 100644 --- a/src/QueryBuilder/QueryExpressionBuilder.cs +++ b/src/QueryBuilder/QueryExpressionBuilder.cs @@ -40,8 +40,8 @@ internal QueryExpressionBuilder( IAttributeNameResolver attributeNameResolver, IValueConverter valueConverter) { - var logicalNamePropx = typeof(TEntity).GetField("EntityLogicalName"); - entityLogicalName = logicalNamePropx?.GetValue(null) as string + var logicalNameProp = typeof(TEntity).GetField("EntityLogicalName"); + entityLogicalName = logicalNameProp?.GetValue(null) as string ?? typeof(TEntity).Name.ToLowerInvariant(); this.attributeNameResolver = attributeNameResolver ?? throw new ArgumentNullException(nameof(attributeNameResolver)); @@ -438,4 +438,4 @@ private FilterExpression GetOrCreateAndFilter() return andFilter; } } -} +} \ No newline at end of file diff --git a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs index 2122cdf..a00ecb5 100644 --- a/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs +++ b/test/QueryBuilder.Tests/QueryExpressionBuilderTests.cs @@ -231,11 +231,11 @@ public void WhereGroup_WithEmptyGroup_DoesNotAddFilter() .OrWhereGroup(group => { /* Empty group */ }) .Build(); - // Assertx - Only the regular Where condition should be present + // Assert - Only the regular Where condition should be present Assert.Single(query.Criteria.Filters); var filter = query.Criteria.Filters[0]; Assert.Equal(LogicalOperator.And, filter.FilterOperator); Assert.Single(filter.Conditions); } } -} +} \ No newline at end of file From 4be2711f16c0fa177f42382dfce66ecb1e1bdd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20Gether=20S=C3=B8rensen?= Date: Thu, 10 Jul 2025 10:46:55 +0200 Subject: [PATCH 9/9] fix: Update .editorconfig to disable final newline and remove .gitattributes file --- .editorconfig | 2 +- .gitattributes | 13 ------------- .../Extensions/WhereGroupBuilderExtensions.cs | 2 +- src/QueryBuilder/IQueryBuilder.cs | 2 +- .../Interfaces/IAttributeNameResolver.cs | 2 +- src/QueryBuilder/Interfaces/IValueConverter.cs | 2 +- .../QueryExpressionBuilderExtensions.cs | 2 +- src/QueryBuilder/Services/AttributeNameResolver.cs | 2 +- src/QueryBuilder/Services/ValueConverter.cs | 2 +- src/QueryBuilder/WhereGroupBuilder.cs | 2 +- 10 files changed, 9 insertions(+), 22 deletions(-) delete mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig index 1912ba4..0cd4200 100644 --- a/.editorconfig +++ b/.editorconfig @@ -17,7 +17,7 @@ root = true charset = utf-8 indent_style = space indent_size = 4 -insert_final_newline = true +insert_final_newline = false trim_trailing_whitespace = true ########################################## diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index e2b54a6..0000000 --- a/.gitattributes +++ /dev/null @@ -1,13 +0,0 @@ -# Set default behavior to automatically normalize line endings -* text=auto - -# Force all C# files to have LF line endings -*.cs text eol=lf -*.csproj text eol=lf -*.sln text eol=lf - -# Ensure specific files are treated as text -*.md text eol=lf -*.json text eol=lf -*.yml text eol=lf -*.yaml text eol=lf \ No newline at end of file diff --git a/src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs b/src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs index 39d9b07..8559717 100644 --- a/src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs +++ b/src/QueryBuilder/Extensions/WhereGroupBuilderExtensions.cs @@ -164,4 +164,4 @@ public static WhereGroupBuilder WhereNotNull( return builder.Where(fieldSelector, ConditionOperator.NotNull); } } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/IQueryBuilder.cs b/src/QueryBuilder/IQueryBuilder.cs index 6bab783..55c2107 100644 --- a/src/QueryBuilder/IQueryBuilder.cs +++ b/src/QueryBuilder/IQueryBuilder.cs @@ -12,4 +12,4 @@ public interface IQueryBuilder LinkEntity BuildLinkEntity(ExpandBuilder expand); } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/Interfaces/IAttributeNameResolver.cs b/src/QueryBuilder/Interfaces/IAttributeNameResolver.cs index 29509af..5fe0a05 100644 --- a/src/QueryBuilder/Interfaces/IAttributeNameResolver.cs +++ b/src/QueryBuilder/Interfaces/IAttributeNameResolver.cs @@ -18,4 +18,4 @@ public interface IAttributeNameResolver string? GetAttributeName(Expression> fieldSelector) where TEntity : Entity; } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/Interfaces/IValueConverter.cs b/src/QueryBuilder/Interfaces/IValueConverter.cs index 5050b3b..78e30f5 100644 --- a/src/QueryBuilder/Interfaces/IValueConverter.cs +++ b/src/QueryBuilder/Interfaces/IValueConverter.cs @@ -13,4 +13,4 @@ public interface IValueConverter /// An array of converted values suitable for Dataverse queries. object[] ConvertValues(TValue[] values); } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/QueryExpressionBuilderExtensions.cs b/src/QueryBuilder/QueryExpressionBuilderExtensions.cs index ca13643..f3a70a2 100644 --- a/src/QueryBuilder/QueryExpressionBuilderExtensions.cs +++ b/src/QueryBuilder/QueryExpressionBuilderExtensions.cs @@ -14,4 +14,4 @@ public static IReadOnlyCollection RetrieveAll(this IOrganizati return result.Entities.Select(e => e.ToEntity()).ToArray(); } } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/Services/AttributeNameResolver.cs b/src/QueryBuilder/Services/AttributeNameResolver.cs index 8f0a00e..f672732 100644 --- a/src/QueryBuilder/Services/AttributeNameResolver.cs +++ b/src/QueryBuilder/Services/AttributeNameResolver.cs @@ -29,4 +29,4 @@ public sealed class AttributeNameResolver : IAttributeNameResolver }; } } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/Services/ValueConverter.cs b/src/QueryBuilder/Services/ValueConverter.cs index 63e4900..d8186e6 100644 --- a/src/QueryBuilder/Services/ValueConverter.cs +++ b/src/QueryBuilder/Services/ValueConverter.cs @@ -38,4 +38,4 @@ private static object ConvertValue(object value) }; } } -} +} \ No newline at end of file diff --git a/src/QueryBuilder/WhereGroupBuilder.cs b/src/QueryBuilder/WhereGroupBuilder.cs index 1de9121..c6ec268 100644 --- a/src/QueryBuilder/WhereGroupBuilder.cs +++ b/src/QueryBuilder/WhereGroupBuilder.cs @@ -69,4 +69,4 @@ public WhereGroupBuilder Where( /// True if this group has conditions, false otherwise. internal bool HasConditions => conditions.Count > 0; } -} +} \ No newline at end of file