다음을 통해 공유


런타임 상태 기반 쿼리

대부분의 LINQ 쿼리에서 쿼리의 일반적인 모양은 코드에서 설정됩니다. where 절을 사용하여 항목을 필터링하고, orderby를 사용하여 출력 컬렉션을 정렬하고, 항목을 그룹화하거나 일부 계산을 수행할 수 있습니다. 코드에서는 필터, 정렬 키 또는 쿼리의 일부인 기타 식에 대한 매개 변수를 제공할 수 있습니다. 그러나 쿼리의 전체 모양은 변경할 수 없습니다. 이 문서에서는 런타임 시 쿼리 형태를 수정하기 위해 System.Linq.IQueryable<T> 인터페이스와 이를 구현하는 형식을 사용하는 기술을 알아봅니다.

이러한 기술을 사용하여 런타임에 쿼리를 빌드합니다. 여기서 일부 사용자 입력 또는 런타임 상태는 쿼리의 일부로 사용하려는 쿼리 메서드를 변경합니다. 쿼리 절을 추가, 제거 또는 편집하여 쿼리를 편집하려고 합니다.

참고 항목

using System.Linq.Expressions;using static System.Linq.Expressions.Expression;.cs 파일의 맨 위에 추가해야 합니다.

데이터 원본에 대해 IQueryable 또는 IQueryable<T>를 정의하는 코드를 고려합니다.

string[] companyNames = [
    "Consolidated Messenger", "Alpine Ski House", "Southridge Video",
    "City Power & Light", "Coho Winery", "Wide World Importers",
    "Graphic Design Institute", "Adventure Works", "Humongous Insurance",
    "Woodgrove Bank", "Margie's Travel", "Northwind Traders",
    "Blue Yonder Airlines", "Trey Research", "The Phone Company",
    "Wingtip Toys", "Lucerne Publishing", "Fourth Coffee"
];

// Use an in-memory array as the data source, but the IQueryable could have come
// from anywhere -- an ORM backed by a database, a web request, or any other LINQ provider.
IQueryable<string> companyNamesSource = companyNames.AsQueryable();
var fixedQry = companyNames.OrderBy(x => x);

앞의 코드를 실행할 때마다 동일한 쿼리가 실행됩니다. 쿼리를 확장하거나 수정하는 방법을 알아봅시다. 기본적으로 IQueryable에는 다음 두 가지 구성 요소가 있습니다.

  • Expression—식 트리 형식으로 현재 쿼리 구성 요소의 언어 중립적 및 데이터 소스 중립적 표현입니다.
  • Provider현재 쿼리를 값 또는 값 세트로 구체화하는 방법을 인식하는 LINQ 공급자의 인스턴스입니다.

동적 쿼리의 컨텍스트에서 공급자는 일반적으로 동일하게 유지됩니다. 쿼리의 식 트리는 쿼리마다 다릅니다.

식 트리는 변경할 수 없습니다. 다른 식 트리와 다른 쿼리를 원하는 경우 기존 식 트리를 새 식 트리로 변환해야 합니다. 다음 섹션에서는 런타임 상태에 대한 응답으로 다르게 쿼리하는 특정 기술을 설명합니다.

각 기술은 더 많은 기능을 제공하지만 복잡성이 증가합니다.

식 트리 내에서 런타임 상태 사용

동적으로 쿼리하는 가장 간단한 방법은 다음 코드 예의 length와 같은 폐쇄형 변수를 통해 쿼리에서 직접 런타임 상태를 참조하는 것입니다.

var length = 1;
var qry = companyNamesSource
    .Select(x => x.Substring(0, length))
    .Distinct();

Console.WriteLine(string.Join(",", qry));
// prints: C, A, S, W, G, H, M, N, B, T, L, F

length = 2;
Console.WriteLine(string.Join(",", qry));
// prints: Co, Al, So, Ci, Wi, Gr, Ad, Hu, Wo, Ma, No, Bl, Tr, Th, Lu, Fo

내부 식 트리와 쿼리는 수정되지 않습니다. 쿼리는 length 값이 변경되었기 때문에 다른 값을 반환합니다.

추가 LINQ 메서드 호출

일반적으로 Queryable기본 제공 LINQ 메서드는 다음 두 단계를 수행합니다.

원래 쿼리를 System.Linq.IQueryable<T> 반환 메서드의 결과로 바꿔서 새 쿼리를 가져올 수 있습니다. 다음 예와 같이 런타임 상태를 사용할 수 있습니다.

// bool sortByLength = /* ... */;

var qry = companyNamesSource;
if (sortByLength)
{
    qry = qry.OrderBy(x => x.Length);
}

LINQ 메서드에 전달된 식 트리 다양화

런타임 상태에 따라 다양한 식을 LINQ 메서드에 전달할 수 있습니다.

// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>> expr = (startsWith, endsWith) switch
{
    ("" or null, "" or null) => x => true,
    (_, "" or null) => x => x.StartsWith(startsWith),
    ("" or null, _) => x => x.EndsWith(endsWith),
    (_, _) => x => x.StartsWith(startsWith) || x.EndsWith(endsWith)
};

var qry = companyNamesSource.Where(expr);

LinqKitPredicateBuilder와 같은 다른 라이브러리를 사용하여 다양한 하위 식을 구성할 수도 있습니다.

// This is functionally equivalent to the previous example.

// using LinqKit;
// string? startsWith = /* ... */;
// string? endsWith = /* ... */;

Expression<Func<string, bool>>? expr = PredicateBuilder.New<string>(false);
var original = expr;
if (!string.IsNullOrEmpty(startsWith))
{
    expr = expr.Or(x => x.StartsWith(startsWith));
}
if (!string.IsNullOrEmpty(endsWith))
{
    expr = expr.Or(x => x.EndsWith(endsWith));
}
if (expr == original)
{
    expr = x => true;
}

var qry = companyNamesSource.Where(expr);

팩터리 메서드를 사용하여 식 트리 및 쿼리 생성

지금까지의 모든 예에서는 컴파일 시간의 요소 형식(string)과 쿼리 형식(IQueryable<string>)을 알 수 있었습니다. 요소 형식에 따라 모든 요소 형식의 쿼리에 구성 요소를 추가하거나 다른 구성 요소를 추가할 수 있습니다. System.Linq.Expressions.Expression에서 팩터리 메서드를 사용하여 처음부터 식 트리를 만들어서 런타임에 식을 특정 요소 형식에 맞게 조정할 수 있습니다.

식<TDelegate> 구문

LINQ 메서드 중 하나에 전달할 식을 생성하는 경우 실제로는 System.Linq.Expressions.Expression<TDelegate>의 인스턴스를 생성하는 것입니다. 여기서 TDelegateFunc<string, bool>, Action 또는 사용자 지정 대리자 형식과 같은 대리자 형식입니다.

System.Linq.Expressions.Expression<TDelegate>는 다음 예와 같이 완전한 람다 식을 나타내는 LambdaExpression에서 상속됩니다.

Expression<Func<string, bool>> expr = x => x.StartsWith("a");

LambdaExpression에는 다음 두 가지 구성 요소가 있습니다.

  1. 매개 변수 목록((string x))은 Parameters 속성으로 표시됩니다.
  2. 본문(x.StartsWith("a"))은 Body 속성으로 표시됩니다.

Expression<TDelegate>를 생성하는 기본 단계는 다음과 같습니다.

  1. Parameter 팩터리 메서드를 사용하여 람다 식에서 각 매개 변수(있는 경우)에 대해 ParameterExpression 개체를 정의합니다.
    ParameterExpression x = Parameter(typeof(string), "x");
    
  2. 정의된 ParameterExpressionExpression의 팩터리 메서드를 사용하여 LambdaExpression의 본문을 구성합니다. 예를 들어 x.StartsWith("a")를 나타내는 식은 다음과 같이 생성할 수 있습니다.
    Expression body = Call(
        x,
        typeof(string).GetMethod("StartsWith", [typeof(string)])!,
        Constant("a")
    );
    
  3. 적절한 Lambda 팩터리 메서드 오버로드를 사용하여 컴파일 시간 형식의 Expression<TDelegate>에 매개 변수와 본문을 래핑합니다.
    Expression<Func<string, bool>> expr = Lambda<Func<string, bool>>(body, x);
    

다음 섹션에서는 LINQ 메서드에 전달하기 위해 Expression<TDelegate>를 생성하려는 시나리오를 설명합니다. 팩터리 메서드를 사용하여 이를 수행하는 방법에 대한 완전한 예를 제공합니다.

런타임 시 전체 쿼리 구문

여러 엔터티 형식에 작동하는 쿼리를 작성하려고 합니다.

record Person(string LastName, string FirstName, DateTime DateOfBirth);
record Car(string Model, int Year);

해당 엔터티 형식에 대해 해당 string 필드 중 하나에 지정된 텍스트가 포함된 엔터티만 필터링하고 반환하려고 합니다. Person의 경우 FirstNameLastName 속성을 검색하려고 합니다.

string term = /* ... */;
var personsQry = new List<Person>()
    .AsQueryable()
    .Where(x => x.FirstName.Contains(term) || x.LastName.Contains(term));

그러나 Car의 경우 Model 속성만 검색하려고 합니다.

string term = /* ... */;
var carsQry = new List<Car>()
    .AsQueryable()
    .Where(x => x.Model.Contains(term));

IQueryable<Person>IQueryable<Car>에 대해 하나씩 사용자 지정 함수를 작성할 수 있지만, 다음 함수는 특정 요소 형식과 관계없이 이 필터링을 기존 쿼리에 추가합니다.

