From f82b52579867ada2f4a9ffe9ff2a5b3b4c12ce8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:23:59 +0000 Subject: [PATCH 1/7] Initial plan From 6fb029aef9645181ffd1d7d6d3169823b0694a1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:40:58 +0000 Subject: [PATCH 2/7] Implement IQueryable support for GetList/GetListAsync with LINQ to OData translation Co-authored-by: dnaumov <150417680+dnaumov@users.noreply.github.com> --- .../ApiClientExtensions.cs | 47 +++ .../EntityQueryProvider.cs | 228 ++++++++++++ .../EntityQueryable.cs | 135 +++++++ .../ExpressionToQueryParametersVisitor.cs | 334 +++++++++++++++++ Tests/RESTClientTests/QueryableTests.cs | 352 ++++++++++++++++++ 5 files changed, 1096 insertions(+) create mode 100644 Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs create mode 100644 Acumatica.RESTClient.ContractBasedApi/EntityQueryable.cs create mode 100644 Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs create mode 100644 Tests/RESTClientTests/QueryableTests.cs diff --git a/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs b/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs index aa91d7b2..d970b325 100644 --- a/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs +++ b/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs @@ -920,6 +920,53 @@ public static List GetList( return GetListAsync(client, endpointPath, select, filter, expand, custom, skip, top, customHeaders).GetAwaiter().GetResult(); } #endregion + #region AsQueryable + /// + /// Returns an IQueryable for the entity type, allowing LINQ queries to be translated to REST API calls. + /// LINQ Where clauses will be automatically converted to OData $filter parameters. + /// + /// The entity type + /// The API client + /// Optional parameter for endpoint path. If not provided, it is taken from the + /// The fields of the entity to be returned from the system. (optional) + /// The conditions that determine which records should be selected from the system. (optional) + /// The linked and detail entities that should be expanded. (optional) + /// The fields that are not defined in the contract of the endpoint to be returned from the system. (optional) + /// Custom headers to include in the request. (optional) + /// IQueryable that can be used with LINQ + /// + /// + /// // Simple Where clause + /// var activeCustomers = client.AsQueryable<Customer>() + /// .Where(c => c.Status == "Active") + /// .ToList(); + /// + /// // Multiple conditions + /// var customers = client.AsQueryable<Customer>() + /// .Where(c => c.Status == "Active" && c.CustomerName.Contains("ABC")) + /// .Take(10) + /// .ToList(); + /// + /// // Async execution + /// var customers = await client.AsQueryable<Customer>() + /// .Where(c => c.Status == "Active") + /// .ToListAsync(); + /// + /// + public static IQueryable AsQueryable( + this ApiClient client, + string? endpointPath = null, + string? select = null, + string? filter = null, + string? expand = null, + string? custom = null, + Dictionary? customHeaders = null) + where EntityType : ITopLevelEntity, new() + { + var provider = new EntityQueryProvider(client, endpointPath, select, filter, expand, custom, customHeaders); + return new EntityQueryable(provider); + } + #endregion #region GetSchema public static string GetSwagger(this ApiClient client, string endpointPath) diff --git a/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs b/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs new file mode 100644 index 00000000..286bcce2 --- /dev/null +++ b/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +using Acumatica.RESTClient.Client; + +namespace Acumatica.RESTClient.ContractBasedApi +{ + /// + /// Query provider that translates LINQ expressions to REST API calls + /// + public class EntityQueryProvider : IQueryProvider + { + private readonly ApiClient _client; + private readonly string? _endpointPath; + private readonly string? _initialSelect; + private readonly string? _initialFilter; + private readonly string? _initialExpand; + private readonly string? _initialCustom; + private readonly Dictionary? _customHeaders; + + public EntityQueryProvider( + ApiClient client, + string? endpointPath = null, + string? select = null, + string? filter = null, + string? expand = null, + string? custom = null, + Dictionary? customHeaders = null) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _endpointPath = endpointPath; + _initialSelect = select; + _initialFilter = filter; + _initialExpand = expand; + _initialCustom = custom; + _customHeaders = customHeaders; + } + + public IQueryable CreateQuery(Expression expression) + { + Type elementType = TypeHelper.GetElementType(expression.Type); + try + { + return (IQueryable)Activator.CreateInstance( + typeof(EntityQueryable<>).MakeGenericType(elementType), + new object[] { this, expression })!; + } + catch (TargetInvocationException tie) + { + throw tie.InnerException!; + } + } + + public IQueryable CreateQuery(Expression expression) + { + return new EntityQueryable(this, expression); + } + + public object? Execute(Expression expression) + { + return ExecuteAsync(expression, CancellationToken.None).GetAwaiter().GetResult(); + } + + public TResult Execute(Expression expression) + { + return ExecuteAsync(expression, CancellationToken.None).GetAwaiter().GetResult(); + } + + internal async Task ExecuteAsync(Expression expression, CancellationToken cancellationToken) + { + var queryParameters = ExpressionToQueryParametersVisitor.Translate(expression); + + // Merge initial filters with LINQ-generated filters + string? finalSelect = CombineParameters(_initialSelect, queryParameters.Select); + string? finalFilter = CombineFilters(_initialFilter, queryParameters.Filter); + string? finalExpand = CombineParameters(_initialExpand, queryParameters.Expand); + + // Determine the actual element type (entity type) for the query + Type elementType; + var methodCallExpression = expression as MethodCallExpression; + if (methodCallExpression != null && methodCallExpression.Arguments.Count > 0) + { + // Get element type from the source expression (the IQueryable) + elementType = TypeHelper.GetElementType(methodCallExpression.Arguments[0].Type); + } + else + { + elementType = TypeHelper.GetElementType(expression.Type); + } + + // Use reflection to call GetListAsync with the correct entity type + var getListAsyncMethod = typeof(ApiClientExtensions) + .GetMethod(nameof(ApiClientExtensions.GetListAsync))! + .MakeGenericMethod(elementType); + + var task = (Task)getListAsyncMethod.Invoke(null, new object?[] + { + _client, + _endpointPath, + finalSelect, + finalFilter, + finalExpand, + _initialCustom, + queryParameters.Skip, + queryParameters.Top, + _customHeaders, + cancellationToken + })!; + + await task.ConfigureAwait(false); + + var resultProperty = task.GetType().GetProperty("Result"); + var result = resultProperty!.GetValue(task); + + // Handle different return types + if (typeof(TResult).IsGenericType) + { + var genericTypeDef = typeof(TResult).GetGenericTypeDefinition(); + if (genericTypeDef == typeof(List<>)) + { + return (TResult)result!; + } + } + + if (result is System.Collections.IEnumerable enumerable) + { + var resultList = enumerable.Cast().ToList(); + + // Check if we need to return first/single/count/any + if (methodCallExpression != null) + { + string methodName = methodCallExpression.Method.Name; + + switch (methodName) + { + case "First": + case "FirstOrDefault": + return (TResult)(resultList.FirstOrDefault() ?? default(TResult)!); + case "Single": + case "SingleOrDefault": + return (TResult)(resultList.SingleOrDefault() ?? default(TResult)!); + case "Count": + case "LongCount": + return (TResult)(object)resultList.Count; + case "Any": + return (TResult)(object)resultList.Any(); + } + } + + return (TResult)result!; + } + + return (TResult)result!; + } + + private string? CombineParameters(string? param1, string? param2) + { + if (string.IsNullOrEmpty(param1)) return param2; + if (string.IsNullOrEmpty(param2)) return param1; + return $"{param1},{param2}"; + } + + private string? CombineFilters(string? filter1, string? filter2) + { + if (string.IsNullOrEmpty(filter1)) return filter2; + if (string.IsNullOrEmpty(filter2)) return filter1; + return $"({filter1}) and ({filter2})"; + } + } + + internal static class TypeHelper + { + internal static Type GetElementType(Type seqType) + { + Type? ienum = FindIEnumerable(seqType); + if (ienum == null) return seqType; + return ienum.GetGenericArguments()[0]; + } + + private static Type? FindIEnumerable(Type seqType) + { + if (seqType == null || seqType == typeof(string)) + return null; + + if (seqType.IsArray) + return typeof(IEnumerable<>).MakeGenericType(seqType.GetElementType()!); + + if (seqType.IsGenericType) + { + foreach (Type arg in seqType.GetGenericArguments()) + { + Type ienum = typeof(IEnumerable<>).MakeGenericType(arg); + if (ienum.IsAssignableFrom(seqType)) + return ienum; + } + } + + Type[] ifaces = seqType.GetInterfaces(); + if (ifaces.Length > 0) + { + foreach (Type iface in ifaces) + { + Type? ienum = FindIEnumerable(iface); + if (ienum != null) return ienum; + } + } + + if (seqType.BaseType != null && seqType.BaseType != typeof(object)) + return FindIEnumerable(seqType.BaseType); + + return null; + } + } + + internal class QueryParameters + { + public string? Select { get; set; } + public string? Filter { get; set; } + public string? Expand { get; set; } + public int? Skip { get; set; } + public int? Top { get; set; } + } +} diff --git a/Acumatica.RESTClient.ContractBasedApi/EntityQueryable.cs b/Acumatica.RESTClient.ContractBasedApi/EntityQueryable.cs new file mode 100644 index 00000000..a7785e9f --- /dev/null +++ b/Acumatica.RESTClient.ContractBasedApi/EntityQueryable.cs @@ -0,0 +1,135 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Acumatica.RESTClient.ContractBasedApi +{ + /// + /// Queryable wrapper for REST API entities + /// + /// Entity type + public class EntityQueryable : IOrderedQueryable + { + private readonly EntityQueryProvider _provider; + private readonly Expression _expression; + + public EntityQueryable(EntityQueryProvider provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _expression = Expression.Constant(this); + } + + public EntityQueryable(EntityQueryProvider provider, Expression expression) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + _expression = expression ?? throw new ArgumentNullException(nameof(expression)); + } + + public Type ElementType => typeof(T); + + public Expression Expression => _expression; + + public IQueryProvider Provider => _provider; + + public IEnumerator GetEnumerator() + { + var result = _provider.Execute>(_expression); + return result.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Executes the query asynchronously and returns the result as a list + /// + public async Task> ToListAsync(CancellationToken cancellationToken = default) + { + return await _provider.ExecuteAsync>(_expression, cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the query asynchronously and returns the first element + /// + public async Task FirstAsync(CancellationToken cancellationToken = default) + { + var expression = Expression.Call( + typeof(Queryable), + nameof(Queryable.First), + new[] { typeof(T) }, + _expression); + return await _provider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the query asynchronously and returns the first element or default + /// + public async Task FirstOrDefaultAsync(CancellationToken cancellationToken = default) + { + var expression = Expression.Call( + typeof(Queryable), + nameof(Queryable.FirstOrDefault), + new[] { typeof(T) }, + _expression); + return await _provider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the query asynchronously and returns the single element + /// + public async Task SingleAsync(CancellationToken cancellationToken = default) + { + var expression = Expression.Call( + typeof(Queryable), + nameof(Queryable.Single), + new[] { typeof(T) }, + _expression); + return await _provider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the query asynchronously and returns the single element or default + /// + public async Task SingleOrDefaultAsync(CancellationToken cancellationToken = default) + { + var expression = Expression.Call( + typeof(Queryable), + nameof(Queryable.SingleOrDefault), + new[] { typeof(T) }, + _expression); + return await _provider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the query asynchronously and returns the count + /// + public async Task CountAsync(CancellationToken cancellationToken = default) + { + var expression = Expression.Call( + typeof(Queryable), + nameof(Queryable.Count), + new[] { typeof(T) }, + _expression); + return await _provider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + + /// + /// Executes the query asynchronously and returns whether any elements exist + /// + public async Task AnyAsync(CancellationToken cancellationToken = default) + { + var expression = Expression.Call( + typeof(Queryable), + nameof(Queryable.Any), + new[] { typeof(T) }, + _expression); + return await _provider.ExecuteAsync(expression, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs b/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs new file mode 100644 index 00000000..cb795e59 --- /dev/null +++ b/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text; + +namespace Acumatica.RESTClient.ContractBasedApi +{ + /// + /// Visitor that translates LINQ expressions to OData query parameters + /// + internal class ExpressionToQueryParametersVisitor : ExpressionVisitor + { + private readonly QueryParameters _parameters = new QueryParameters(); + private readonly StringBuilder _filterBuilder = new StringBuilder(); + + public static QueryParameters Translate(Expression expression) + { + var visitor = new ExpressionToQueryParametersVisitor(); + visitor.Visit(expression); + return visitor._parameters; + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.DeclaringType == typeof(Queryable) || + node.Method.DeclaringType == typeof(Enumerable)) + { + switch (node.Method.Name) + { + case "Where": + Visit(node.Arguments[0]); + var lambda = (LambdaExpression)StripQuotes(node.Arguments[1]); + var filter = TranslateWhere(lambda.Body); + if (!string.IsNullOrEmpty(_parameters.Filter)) + { + _parameters.Filter = $"({_parameters.Filter}) and ({filter})"; + } + else + { + _parameters.Filter = filter; + } + return node; + + case "Select": + Visit(node.Arguments[0]); + var selectLambda = (LambdaExpression)StripQuotes(node.Arguments[1]); + _parameters.Select = TranslateSelect(selectLambda.Body); + return node; + + case "Take": + Visit(node.Arguments[0]); + _parameters.Top = (int)((ConstantExpression)node.Arguments[1]).Value!; + return node; + + case "Skip": + Visit(node.Arguments[0]); + _parameters.Skip = (int)((ConstantExpression)node.Arguments[1]).Value!; + return node; + + case "First": + case "FirstOrDefault": + Visit(node.Arguments[0]); + if (node.Arguments.Count > 1) + { + var firstLambda = (LambdaExpression)StripQuotes(node.Arguments[1]); + var firstFilter = TranslateWhere(firstLambda.Body); + if (!string.IsNullOrEmpty(_parameters.Filter)) + { + _parameters.Filter = $"({_parameters.Filter}) and ({firstFilter})"; + } + else + { + _parameters.Filter = firstFilter; + } + } + _parameters.Top = 1; + return node; + + case "Single": + case "SingleOrDefault": + Visit(node.Arguments[0]); + if (node.Arguments.Count > 1) + { + var singleLambda = (LambdaExpression)StripQuotes(node.Arguments[1]); + var singleFilter = TranslateWhere(singleLambda.Body); + if (!string.IsNullOrEmpty(_parameters.Filter)) + { + _parameters.Filter = $"({_parameters.Filter}) and ({singleFilter})"; + } + else + { + _parameters.Filter = singleFilter; + } + } + _parameters.Top = 2; // Take 2 to validate Single + return node; + + case "Count": + case "LongCount": + case "Any": + Visit(node.Arguments[0]); + if (node.Arguments.Count > 1) + { + var countLambda = (LambdaExpression)StripQuotes(node.Arguments[1]); + var countFilter = TranslateWhere(countLambda.Body); + if (!string.IsNullOrEmpty(_parameters.Filter)) + { + _parameters.Filter = $"({_parameters.Filter}) and ({countFilter})"; + } + else + { + _parameters.Filter = countFilter; + } + } + return node; + } + } + + return base.VisitMethodCall(node); + } + + private static Expression StripQuotes(Expression expression) + { + while (expression.NodeType == ExpressionType.Quote) + { + expression = ((UnaryExpression)expression).Operand; + } + return expression; + } + + private string TranslateWhere(Expression expression) + { + switch (expression.NodeType) + { + case ExpressionType.Equal: + return TranslateBinaryExpression((BinaryExpression)expression, "eq"); + case ExpressionType.NotEqual: + return TranslateBinaryExpression((BinaryExpression)expression, "ne"); + case ExpressionType.GreaterThan: + return TranslateBinaryExpression((BinaryExpression)expression, "gt"); + case ExpressionType.GreaterThanOrEqual: + return TranslateBinaryExpression((BinaryExpression)expression, "ge"); + case ExpressionType.LessThan: + return TranslateBinaryExpression((BinaryExpression)expression, "lt"); + case ExpressionType.LessThanOrEqual: + return TranslateBinaryExpression((BinaryExpression)expression, "le"); + case ExpressionType.AndAlso: + var andLeft = TranslateWhere(((BinaryExpression)expression).Left); + var andRight = TranslateWhere(((BinaryExpression)expression).Right); + return $"({andLeft}) and ({andRight})"; + case ExpressionType.OrElse: + var orLeft = TranslateWhere(((BinaryExpression)expression).Left); + var orRight = TranslateWhere(((BinaryExpression)expression).Right); + return $"({orLeft}) or ({orRight})"; + case ExpressionType.Not: + var operand = TranslateWhere(((UnaryExpression)expression).Operand); + return $"not ({operand})"; + case ExpressionType.Call: + return TranslateMethodCall((MethodCallExpression)expression); + case ExpressionType.MemberAccess: + // Handle boolean properties directly (e.g., entity.IsActive) + var memberExpr = (MemberExpression)expression; + if (memberExpr.Type == typeof(bool)) + { + return $"{GetMemberName(memberExpr)} eq true"; + } + break; + } + + throw new NotSupportedException($"Expression node type '{expression.NodeType}' is not supported in Where clause"); + } + + private string TranslateBinaryExpression(BinaryExpression expression, string operatorString) + { + var left = GetMemberName(expression.Left); + var right = GetValue(expression.Right); + return $"{left} {operatorString} {right}"; + } + + private string TranslateMethodCall(MethodCallExpression expression) + { + if (expression.Method.Name == "Contains" && expression.Method.DeclaringType == typeof(string)) + { + var obj = GetMemberName(expression.Object!); + var value = GetValue(expression.Arguments[0]); + return $"contains({obj},{value})"; + } + + if (expression.Method.Name == "StartsWith" && expression.Method.DeclaringType == typeof(string)) + { + var obj = GetMemberName(expression.Object!); + var value = GetValue(expression.Arguments[0]); + return $"startswith({obj},{value})"; + } + + if (expression.Method.Name == "EndsWith" && expression.Method.DeclaringType == typeof(string)) + { + var obj = GetMemberName(expression.Object!); + var value = GetValue(expression.Arguments[0]); + return $"endswith({obj},{value})"; + } + + throw new NotSupportedException($"Method '{expression.Method.Name}' is not supported in Where clause"); + } + + private string GetMemberName(Expression expression) + { + if (expression is MemberExpression memberExpr) + { + // Get the property/field name + string memberName = memberExpr.Member.Name; + + // Check if the member has a DataMember attribute with a custom name + var dataMemberAttr = memberExpr.Member.GetCustomAttributes(typeof(System.Runtime.Serialization.DataMemberAttribute), false) + .OfType() + .FirstOrDefault(); + + if (dataMemberAttr != null && !string.IsNullOrEmpty(dataMemberAttr.Name)) + { + memberName = dataMemberAttr.Name; + } + + // Handle nested properties + if (memberExpr.Expression is MemberExpression parentMember) + { + return $"{GetMemberName(parentMember)}/{memberName}"; + } + + return memberName; + } + + if (expression is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Convert) + { + return GetMemberName(unaryExpr.Operand); + } + + throw new NotSupportedException($"Expression type '{expression.NodeType}' is not supported for member access"); + } + + private string GetValue(Expression expression) + { + if (expression is ConstantExpression constantExpr) + { + return FormatValue(constantExpr.Value); + } + + if (expression is MemberExpression memberExpr) + { + var objectMember = Expression.Convert(memberExpr, typeof(object)); + var getterLambda = Expression.Lambda>(objectMember); + var getter = getterLambda.Compile(); + return FormatValue(getter()); + } + + if (expression is UnaryExpression unaryExpr) + { + if (unaryExpr.NodeType == ExpressionType.Convert) + { + return GetValue(unaryExpr.Operand); + } + } + + // Try to evaluate the expression + try + { + var objectMember = Expression.Convert(expression, typeof(object)); + var getterLambda = Expression.Lambda>(objectMember); + var getter = getterLambda.Compile(); + return FormatValue(getter()); + } + catch + { + throw new NotSupportedException($"Cannot get value from expression type '{expression.NodeType}'"); + } + } + + private string FormatValue(object? value) + { + if (value == null) + { + return "null"; + } + + if (value is string str) + { + return $"'{str.Replace("'", "''")}'"; + } + + if (value is DateTime dateTime) + { + return $"'{dateTime:yyyy-MM-ddTHH:mm:ss}'"; + } + + if (value is bool boolValue) + { + return boolValue ? "true" : "false"; + } + + if (value is Guid guid) + { + return $"guid'{guid}'"; + } + + // Numeric types + return value.ToString()!; + } + + private string TranslateSelect(Expression expression) + { + // Handle simple member access: x => x.PropertyName + if (expression is MemberExpression memberExpr) + { + return memberExpr.Member.Name; + } + + // Handle new expression: x => new { x.Prop1, x.Prop2 } + if (expression is NewExpression newExpr) + { + var properties = new List(); + foreach (var arg in newExpr.Arguments) + { + if (arg is MemberExpression member) + { + properties.Add(member.Member.Name); + } + } + return string.Join(",", properties); + } + + throw new NotSupportedException($"Select expression type '{expression.NodeType}' is not supported"); + } + } +} diff --git a/Tests/RESTClientTests/QueryableTests.cs b/Tests/RESTClientTests/QueryableTests.cs new file mode 100644 index 00000000..a8c93cc6 --- /dev/null +++ b/Tests/RESTClientTests/QueryableTests.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; + +using Acumatica.RESTClient.Client; +using Acumatica.RESTClient.ContractBasedApi; + +using RESTClientTests.Mocks; + +using Xunit; + +namespace RESTClientTests +{ + public class QueryableTests + { + [Fact] + public void AsQueryable_WithSimpleWhereClause_GeneratesCorrectFilter() + { + string? capturedFilter = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedFilter = GetQueryParameter(request.RequestUri, "$filter"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable() + .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); + + var result = query.ToList(); + + Assert.NotNull(capturedFilter); + Assert.Contains("Date/value", capturedFilter); + Assert.Contains("eq", capturedFilter); + Assert.Contains("2024-01-01", capturedFilter); + } + + [Fact] + public void AsQueryable_WithMultipleWhereConditions_GeneratesCorrectFilter() + { + string? capturedFilter = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedFilter = GetQueryParameter(request.RequestUri, "$filter"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable() + .Where(so => so.Date.Value == new DateTime(2024, 1, 1)) + .Where(so => so.Date.Value > new DateTime(2023, 12, 31)); + + var result = query.ToList(); + + Assert.NotNull(capturedFilter); + Assert.Contains("Date/value", capturedFilter); + Assert.Contains("eq", capturedFilter); + Assert.Contains("2024-01-01", capturedFilter); + Assert.Contains("and", capturedFilter); + Assert.Contains("gt", capturedFilter); + Assert.Contains("2023-12-31", capturedFilter); + } + + [Fact] + public void AsQueryable_WithAndCondition_GeneratesCorrectFilter() + { + string? capturedFilter = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedFilter = GetQueryParameter(request.RequestUri, "$filter"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var minDate = new DateTime(2023, 12, 31); + var maxDate = new DateTime(2024, 1, 1); + var query = client.AsQueryable() + .Where(so => so.Date.Value > minDate && so.Date.Value == maxDate); + + var result = query.ToList(); + + Assert.NotNull(capturedFilter); + Assert.Contains("Date/value", capturedFilter); + Assert.Contains("gt", capturedFilter); + Assert.Contains("2023-12-31", capturedFilter); + Assert.Contains("and", capturedFilter); + Assert.Contains("eq", capturedFilter); + Assert.Contains("2024-01-01", capturedFilter); + } + + [Fact] + public void AsQueryable_WithTake_GeneratesCorrectTopParameter() + { + string? capturedTop = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedTop = GetQueryParameter(request.RequestUri, "$top"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable() + .Take(5); + + var result = query.ToList(); + + Assert.Equal("5", capturedTop); + } + + [Fact] + public void AsQueryable_WithSkip_GeneratesCorrectSkipParameter() + { + string? capturedSkip = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedSkip = GetQueryParameter(request.RequestUri, "$skip"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable() + .Skip(10); + + var result = query.ToList(); + + Assert.Equal("10", capturedSkip); + } + + [Fact] + public void AsQueryable_WithSkipAndTake_GeneratesCorrectParameters() + { + string? capturedSkip = null; + string? capturedTop = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedSkip = GetQueryParameter(request.RequestUri, "$skip"); + capturedTop = GetQueryParameter(request.RequestUri, "$top"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable() + .Skip(10) + .Take(5); + + var result = query.ToList(); + + Assert.Equal("10", capturedSkip); + Assert.Equal("5", capturedTop); + } + + [Fact] + public void AsQueryable_WithCombinedConditions_GeneratesCorrectParameters() + { + string? capturedFilter = null; + string? capturedTop = null; + string? capturedSkip = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedFilter = GetQueryParameter(request.RequestUri, "$filter"); + capturedTop = GetQueryParameter(request.RequestUri, "$top"); + capturedSkip = GetQueryParameter(request.RequestUri, "$skip"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable() + .Where(so => so.Date.Value == new DateTime(2024, 1, 1)) + .Skip(5) + .Take(10); + + var result = query.ToList(); + + Assert.NotNull(capturedFilter); + Assert.Contains("Date/value", capturedFilter); + Assert.Contains("eq", capturedFilter); + Assert.Contains("2024-01-01", capturedFilter); + Assert.Equal("10", capturedTop); + Assert.Equal("5", capturedSkip); + } + + [Fact] + public async Task AsQueryable_WithToListAsync_ExecutesAsynchronously() + { + bool executed = false; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + executed = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = (EntityQueryable)client.AsQueryable() + .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); + + var result = await query.ToListAsync(); + + Assert.True(executed); + Assert.NotNull(result); + } + + [Fact] + public void AsQueryable_WithInitialFilter_CombinesFilters() + { + string? capturedFilter = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedFilter = GetQueryParameter(request.RequestUri, "$filter"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable(filter: "OrderNbr eq 'SO1234'") + .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); + + var result = query.ToList(); + + Assert.NotNull(capturedFilter); + Assert.Contains("OrderNbr", capturedFilter); + Assert.Contains("SO1234", capturedFilter); + Assert.Contains("and", capturedFilter); + Assert.Contains("Date/value", capturedFilter); + Assert.Contains("2024-01-01", capturedFilter); + } + + [Fact] + public async Task AsQueryable_WithFirstAsync_GeneratesTopOne() + { + string? capturedTop = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedTop = GetQueryParameter(request.RequestUri, "$top"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[{\"Date\": {\"value\": \"2024-01-01T00:00:00\"}}]") }; + })); + + var query = (EntityQueryable)client.AsQueryable() + .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); + + var result = await query.FirstOrDefaultAsync(); + + Assert.Equal("1", capturedTop); + } + + [Fact] + public async Task AsQueryable_WithCountAsync_ExecutesQuery() + { + bool executed = false; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + executed = true; + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[{\"Date\": {\"value\": \"2024-01-01T00:00:00\"}}, {\"Date\": {\"value\": \"2024-01-02T00:00:00\"}}]") }; + })); + + var query = (EntityQueryable)client.AsQueryable() + .Where(so => so.Date.Value > new DateTime(2023, 12, 31)); + + var count = await query.CountAsync(); + + Assert.True(executed); + Assert.Equal(2, count); + } + + [Fact] + public void AsQueryable_SupportsEnumerationDirectly() + { + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[{\"Date\": {\"value\": \"2024-01-01T00:00:00\"}}]") }; + })); + + var query = client.AsQueryable(); + + int count = 0; + foreach (var item in query) + { + count++; + } + + Assert.Equal(1, count); + } + + [Fact] + public void AsQueryable_WithComplexLinqQuery_GeneratesCorrectParameters() + { + string? capturedFilter = null; + string? capturedTop = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedFilter = GetQueryParameter(request.RequestUri, "$filter"); + capturedTop = GetQueryParameter(request.RequestUri, "$top"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var startDate = new DateTime(2024, 1, 1); + var endDate = new DateTime(2024, 12, 31); + + var result = client.AsQueryable() + .Where(so => so.Date.Value >= startDate && so.Date.Value <= endDate) + .Take(100) + .ToList(); + + Assert.NotNull(capturedFilter); + Assert.Contains("Date/value", capturedFilter); + Assert.Contains("ge", capturedFilter); + Assert.Contains("2024-01-01", capturedFilter); + Assert.Contains("le", capturedFilter); + Assert.Contains("2024-12-31", capturedFilter); + Assert.Contains("and", capturedFilter); + Assert.Equal("100", capturedTop); + } + + private string? GetQueryParameter(Uri? uri, string parameterName) + { + if (uri == null) return null; + + var query = uri.Query; + if (string.IsNullOrEmpty(query)) return null; + + var parameters = query.TrimStart('?').Split('&'); + foreach (var param in parameters) + { + var parts = param.Split('='); + if (parts.Length == 2 && parts[0] == parameterName) + { + return Uri.UnescapeDataString(parts[1]); + } + } + + return null; + } + } +} From b3cb4b2a9ba460967aeead1edda886e0c850feba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:43:05 +0000 Subject: [PATCH 3/7] Fix null reference warnings in expression visitor Co-authored-by: dnaumov <150417680+dnaumov@users.noreply.github.com> --- .../ExpressionToQueryParametersVisitor.cs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs b/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs index cb795e59..7b734bbb 100644 --- a/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs +++ b/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs @@ -50,12 +50,20 @@ protected override Expression VisitMethodCall(MethodCallExpression node) case "Take": Visit(node.Arguments[0]); - _parameters.Top = (int)((ConstantExpression)node.Arguments[1]).Value!; + var takeValue = ((ConstantExpression)node.Arguments[1]).Value; + if (takeValue != null) + { + _parameters.Top = (int)takeValue; + } return node; case "Skip": Visit(node.Arguments[0]); - _parameters.Skip = (int)((ConstantExpression)node.Arguments[1]).Value!; + var skipValue = ((ConstantExpression)node.Arguments[1]).Value; + if (skipValue != null) + { + _parameters.Skip = (int)skipValue; + } return node; case "First": @@ -302,8 +310,9 @@ private string FormatValue(object? value) return $"guid'{guid}'"; } - // Numeric types - return value.ToString()!; + // Numeric types and other value types + var strValue = value.ToString(); + return strValue ?? string.Empty; } private string TranslateSelect(Expression expression) From 4c92a9906d4da2d8e4efc0b5e6d7cf550d635c48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:44:48 +0000 Subject: [PATCH 4/7] Add documentation and example code for IQueryable support Co-authored-by: dnaumov <150417680+dnaumov@users.noreply.github.com> --- IQUERYABLE_GUIDE.md | 246 +++++++++++++++++++++++++++++++++++++++++++ IQueryableExample.cs | 124 ++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 IQUERYABLE_GUIDE.md create mode 100644 IQueryableExample.cs diff --git a/IQUERYABLE_GUIDE.md b/IQUERYABLE_GUIDE.md new file mode 100644 index 00000000..8e9646b5 --- /dev/null +++ b/IQUERYABLE_GUIDE.md @@ -0,0 +1,246 @@ +# IQueryable Support for REST API Client + +## Overview + +The Acumatica REST API Client now supports IQueryable, allowing you to use LINQ queries that are automatically translated to OData filter expressions. This makes querying the REST API more intuitive and type-safe. + +## Features + +### LINQ to OData Translation + +The client automatically translates LINQ `Where` clauses to OData `$filter` parameters: + +```csharp +// LINQ query +var activeCustomers = client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .ToList(); + +// Translates to REST call with: $filter=Status/value eq 'Active' +``` + +### Supported Operators + +#### Comparison Operators +- `==` → `eq` (equal) +- `!=` → `ne` (not equal) +- `>` → `gt` (greater than) +- `>=` → `ge` (greater than or equal) +- `<` → `lt` (less than) +- `<=` → `le` (less than or equal) + +```csharp +var orders = client.AsQueryable() + .Where(so => so.OrderTotal.Value >= 1000) + .ToList(); +// Generates: $filter=OrderTotal/value ge 1000 +``` + +#### Logical Operators +- `&&` → `and` +- `||` → `or` +- `!` → `not` + +```csharp +var customers = client.AsQueryable() + .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "RETAIL") + .ToList(); +// Generates: $filter=(Status/value eq 'Active') and (CustomerClass/value eq 'RETAIL') +``` + +#### String Methods +- `Contains()` → `contains()` +- `StartsWith()` → `startswith()` +- `EndsWith()` → `endswith()` + +```csharp +var customers = client.AsQueryable() + .Where(c => c.CustomerName.Value.Contains("ABC")) + .ToList(); +// Generates: $filter=contains(CustomerName/value,'ABC') +``` + +### Pagination + +Use `Take()` and `Skip()` for pagination: + +```csharp +var page2 = client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .Skip(20) + .Take(10) + .ToList(); +// Generates: $filter=Status/value eq 'Active'&$skip=20&$top=10 +``` + +### Async Execution + +All queries support async execution: + +```csharp +// ToListAsync +var customers = await client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .ToListAsync(); + +// FirstOrDefaultAsync +var customer = await ((EntityQueryable)client.AsQueryable() + .Where(c => c.CustomerID.Value == "CUST001")) + .FirstOrDefaultAsync(); + +// CountAsync +var count = await ((EntityQueryable)client.AsQueryable() + .Where(c => c.Status.Value == "Active")) + .CountAsync(); + +// AnyAsync +var hasActive = await ((EntityQueryable)client.AsQueryable() + .Where(c => c.Status.Value == "Active")) + .AnyAsync(); +``` + +### Combining Filters + +You can combine initial filters with LINQ queries: + +```csharp +var customers = client.AsQueryable( + filter: "Status eq 'Active'", + expand: "Contacts", + select: "CustomerID,CustomerName" +) + .Where(c => c.CustomerClass.Value == "RETAIL") + .Take(50) + .ToList(); +// Combines both filters with 'and' +``` + +## Usage Examples + +### Example 1: Simple Query +```csharp +var activeCustomers = client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .ToList(); +``` + +### Example 2: Multiple Conditions +```csharp +var orders = client.AsQueryable() + .Where(so => so.Status.Value == "Open" && so.OrderTotal.Value > 1000) + .Take(10) + .ToList(); +``` + +### Example 3: Date Range +```csharp +var startDate = new DateTime(2024, 1, 1); +var endDate = new DateTime(2024, 12, 31); + +var orders = client.AsQueryable() + .Where(so => so.Date.Value >= startDate && so.Date.Value <= endDate) + .ToList(); +``` + +### Example 4: Pattern Matching +```csharp +var customers = client.AsQueryable() + .Where(c => c.CustomerName.Value.StartsWith("A") && + c.CustomerClass.Value != "INACTIVE") + .ToList(); +``` + +### Example 5: Pagination +```csharp +int pageSize = 20; +int pageNumber = 2; + +var customers = client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToList(); +``` + +### Example 6: Async with LINQ +```csharp +var activeWholesaleCustomers = await client.AsQueryable() + .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "WHOLESALE") + .ToListAsync(); +``` + +## Important Notes + +### Property Name Translation + +The query provider automatically handles `DataMember` attribute names. For example, `Date.Value` in LINQ translates to `Date/value` in the OData filter, matching the JSON serialization. + +### Type Constraints + +The `AsQueryable()` method requires entities that implement `ITopLevelEntity` and have a parameterless constructor. + +### Limitations + +1. **OrderBy**: LINQ `OrderBy` is not translated to OData `$orderby`. You must use server-side ordering or apply ordering after fetching. + +2. **Select Projection**: While `Select()` for field projection is supported in the underlying API, complex LINQ projections are not translated. Use the `select` parameter for field selection: +```csharp +var customers = client.AsQueryable(select: "CustomerID,CustomerName") + .Where(c => c.Status.Value == "Active") + .ToList(); +``` + +3. **GroupBy/Join**: These operations are not supported as they don't map to OData queries. + +4. **Nested Collections**: Filtering on nested collection properties is limited. + +## Migration from GetList + +### Before (without IQueryable): +```csharp +var customers = client.GetList( + filter: "Status eq 'Active' and CustomerClass eq 'RETAIL'", + top: 10, + skip: 20 +); +``` + +### After (with IQueryable): +```csharp +var customers = client.AsQueryable() + .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "RETAIL") + .Skip(20) + .Take(10) + .ToList(); +``` + +Both approaches work, but IQueryable provides better type safety and IntelliSense support. + +## Performance Considerations + +- The query is not executed until you call a terminal operation like `ToList()`, `ToListAsync()`, `First()`, `Count()`, etc. +- The entire query is translated and sent to the server, so no client-side filtering occurs +- Use `Take()` to limit results and improve performance +- Consider using `CountAsync()` instead of loading all records just to count them + +## Error Handling + +If an expression cannot be translated to OData, you'll receive a `NotSupportedException`: + +```csharp +try +{ + var result = client.AsQueryable() + .Where(c => SomeUnsupportedMethod(c.Status.Value)) + .ToList(); +} +catch (NotSupportedException ex) +{ + Console.WriteLine($"Query translation error: {ex.Message}"); +} +``` + +## See Also + +- [Acumatica REST API Documentation](https://help.acumatica.com/Help?ScreenId=ShowWiki&pageid=4a6a5858-c3f0-42cc-8167-29f8e0367c80) +- [OData Filter Query Option](https://www.odata.org/getting-started/basic-tutorial/#queryData) diff --git a/IQueryableExample.cs b/IQueryableExample.cs new file mode 100644 index 00000000..797c5773 --- /dev/null +++ b/IQueryableExample.cs @@ -0,0 +1,124 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Acumatica.RESTClient.Client; +using Acumatica.RESTClient.ContractBasedApi; + +namespace IQueryableExample +{ + /// + /// Example demonstrating the new IQueryable support for GetList/GetListAsync methods. + /// This allows using LINQ Where conditions that are automatically translated to OData $filter parameters. + /// + class Program + { + static async Task Main(string[] args) + { + // Initialize API client (replace with your actual credentials and URL) + var client = new ApiClient("https://your-acumatica-instance.com"); + + // Example 1: Simple Where clause + Console.WriteLine("Example 1: Simple Where clause"); + var activeCustomers = client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .ToList(); + Console.WriteLine($"Found {activeCustomers.Count} active customers"); + + // Example 2: Multiple conditions with AND + Console.WriteLine("\nExample 2: Multiple conditions"); + var specificCustomers = client.AsQueryable() + .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "WHOLESALE") + .Take(10) + .ToList(); + Console.WriteLine($"Found {specificCustomers.Count} active wholesale customers"); + + // Example 3: Using Contains for pattern matching + Console.WriteLine("\nExample 3: String Contains"); + var searchResults = client.AsQueryable() + .Where(c => c.CustomerName.Value.Contains("ABC")) + .ToList(); + Console.WriteLine($"Found {searchResults.Count} customers with 'ABC' in name"); + + // Example 4: Date range filtering + Console.WriteLine("\nExample 4: Date range"); + var startDate = new DateTime(2024, 1, 1); + var endDate = new DateTime(2024, 12, 31); + var salesOrders = client.AsQueryable() + .Where(so => so.Date.Value >= startDate && so.Date.Value <= endDate) + .ToList(); + Console.WriteLine($"Found {salesOrders.Count} sales orders in 2024"); + + // Example 5: Pagination with Skip and Take + Console.WriteLine("\nExample 5: Pagination"); + var page2Customers = client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .OrderBy(c => c.CustomerID.Value) // Note: OrderBy must be done server-side + .Skip(20) + .Take(10) + .ToList(); + Console.WriteLine($"Page 2 (items 21-30): {page2Customers.Count} customers"); + + // Example 6: Async execution + Console.WriteLine("\nExample 6: Async execution"); + var asyncCustomers = await client.AsQueryable() + .Where(c => c.Status.Value == "Active") + .ToListAsync(); + Console.WriteLine($"Async query found {asyncCustomers.Count} customers"); + + // Example 7: Count without loading all data + Console.WriteLine("\nExample 7: Count"); + var count = await ((EntityQueryable)client.AsQueryable() + .Where(c => c.Status.Value == "Active")) + .CountAsync(); + Console.WriteLine($"Total active customers: {count}"); + + // Example 8: First or default + Console.WriteLine("\nExample 8: First or Default"); + var firstCustomer = await ((EntityQueryable)client.AsQueryable() + .Where(c => c.CustomerID.Value == "CUST001")) + .FirstOrDefaultAsync(); + if (firstCustomer != null) + { + Console.WriteLine($"Found customer: {firstCustomer.CustomerName?.Value}"); + } + + // Example 9: Combining initial filter with LINQ + Console.WriteLine("\nExample 9: Combining filters"); + var combined = client.AsQueryable(filter: "Status eq 'Active'") + .Where(c => c.CustomerClass.Value == "RETAIL") + .ToList(); + Console.WriteLine($"Active retail customers: {combined.Count}"); + + // Example 10: Complex boolean logic + Console.WriteLine("\nExample 10: Complex boolean logic"); + var complexQuery = client.AsQueryable() + .Where(c => (c.Status.Value == "Active" || c.Status.Value == "OnHold") + && c.CustomerClass.Value != "INACTIVE") + .ToList(); + Console.WriteLine($"Complex query results: {complexQuery.Count}"); + + Console.WriteLine("\nAll examples completed!"); + } + } + + // Simplified entity classes for demonstration + // In real usage, these would be generated from your Acumatica endpoint schema + public class Customer : Entity, ITopLevelEntity + { + public StringValue CustomerID { get; set; } + public StringValue CustomerName { get; set; } + public StringValue Status { get; set; } + public StringValue CustomerClass { get; set; } + + public string GetEndpointPath() => "entity/Default/22.200.001"; + } + + public class SalesOrder : Entity, ITopLevelEntity + { + public StringValue OrderNbr { get; set; } + public DateTimeValue Date { get; set; } + public StringValue Status { get; set; } + + public string GetEndpointPath() => "entity/Default/22.200.001"; + } +} From 950ffb487118eff2c16683c5371c1a2e9606ed74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:47:22 +0000 Subject: [PATCH 5/7] Address code review feedback: fix null handling and improve string escaping Co-authored-by: dnaumov <150417680+dnaumov@users.noreply.github.com> --- .../EntityQueryProvider.cs | 10 ++++++++-- .../ExpressionToQueryParametersVisitor.cs | 5 ++++- IQueryableExample.cs | 3 ++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs b/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs index 286bcce2..b8c823ae 100644 --- a/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs +++ b/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs @@ -140,10 +140,16 @@ internal async Task ExecuteAsync(Expression expression, Cancel { case "First": case "FirstOrDefault": - return (TResult)(resultList.FirstOrDefault() ?? default(TResult)!); + { + var firstResult = resultList.FirstOrDefault(); + return firstResult != null ? (TResult)firstResult : default!; + } case "Single": case "SingleOrDefault": - return (TResult)(resultList.SingleOrDefault() ?? default(TResult)!); + { + var singleResult = resultList.SingleOrDefault(); + return singleResult != null ? (TResult)singleResult : default!; + } case "Count": case "LongCount": return (TResult)(object)resultList.Count; diff --git a/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs b/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs index 7b734bbb..5619053c 100644 --- a/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs +++ b/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs @@ -292,7 +292,10 @@ private string FormatValue(object? value) if (value is string str) { - return $"'{str.Replace("'", "''")}'"; + // Escape single quotes for OData string literals + // Also escape backslashes to prevent injection + var escaped = str.Replace("\\", "\\\\").Replace("'", "''"); + return $"'{escaped}'"; } if (value is DateTime dateTime) diff --git a/IQueryableExample.cs b/IQueryableExample.cs index 797c5773..70983f67 100644 --- a/IQueryableExample.cs +++ b/IQueryableExample.cs @@ -52,7 +52,8 @@ static async Task Main(string[] args) Console.WriteLine("\nExample 5: Pagination"); var page2Customers = client.AsQueryable() .Where(c => c.Status.Value == "Active") - .OrderBy(c => c.CustomerID.Value) // Note: OrderBy must be done server-side + // Note: OrderBy is not supported in LINQ translation. + // Use the $orderby parameter on the REST API directly if ordering is needed. .Skip(20) .Take(10) .ToList(); From 26aab1660bea657342275f21149369eb2984005f Mon Sep 17 00:00:00 2001 From: Dmitrii Naumov Date: Mon, 3 Nov 2025 11:17:58 -0500 Subject: [PATCH 6/7] Value should not be part of the filer --- Tests/RESTClientTests/QueryableTests.cs | 33 ++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/Tests/RESTClientTests/QueryableTests.cs b/Tests/RESTClientTests/QueryableTests.cs index a8c93cc6..0e01d129 100644 --- a/Tests/RESTClientTests/QueryableTests.cs +++ b/Tests/RESTClientTests/QueryableTests.cs @@ -182,13 +182,44 @@ public void AsQueryable_WithCombinedConditions_GeneratesCorrectParameters() var result = query.ToList(); Assert.NotNull(capturedFilter); - Assert.Contains("Date/value", capturedFilter); + Assert.Contains("Date", capturedFilter); Assert.Contains("eq", capturedFilter); Assert.Contains("2024-01-01", capturedFilter); Assert.Equal("10", capturedTop); Assert.Equal("5", capturedSkip); } + [Fact] + public void AsQueryable_WithCombinedConditionsGT_GeneratesCorrectParameters() + { + string? capturedFilter = null; + string? capturedTop = null; + string? capturedSkip = null; + var client = new ApiClient( + new HttpClientMock(async (request, ct) => + { + capturedFilter = GetQueryParameter(request.RequestUri, "$filter"); + capturedTop = GetQueryParameter(request.RequestUri, "$top"); + capturedSkip = GetQueryParameter(request.RequestUri, "$skip"); + return new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("[]") }; + })); + + var query = client.AsQueryable() + .Where(so => so.Date > new DateTime(2024, 1, 1)) + .Skip(5) + .Take(10); + + var result = query.ToList(); + + Assert.NotNull(capturedFilter); + Assert.Contains("Date", capturedFilter); + Assert.Contains("gt", capturedFilter); + Assert.Contains("2024-01-01", capturedFilter); + Assert.Equal("10", capturedTop); + Assert.Equal("5", capturedSkip); + } + [Fact] public async Task AsQueryable_WithToListAsync_ExecutesAsynchronously() { From 32f24f47bec2ba26053c721cf1cde68ec26af508 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 4 Nov 2025 17:30:47 +0000 Subject: [PATCH 7/7] Change AsQueryable to GetList overload with asQueryable parameter Co-authored-by: dnaumov <150417680+dnaumov@users.noreply.github.com> --- .../ApiClientExtensions.cs | 17 ++++++--- IQUERYABLE_GUIDE.md | 38 +++++++++---------- IQueryableExample.cs | 20 +++++----- Tests/RESTClientTests/QueryableTests.cs | 28 +++++++------- 4 files changed, 54 insertions(+), 49 deletions(-) diff --git a/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs b/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs index d970b325..8912f8b2 100644 --- a/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs +++ b/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs @@ -919,14 +919,15 @@ public static List GetList( { return GetListAsync(client, endpointPath, select, filter, expand, custom, skip, top, customHeaders).GetAwaiter().GetResult(); } - #endregion - #region AsQueryable + /// /// Returns an IQueryable for the entity type, allowing LINQ queries to be translated to REST API calls. /// LINQ Where clauses will be automatically converted to OData $filter parameters. + /// This overload provides deferred execution - the query is not executed until enumerated. /// /// The entity type /// The API client + /// Set to true to return IQueryable for LINQ support /// Optional parameter for endpoint path. If not provided, it is taken from the /// The fields of the entity to be returned from the system. (optional) /// The conditions that determine which records should be selected from the system. (optional) @@ -937,24 +938,25 @@ public static List GetList( /// /// /// // Simple Where clause - /// var activeCustomers = client.AsQueryable<Customer>() + /// var activeCustomers = client.GetList<Customer>(asQueryable: true) /// .Where(c => c.Status == "Active") /// .ToList(); /// /// // Multiple conditions - /// var customers = client.AsQueryable<Customer>() + /// var customers = client.GetList<Customer>(asQueryable: true) /// .Where(c => c.Status == "Active" && c.CustomerName.Contains("ABC")) /// .Take(10) /// .ToList(); /// /// // Async execution - /// var customers = await client.AsQueryable<Customer>() + /// var customers = await client.GetList<Customer>(asQueryable: true) /// .Where(c => c.Status == "Active") /// .ToListAsync(); /// /// - public static IQueryable AsQueryable( + public static IQueryable GetList( this ApiClient client, + bool asQueryable, string? endpointPath = null, string? select = null, string? filter = null, @@ -963,6 +965,9 @@ public static IQueryable AsQueryable( Dictionary? customHeaders = null) where EntityType : ITopLevelEntity, new() { + if (!asQueryable) + throw new ArgumentException("This overload requires asQueryable to be true. Use the other GetList overload for immediate execution.", nameof(asQueryable)); + var provider = new EntityQueryProvider(client, endpointPath, select, filter, expand, custom, customHeaders); return new EntityQueryable(provider); } diff --git a/IQUERYABLE_GUIDE.md b/IQUERYABLE_GUIDE.md index 8e9646b5..fbe507a3 100644 --- a/IQUERYABLE_GUIDE.md +++ b/IQUERYABLE_GUIDE.md @@ -12,7 +12,7 @@ The client automatically translates LINQ `Where` clauses to OData `$filter` para ```csharp // LINQ query -var activeCustomers = client.AsQueryable() +var activeCustomers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") .ToList(); @@ -30,7 +30,7 @@ var activeCustomers = client.AsQueryable() - `<=` → `le` (less than or equal) ```csharp -var orders = client.AsQueryable() +var orders = client.GetList(asQueryable: true) .Where(so => so.OrderTotal.Value >= 1000) .ToList(); // Generates: $filter=OrderTotal/value ge 1000 @@ -42,7 +42,7 @@ var orders = client.AsQueryable() - `!` → `not` ```csharp -var customers = client.AsQueryable() +var customers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "RETAIL") .ToList(); // Generates: $filter=(Status/value eq 'Active') and (CustomerClass/value eq 'RETAIL') @@ -54,7 +54,7 @@ var customers = client.AsQueryable() - `EndsWith()` → `endswith()` ```csharp -var customers = client.AsQueryable() +var customers = client.GetList(asQueryable: true) .Where(c => c.CustomerName.Value.Contains("ABC")) .ToList(); // Generates: $filter=contains(CustomerName/value,'ABC') @@ -65,7 +65,7 @@ var customers = client.AsQueryable() Use `Take()` and `Skip()` for pagination: ```csharp -var page2 = client.AsQueryable() +var page2 = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") .Skip(20) .Take(10) @@ -79,22 +79,22 @@ All queries support async execution: ```csharp // ToListAsync -var customers = await client.AsQueryable() +var customers = await client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") .ToListAsync(); // FirstOrDefaultAsync -var customer = await ((EntityQueryable)client.AsQueryable() +var customer = await ((EntityQueryable)client.GetList(asQueryable: true) .Where(c => c.CustomerID.Value == "CUST001")) .FirstOrDefaultAsync(); // CountAsync -var count = await ((EntityQueryable)client.AsQueryable() +var count = await ((EntityQueryable)client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active")) .CountAsync(); // AnyAsync -var hasActive = await ((EntityQueryable)client.AsQueryable() +var hasActive = await ((EntityQueryable)client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active")) .AnyAsync(); ``` @@ -104,7 +104,7 @@ var hasActive = await ((EntityQueryable)client.AsQueryable() You can combine initial filters with LINQ queries: ```csharp -var customers = client.AsQueryable( +var customers = client.GetList(asQueryable: true, filter: "Status eq 'Active'", expand: "Contacts", select: "CustomerID,CustomerName" @@ -119,14 +119,14 @@ var customers = client.AsQueryable( ### Example 1: Simple Query ```csharp -var activeCustomers = client.AsQueryable() +var activeCustomers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") .ToList(); ``` ### Example 2: Multiple Conditions ```csharp -var orders = client.AsQueryable() +var orders = client.GetList(asQueryable: true) .Where(so => so.Status.Value == "Open" && so.OrderTotal.Value > 1000) .Take(10) .ToList(); @@ -137,14 +137,14 @@ var orders = client.AsQueryable() var startDate = new DateTime(2024, 1, 1); var endDate = new DateTime(2024, 12, 31); -var orders = client.AsQueryable() +var orders = client.GetList(asQueryable: true) .Where(so => so.Date.Value >= startDate && so.Date.Value <= endDate) .ToList(); ``` ### Example 4: Pattern Matching ```csharp -var customers = client.AsQueryable() +var customers = client.GetList(asQueryable: true) .Where(c => c.CustomerName.Value.StartsWith("A") && c.CustomerClass.Value != "INACTIVE") .ToList(); @@ -155,7 +155,7 @@ var customers = client.AsQueryable() int pageSize = 20; int pageNumber = 2; -var customers = client.AsQueryable() +var customers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") .Skip((pageNumber - 1) * pageSize) .Take(pageSize) @@ -164,7 +164,7 @@ var customers = client.AsQueryable() ### Example 6: Async with LINQ ```csharp -var activeWholesaleCustomers = await client.AsQueryable() +var activeWholesaleCustomers = await client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "WHOLESALE") .ToListAsync(); ``` @@ -185,7 +185,7 @@ The `AsQueryable()` method requires entities that implement `ITopLevelEntity` 2. **Select Projection**: While `Select()` for field projection is supported in the underlying API, complex LINQ projections are not translated. Use the `select` parameter for field selection: ```csharp -var customers = client.AsQueryable(select: "CustomerID,CustomerName") +var customers = client.GetList(asQueryable: true, select: "CustomerID,CustomerName") .Where(c => c.Status.Value == "Active") .ToList(); ``` @@ -207,7 +207,7 @@ var customers = client.GetList( ### After (with IQueryable): ```csharp -var customers = client.AsQueryable() +var customers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "RETAIL") .Skip(20) .Take(10) @@ -230,7 +230,7 @@ If an expression cannot be translated to OData, you'll receive a `NotSupportedEx ```csharp try { - var result = client.AsQueryable() + var result = client.GetList(asQueryable: true) .Where(c => SomeUnsupportedMethod(c.Status.Value)) .ToList(); } diff --git a/IQueryableExample.cs b/IQueryableExample.cs index 70983f67..5775112c 100644 --- a/IQueryableExample.cs +++ b/IQueryableExample.cs @@ -19,14 +19,14 @@ static async Task Main(string[] args) // Example 1: Simple Where clause Console.WriteLine("Example 1: Simple Where clause"); - var activeCustomers = client.AsQueryable() + var activeCustomers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") .ToList(); Console.WriteLine($"Found {activeCustomers.Count} active customers"); // Example 2: Multiple conditions with AND Console.WriteLine("\nExample 2: Multiple conditions"); - var specificCustomers = client.AsQueryable() + var specificCustomers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active" && c.CustomerClass.Value == "WHOLESALE") .Take(10) .ToList(); @@ -34,7 +34,7 @@ static async Task Main(string[] args) // Example 3: Using Contains for pattern matching Console.WriteLine("\nExample 3: String Contains"); - var searchResults = client.AsQueryable() + var searchResults = client.GetList(asQueryable: true) .Where(c => c.CustomerName.Value.Contains("ABC")) .ToList(); Console.WriteLine($"Found {searchResults.Count} customers with 'ABC' in name"); @@ -43,14 +43,14 @@ static async Task Main(string[] args) Console.WriteLine("\nExample 4: Date range"); var startDate = new DateTime(2024, 1, 1); var endDate = new DateTime(2024, 12, 31); - var salesOrders = client.AsQueryable() + var salesOrders = client.GetList(asQueryable: true) .Where(so => so.Date.Value >= startDate && so.Date.Value <= endDate) .ToList(); Console.WriteLine($"Found {salesOrders.Count} sales orders in 2024"); // Example 5: Pagination with Skip and Take Console.WriteLine("\nExample 5: Pagination"); - var page2Customers = client.AsQueryable() + var page2Customers = client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") // Note: OrderBy is not supported in LINQ translation. // Use the $orderby parameter on the REST API directly if ordering is needed. @@ -61,21 +61,21 @@ static async Task Main(string[] args) // Example 6: Async execution Console.WriteLine("\nExample 6: Async execution"); - var asyncCustomers = await client.AsQueryable() + var asyncCustomers = await client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active") .ToListAsync(); Console.WriteLine($"Async query found {asyncCustomers.Count} customers"); // Example 7: Count without loading all data Console.WriteLine("\nExample 7: Count"); - var count = await ((EntityQueryable)client.AsQueryable() + var count = await ((EntityQueryable)client.GetList(asQueryable: true) .Where(c => c.Status.Value == "Active")) .CountAsync(); Console.WriteLine($"Total active customers: {count}"); // Example 8: First or default Console.WriteLine("\nExample 8: First or Default"); - var firstCustomer = await ((EntityQueryable)client.AsQueryable() + var firstCustomer = await ((EntityQueryable)client.GetList(asQueryable: true) .Where(c => c.CustomerID.Value == "CUST001")) .FirstOrDefaultAsync(); if (firstCustomer != null) @@ -85,14 +85,14 @@ static async Task Main(string[] args) // Example 9: Combining initial filter with LINQ Console.WriteLine("\nExample 9: Combining filters"); - var combined = client.AsQueryable(filter: "Status eq 'Active'") + var combined = client.GetList(asQueryable: true, filter: "Status eq 'Active'") .Where(c => c.CustomerClass.Value == "RETAIL") .ToList(); Console.WriteLine($"Active retail customers: {combined.Count}"); // Example 10: Complex boolean logic Console.WriteLine("\nExample 10: Complex boolean logic"); - var complexQuery = client.AsQueryable() + var complexQuery = client.GetList(asQueryable: true) .Where(c => (c.Status.Value == "Active" || c.Status.Value == "OnHold") && c.CustomerClass.Value != "INACTIVE") .ToList(); diff --git a/Tests/RESTClientTests/QueryableTests.cs b/Tests/RESTClientTests/QueryableTests.cs index 0e01d129..6690c9aa 100644 --- a/Tests/RESTClientTests/QueryableTests.cs +++ b/Tests/RESTClientTests/QueryableTests.cs @@ -28,7 +28,7 @@ public void AsQueryable_WithSimpleWhereClause_GeneratesCorrectFilter() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); var result = query.ToList(); @@ -51,7 +51,7 @@ public void AsQueryable_WithMultipleWhereConditions_GeneratesCorrectFilter() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Where(so => so.Date.Value == new DateTime(2024, 1, 1)) .Where(so => so.Date.Value > new DateTime(2023, 12, 31)); @@ -80,7 +80,7 @@ public void AsQueryable_WithAndCondition_GeneratesCorrectFilter() var minDate = new DateTime(2023, 12, 31); var maxDate = new DateTime(2024, 1, 1); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Where(so => so.Date.Value > minDate && so.Date.Value == maxDate); var result = query.ToList(); @@ -106,7 +106,7 @@ public void AsQueryable_WithTake_GeneratesCorrectTopParameter() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Take(5); var result = query.ToList(); @@ -126,7 +126,7 @@ public void AsQueryable_WithSkip_GeneratesCorrectSkipParameter() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Skip(10); var result = query.ToList(); @@ -148,7 +148,7 @@ public void AsQueryable_WithSkipAndTake_GeneratesCorrectParameters() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Skip(10) .Take(5); @@ -174,7 +174,7 @@ public void AsQueryable_WithCombinedConditions_GeneratesCorrectParameters() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Where(so => so.Date.Value == new DateTime(2024, 1, 1)) .Skip(5) .Take(10); @@ -205,7 +205,7 @@ public void AsQueryable_WithCombinedConditionsGT_GeneratesCorrectParameters() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable() + var query = client.GetList(asQueryable: true) .Where(so => so.Date > new DateTime(2024, 1, 1)) .Skip(5) .Take(10); @@ -232,7 +232,7 @@ public async Task AsQueryable_WithToListAsync_ExecutesAsynchronously() { Content = new StringContent("[]") }; })); - var query = (EntityQueryable)client.AsQueryable() + var query = (EntityQueryable)client.GetList(asQueryable: true) .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); var result = await query.ToListAsync(); @@ -253,7 +253,7 @@ public void AsQueryable_WithInitialFilter_CombinesFilters() { Content = new StringContent("[]") }; })); - var query = client.AsQueryable(filter: "OrderNbr eq 'SO1234'") + var query = client.GetList(asQueryable: true, filter: "OrderNbr eq 'SO1234'") .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); var result = query.ToList(); @@ -278,7 +278,7 @@ public async Task AsQueryable_WithFirstAsync_GeneratesTopOne() { Content = new StringContent("[{\"Date\": {\"value\": \"2024-01-01T00:00:00\"}}]") }; })); - var query = (EntityQueryable)client.AsQueryable() + var query = (EntityQueryable)client.GetList(asQueryable: true) .Where(so => so.Date.Value == new DateTime(2024, 1, 1)); var result = await query.FirstOrDefaultAsync(); @@ -298,7 +298,7 @@ public async Task AsQueryable_WithCountAsync_ExecutesQuery() { Content = new StringContent("[{\"Date\": {\"value\": \"2024-01-01T00:00:00\"}}, {\"Date\": {\"value\": \"2024-01-02T00:00:00\"}}]") }; })); - var query = (EntityQueryable)client.AsQueryable() + var query = (EntityQueryable)client.GetList(asQueryable: true) .Where(so => so.Date.Value > new DateTime(2023, 12, 31)); var count = await query.CountAsync(); @@ -317,7 +317,7 @@ public void AsQueryable_SupportsEnumerationDirectly() { Content = new StringContent("[{\"Date\": {\"value\": \"2024-01-01T00:00:00\"}}]") }; })); - var query = client.AsQueryable(); + var query = client.GetList(asQueryable: true); int count = 0; foreach (var item in query) @@ -345,7 +345,7 @@ public void AsQueryable_WithComplexLinqQuery_GeneratesCorrectParameters() var startDate = new DateTime(2024, 1, 1); var endDate = new DateTime(2024, 12, 31); - var result = client.AsQueryable() + var result = client.GetList(asQueryable: true) .Where(so => so.Date.Value >= startDate && so.Date.Value <= endDate) .Take(100) .ToList();