diff --git a/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs b/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs index aa91d7b2..8912f8b2 100644 --- a/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs +++ b/Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs @@ -919,6 +919,58 @@ public static List GetList( { return GetListAsync(client, endpointPath, select, filter, expand, custom, skip, top, customHeaders).GetAwaiter().GetResult(); } + + /// + /// 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) + /// 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.GetList<Customer>(asQueryable: true) + /// .Where(c => c.Status == "Active") + /// .ToList(); + /// + /// // Multiple conditions + /// 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.GetList<Customer>(asQueryable: true) + /// .Where(c => c.Status == "Active") + /// .ToListAsync(); + /// + /// + public static IQueryable GetList( + this ApiClient client, + bool asQueryable, + string? endpointPath = null, + string? select = null, + string? filter = null, + string? expand = null, + string? custom = null, + 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); + } #endregion #region GetSchema diff --git a/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs b/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs new file mode 100644 index 00000000..b8c823ae --- /dev/null +++ b/Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs @@ -0,0 +1,234 @@ +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": + { + var firstResult = resultList.FirstOrDefault(); + return firstResult != null ? (TResult)firstResult : default!; + } + case "Single": + case "SingleOrDefault": + { + var singleResult = resultList.SingleOrDefault(); + return singleResult != null ? (TResult)singleResult : default!; + } + 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..5619053c --- /dev/null +++ b/Acumatica.RESTClient.ContractBasedApi/ExpressionToQueryParametersVisitor.cs @@ -0,0 +1,346 @@ +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]); + var takeValue = ((ConstantExpression)node.Arguments[1]).Value; + if (takeValue != null) + { + _parameters.Top = (int)takeValue; + } + return node; + + case "Skip": + Visit(node.Arguments[0]); + var skipValue = ((ConstantExpression)node.Arguments[1]).Value; + if (skipValue != null) + { + _parameters.Skip = (int)skipValue; + } + 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) + { + // 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) + { + 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 and other value types + var strValue = value.ToString(); + return strValue ?? string.Empty; + } + + 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/IQUERYABLE_GUIDE.md b/IQUERYABLE_GUIDE.md new file mode 100644 index 00000000..fbe507a3 --- /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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .Where(so => so.OrderTotal.Value >= 1000) + .ToList(); +// Generates: $filter=OrderTotal/value ge 1000 +``` + +#### Logical Operators +- `&&` → `and` +- `||` → `or` +- `!` → `not` + +```csharp +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') +``` + +#### String Methods +- `Contains()` → `contains()` +- `StartsWith()` → `startswith()` +- `EndsWith()` → `endswith()` + +```csharp +var customers = client.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .Where(c => c.Status.Value == "Active") + .ToListAsync(); + +// FirstOrDefaultAsync +var customer = await ((EntityQueryable)client.GetList(asQueryable: true) + .Where(c => c.CustomerID.Value == "CUST001")) + .FirstOrDefaultAsync(); + +// CountAsync +var count = await ((EntityQueryable)client.GetList(asQueryable: true) + .Where(c => c.Status.Value == "Active")) + .CountAsync(); + +// AnyAsync +var hasActive = await ((EntityQueryable)client.GetList(asQueryable: true) + .Where(c => c.Status.Value == "Active")) + .AnyAsync(); +``` + +### Combining Filters + +You can combine initial filters with LINQ queries: + +```csharp +var customers = client.GetList(asQueryable: true, + 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.GetList(asQueryable: true) + .Where(c => c.Status.Value == "Active") + .ToList(); +``` + +### Example 2: Multiple Conditions +```csharp +var orders = client.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .Where(so => so.Date.Value >= startDate && so.Date.Value <= endDate) + .ToList(); +``` + +### Example 4: Pattern Matching +```csharp +var customers = client.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .Where(c => c.Status.Value == "Active") + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .ToList(); +``` + +### Example 6: Async with LINQ +```csharp +var activeWholesaleCustomers = await client.GetList(asQueryable: true) + .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.GetList(asQueryable: true, 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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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..5775112c --- /dev/null +++ b/IQueryableExample.cs @@ -0,0 +1,125 @@ +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.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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.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.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. + .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.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.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.GetList(asQueryable: true) + .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.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.GetList(asQueryable: true) + .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"; + } +} diff --git a/Tests/RESTClientTests/QueryableTests.cs b/Tests/RESTClientTests/QueryableTests.cs new file mode 100644 index 00000000..6690c9aa --- /dev/null +++ b/Tests/RESTClientTests/QueryableTests.cs @@ -0,0 +1,383 @@ +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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .Where(so => so.Date.Value == new DateTime(2024, 1, 1)) + .Skip(5) + .Take(10); + + var result = query.ToList(); + + Assert.NotNull(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.GetList(asQueryable: true) + .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() + { + 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.GetList(asQueryable: true) + .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.GetList(asQueryable: true, 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.GetList(asQueryable: true) + .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.GetList(asQueryable: true) + .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.GetList(asQueryable: true); + + 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.GetList(asQueryable: true) + .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; + } + } +}