// using static System.Linq.Expressions.Expression;

IQueryable<T> TextFilter<T>(IQueryable<T> source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }

    // T is a compile-time placeholder for the element type of the query.
    Type elementType = typeof(T);

    // Get all the string properties on this specific type.
    PropertyInfo[] stringProperties = elementType
        .GetProperties()
        .Where(x => x.PropertyType == typeof(string))
        .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Get the right overload of String.Contains
    MethodInfo containsMethod = typeof(string).GetMethod("Contains", [typeof(string)])!;

    // Create a parameter for the expression tree:
    // the 'x' in 'x => x.PropertyName.Contains("term")'
    // The type of this parameter is the query's element type
    ParameterExpression prm = Parameter(elementType);

    // Map each property to an expression tree node
    IEnumerable<Expression> expressions = stringProperties
        .Select(prp =>
            // For each property, we have to construct an expression tree node like x.PropertyName.Contains("term")
            Call(                  // .Contains(...) 
                Property(          // .PropertyName
                    prm,           // x 
                    prp
                ),
                containsMethod,
                Constant(term)     // "term" 
            )
        );

    // Combine all the resultant expression nodes using ||
    Expression body = expressions
        .Aggregate((prev, current) => Or(prev, current));

    // Wrap the expression body in a compile-time-typed lambda expression
    Expression<Func<T, bool>> lambda = Lambda<Func<T, bool>>(body, prm);

    // Because the lambda is compile-time-typed (albeit with a generic parameter), we can use it with the Where method
    return source.Where(lambda);
}

TextFilter 함수는 IQueryable<T>만이 아니라 IQueryable을 사용하고 반환하기 때문에 텍스트 필터 뒤에 컴파일 시간 형식의 쿼리 요소를 더 추가할 수 있습니다.

var qry = TextFilter(
        new List<Person>().AsQueryable(),
        "abcd"
    )
    .Where(x => x.DateOfBirth < new DateTime(2001, 1, 1));

var qry1 = TextFilter(
        new List<Car>().AsQueryable(),
        "abcd"
    )
    .Where(x => x.Year == 2010);

IQueryable<TDelegate>의 식 트리에 메서드 호출 노드를 추가합니다.

IQueryable 대신 IQueryable<T>가 있는 경우 제네릭 LINQ 메서드를 직접 호출할 수 없습니다. 한 가지 대안은 이전 예에 표시된 대로 내부 식 트리를 빌드하고 리플렉션을 사용하여 식 트리를 전달하는 동안 적절한 LINQ 메서드를 호출하는 것입니다.

LINQ 메서드 호출을 나타내는 MethodCallExpression으로 트리 전체를 래핑하여 LINQ 메서드의 기능을 복제할 수도 있습니다.

IQueryable TextFilter_Untyped(IQueryable source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }
    Type elementType = source.ElementType;

    // The logic for building the ParameterExpression and the LambdaExpression's body is the same as in the previous example,
    // but has been refactored into the constructBody function.
    (Expression? body, ParameterExpression? prm) = constructBody(elementType, term);
    if (body is null) { return source; }

    Expression filteredTree = Call(
        typeof(Queryable),
        "Where",
        [elementType],
        source.Expression,
        Lambda(body, prm!)
    );

    return source.Provider.CreateQuery(filteredTree);
}

이 경우 컴파일 시간 T 제네릭 자리 표시자가 없으므로 컴파일 시간 형식 정보가 필요하지 않고 Lambda 대신 LambdaExpression을 생성하는 Expression<TDelegate> 오버로드를 사용합니다.

동적 LINQ 라이브러리

팩터리 메서드를 사용하여 식 트리를 생성하는 작업은 비교적 복잡하며, 문자열을 작성하는 작업이 더 쉽습니다. 동적 LINQ 라이브러리Queryable에서 표준 LINQ 메서드에 해당하며 식 트리 대신 특수 구문에서 문자열을 허용하는 IQueryable에 확장 메서드 세트를 공개합니다. 라이브러리는 문자열에서 적절한 식 트리를 생성하며 결과로 변환된 IQueryable을 반환할 수 있습니다.

예를 들어 이전 예제를 다음과 같이 다시 작성할 수 있습니다.

// using System.Linq.Dynamic.Core

IQueryable TextFilter_Strings(IQueryable source, string term)
{
    if (string.IsNullOrEmpty(term)) { return source; }

    var elementType = source.ElementType;

    // Get all the string property names on this specific type.
    var stringProperties =
        elementType.GetProperties()
            .Where(x => x.PropertyType == typeof(string))
            .ToArray();
    if (!stringProperties.Any()) { return source; }

    // Build the string expression
    string filterExpr = string.Join(
        " || ",
        stringProperties.Select(prp => $"{prp.Name}.Contains(@0)")
    );

    return source.Where(filterExpr, term);
}