Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Acumatica.RESTClient.ContractBasedApi/ApiClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,58 @@ public static List<EntityType> GetList<EntityType>(
{
return GetListAsync<EntityType>(client, endpointPath, select, filter, expand, custom, skip, top, customHeaders).GetAwaiter().GetResult();
}

/// <summary>
/// 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.
/// </summary>
/// <typeparam name="EntityType">The entity type</typeparam>
/// <param name="client">The API client</param>
/// <param name="asQueryable">Set to true to return IQueryable for LINQ support</param>
/// <param name="endpointPath">Optional parameter for endpoint path. If not provided, it is taken from the <typeparamref name="EntityType"/></param>
/// <param name="select">The fields of the entity to be returned from the system. (optional)</param>
/// <param name="filter">The conditions that determine which records should be selected from the system. (optional)</param>
/// <param name="expand">The linked and detail entities that should be expanded. (optional)</param>
/// <param name="custom">The fields that are not defined in the contract of the endpoint to be returned from the system. (optional)</param>
/// <param name="customHeaders">Custom headers to include in the request. (optional)</param>
/// <returns>IQueryable that can be used with LINQ</returns>
/// <example>
/// <code>
/// // Simple Where clause
/// var activeCustomers = client.GetList&lt;Customer&gt;(asQueryable: true)
/// .Where(c =&gt; c.Status == "Active")
/// .ToList();
///
/// // Multiple conditions
/// var customers = client.GetList&lt;Customer&gt;(asQueryable: true)
/// .Where(c =&gt; c.Status == "Active" &amp;&amp; c.CustomerName.Contains("ABC"))
/// .Take(10)
/// .ToList();
///
/// // Async execution
/// var customers = await client.GetList&lt;Customer&gt;(asQueryable: true)
/// .Where(c =&gt; c.Status == "Active")
/// .ToListAsync();
/// </code>
/// </example>
public static IQueryable<EntityType> GetList<EntityType>(
this ApiClient client,
bool asQueryable,
string? endpointPath = null,
string? select = null,
string? filter = null,
string? expand = null,
string? custom = null,
Dictionary<string, string>? 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<EntityType>(provider);
}
#endregion
#region GetSchema

Expand Down
234 changes: 234 additions & 0 deletions Acumatica.RESTClient.ContractBasedApi/EntityQueryProvider.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Query provider that translates LINQ expressions to REST API calls
/// </summary>
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<string, string>? _customHeaders;

public EntityQueryProvider(
ApiClient client,
string? endpointPath = null,
string? select = null,
string? filter = null,
string? expand = null,
string? custom = null,
Dictionary<string, string>? 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<TElement> CreateQuery<TElement>(Expression expression)
{
return new EntityQueryable<TElement>(this, expression);
}

public object? Execute(Expression expression)
{
return ExecuteAsync<object>(expression, CancellationToken.None).GetAwaiter().GetResult();
}

public TResult Execute<TResult>(Expression expression)
{
return ExecuteAsync<TResult>(expression, CancellationToken.None).GetAwaiter().GetResult();
}

internal async Task<TResult> ExecuteAsync<TResult>(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<object>().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; }
}
}
Loading